Safe Shelves — Concurrent Collections

Priya’s QuickCart shop keeps growing. Many workers now touch the same data at once: inventory counts, shopping carts, and order queues. Priya tried wrapping a normal HashMap in synchronized blocks. It felt clumsy. Every read and write fought for the same big lock. She needs shelves built for many hands at once.

Why not a synchronized HashMap?

Java gives you Collections.synchronizedMap(new HashMap<>()). Each single operation is safe. But a compound operation — check then act — is not:

Map<String, Integer> inventory = Collections.synchronizedMap(new HashMap<>());

synchronized (inventory) {
    if (!inventory.containsKey("milk")) {
        inventory.put("milk", 50); // Two threads can both pass the check
    }
}

You must lock the whole map yourself for any logic that spans two steps. Easy to forget. Easy to get wrong.

Even worse, iterating and modifying at the same time can throw ConcurrentModificationException:

// BAD with synchronized map — still risky without external lock
for (String key : inventory.keySet()) {
    inventory.remove(key); // Can throw ConcurrentModificationException
}

The fix is a collection made for threads from the start.

ConcurrentHashMap — shared inventory

ConcurrentHashMap lets many threads read and write without wrapping every call in synchronized. Reads are fast and lock-free. Writes lock only a small part of the map (a bucket), not the whole thing. That scales much better than a synchronized HashMap.

import java.util.concurrent.ConcurrentHashMap;

ConcurrentHashMap<String, Integer> inventory = new ConcurrentHashMap<>();

inventory.put("milk", 50);
inventory.put("bread", 30);

int milkLeft = inventory.get("milk");
inventory.remove("milk");

Atomic operations — one step, no external lock

For “put only if missing” or “update in one step,” use built-in atomic methods. Never write check-then-act with separate get and put calls:

// Put only if key is absent — one atomic step
inventory.putIfAbsent("eggs", 100);

// Replace only if current value matches — one atomic step
inventory.replace("milk", 50, 48);

// Compute a new value from the old one
inventory.compute("milk", (product, count) ->
    count == null ? 0 : Math.max(0, count - 1)
);

// Get existing value, or create it
inventory.computeIfAbsent("butter", product -> 25);

// Update only if key already exists
inventory.computeIfPresent("milk", (product, count) -> count - 1);

// Merge — combine old and new values in one step
inventory.merge("milk", 10, (oldCount, added) -> oldCount + added);

The merge method is handy when you want to add to an existing count or insert a starting value if the key is new:

// If "bread" exists, add 5 to the count; if not, set it to 5
inventory.merge("bread", 5, Integer::sum);

Priya uses compute for live stock. When a customer buys milk, a worker subtracts one in a single atomic step. No external lock needed.

public class InventoryService {
    private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();

    public void restock(String product, int amount) {
        stock.put(product, amount);
    }

    public boolean tryPurchase(String product, int quantity) {
        Integer remaining = stock.compute(product, (name, count) -> {
            if (count == null || count < quantity) {
                return count; // Not enough — leave unchanged
            }
            return count - quantity;
        });
        return remaining != null && remaining >= 0;
    }

    public int getStock(String product) {
        return stock.getOrDefault(product, 0);
    }
}

Safe iteration

You can loop over a ConcurrentHashMap while other threads change it. You will not get ConcurrentModificationException. The iterator gives you a view of the map at that moment — it may not include entries added after you start:

for (Map.Entry<String, Integer> entry : inventory.entrySet()) {
    System.out.println(entry.getKey() + " = " + entry.getValue());
}

// Java 8+ forEach
inventory.forEach((product, count) ->
    System.out.println(product + " has " + count + " left")
);

Per-product counters inside the map

Priya tracks how many times each product sold today. She stores an AtomicInteger per product inside the map:

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

ConcurrentHashMap<String, AtomicInteger> salesToday = new ConcurrentHashMap<>();

public void recordSale(String product) {
    salesToday.computeIfAbsent(product, k -> new AtomicInteger(0))
              .incrementAndGet();
}

Each product gets its own atomic counter. Threads rarely fight over the same key unless the same product sells constantly — which is exactly when you want speed.

BlockingQueue — order pipeline

Sometimes one group of workers produces work and another group consumes it. Priya’s payment team sends confirmed orders. The shipping team picks them up. A normal queue is not enough — the shipper should wait when the queue is empty, and the payment worker should wait when the queue is full.

BlockingQueue does exactly that.

ArrayBlockingQueue — fixed size

A bounded array-backed queue. Good when you want back-pressure — producers slow down when the queue is full:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

BlockingQueue<String> orderQueue = new ArrayBlockingQueue<>(100);

// Producer — blocks if queue is full
orderQueue.put("ORDER-101");

// Consumer — blocks if queue is empty
String orderId = orderQueue.take();

// Non-blocking versions
boolean added = orderQueue.offer("ORDER-102"); // false if full
String next = orderQueue.poll();               // null if empty

Always check the return value of offer when you do not want to block:

boolean added = orderQueue.offer("ORDER-103");
if (!added) {
    // Queue is full — retry later or reject the order
    System.out.println("Order queue full — try again");
}

LinkedBlockingQueue — can grow

Unbounded by default (can grow until memory runs out). You can also set a limit:

import java.util.concurrent.LinkedBlockingQueue;

BlockingQueue<String> unboundedQueue = new LinkedBlockingQueue<>();

BlockingQueue<String> boundedQueue = new LinkedBlockingQueue<>(100);

Use a bounded LinkedBlockingQueue when memory matters. An unbounded queue under heavy load can cause out-of-memory errors.

PriorityBlockingQueue — urgent orders first

Orders with higher priority come out first. QuickCart VIP customers get fast shipping:

import java.util.concurrent.PriorityBlockingQueue;

BlockingQueue<String> priorityQueue = new PriorityBlockingQueue<>();

priorityQueue.put("standard-ORDER-1");
priorityQueue.put("vip-ORDER-2");
priorityQueue.put("standard-ORDER-3");

// Takes in priority order (depends on natural ordering of String)
String first = priorityQueue.take();

For real priority logic, wrap orders in a class that implements Comparable or pass a Comparator to the constructor.

SynchronousQueue — direct handoff

No internal storage. A producer blocks until a consumer takes the item, and vice versa. Like handing a package directly from one person to another:

import java.util.concurrent.SynchronousQueue;

BlockingQueue<String> handoff = new SynchronousQueue<>();

// Producer — blocks until consumer takes
new Thread(() -> {
    try {
        handoff.put("ORDER-200"); // Waits until someone takes it
        System.out.println("Handed off ORDER-200");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

// Consumer — blocks until producer puts
new Thread(() -> {
    try {
        String orderId = handoff.take(); // Waits until someone puts it
        System.out.println("Received: " + orderId);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

Producer-consumer for QuickCart

Priya wires up a simple pipeline:

public class OrderPipeline {
    private final BlockingQueue<String> queue = new ArrayBlockingQueue<>(50);

    public void submitOrder(String orderId) throws InterruptedException {
        queue.put(orderId);
        System.out.println("Queued: " + orderId);
    }

    public void shipOrders() throws InterruptedException {
        while (true) {
            String orderId = queue.take();
            packAndShip(orderId);
        }
    }

    private void packAndShip(String orderId) {
        System.out.println("Shipping: " + orderId);
    }
}

Full producer-consumer with a thread pool:

public class QuickCartOrderProcessor {
    private final BlockingQueue<String> queue = new ArrayBlockingQueue<>(10);

    public void producer() throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            queue.put("ORDER-" + i);
            System.out.println("Produced: ORDER-" + i);
        }
    }

    public void consumer() throws InterruptedException {
        while (true) {
            String orderId = queue.take();
            System.out.println("Consumed: " + orderId);
            // Process order
        }
    }
}

CopyOnWriteArrayList — the product catalog

QuickCart’s product list changes rarely. Thousands of customers browse it every minute. Priya needs safe reads without locking on every page view.

CopyOnWriteArrayList copies the whole backing array whenever someone adds or removes. Reads are fast and never block. Writes are slow — fine when writes are rare.

import java.util.concurrent.CopyOnWriteArrayList;

CopyOnWriteArrayList<String> catalog = new CopyOnWriteArrayList<>();

catalog.add("Organic Milk");
catalog.add("Whole Wheat Bread");

String first = catalog.get(0);
catalog.remove("Organic Milk");

// Safe to iterate while other threads modify the list
for (String item : catalog) {
    // Modifications during iteration won't affect this loop
    catalog.add("New Item"); // OK, but won't appear in current iteration
    System.out.println(item);
}

Do not use this for a list that changes constantly. Copying a huge list on every add would kill performance. Use it when reads far outnumber writes — like a product catalog or a list of event listeners.

public class CatalogService {
    private final CopyOnWriteArrayList<String> products = new CopyOnWriteArrayList<>();

    public void addProduct(String name) {
        products.add(name);
    }

    public List<String> getAllProducts() {
        return List.copyOf(products); // Snapshot for the caller
    }
}

Event listeners are a classic use case — many threads fire events, listeners are added or removed rarely:

public class OrderEventSource {
    private final CopyOnWriteArrayList<OrderListener> listeners =
        new CopyOnWriteArrayList<>();

    public void addListener(OrderListener listener) {
        listeners.add(listener);
    }

    public void fireOrderPlaced(String orderId) {
        for (OrderListener listener : listeners) {
            listener.onOrderPlaced(orderId);
        }
    }
}

ConcurrentLinkedQueue — non-blocking handoff

When you need a thread-safe queue but no blocking — producers and consumers never wait — use ConcurrentLinkedQueue. It is lock-free (uses CAS) and unbounded:

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> taskQueue = new ConcurrentLinkedQueue<>();

// Non-blocking operations only
taskQueue.offer("pack-ORDER-1");
String task = taskQueue.poll(); // Returns null if empty

if (!taskQueue.isEmpty()) {
    String next = taskQueue.poll();
}

Use it when:

  • Many producers and consumers run at high concurrency
  • You cannot afford to block (or you handle empty/full yourself)
  • The queue can grow without a fixed limit

Do not use it when you need a producer to wait for space or a consumer to wait for work — that is what BlockingQueue is for.

Picking the right shelf

Need Use
Shared map (inventory, carts) ConcurrentHashMap
Producer waits, consumer waits BlockingQueue
Many reads, few writes CopyOnWriteArrayList
High concurrency, non-blocking queue ConcurrentLinkedQueue

More detail:

Collection Thread-Safe Blocking Bounded Best for
ConcurrentHashMap Yes No No General shared maps
BlockingQueue Yes Yes Configurable Producer-consumer
CopyOnWriteArrayList Yes No No Read-heavy lists
ConcurrentLinkedQueue Yes No No Lock-free FIFO queue

Avoid Collections.synchronizedMap or Collections.synchronizedList when Java already gives you a concurrent version. The concurrent collections handle the hard parts for you.

What to remember

  • Collections.synchronizedMap protects single steps, not check-then-act logic.
  • ConcurrentHashMap is the default choice for thread-safe maps in QuickCart.
  • Use putIfAbsent, compute, merge, and friends for atomic map updates.
  • BlockingQueue connects producers and consumers with built-in waiting.
  • ArrayBlockingQueue is bounded; LinkedBlockingQueue can grow; PriorityBlockingQueue orders by priority.
  • CopyOnWriteArrayList fits read-heavy lists like product catalogs.
  • ConcurrentLinkedQueue is lock-free and non-blocking — different job than BlockingQueue.
  • Match the collection to how often data is read vs written.

What Priya does next: inventory counts are simple numbers. She learns AtomicInteger — a faster way to bump counters without a full lock.