Concurrent Collections

advanced30 min

Ordinary collections are not thread-safe

ArrayList, HashMap, and ArrayDeque assume one thread at a time. Used concurrently they do not merely lose an update — they can corrupt their internal structure.

List<Integer> shared = new ArrayList<>();

Runnable adder = () -> {
    for (int i = 0; i < 10_000; i++) shared.add(i);
};

Thread a = new Thread(adder);
Thread b = new Thread(adder);
a.start(); b.start(); a.join(); b.join();

System.out.println(shared.size());     // rarely 20000; may throw

add reads a size, writes an element, and updates the size. Two threads interleaving those steps can lose elements, overwrite each other, or leave the list in a state that throws ArrayIndexOutOfBoundsException from a later, innocent-looking call.

A corrupted HashMap has historically been able to produce an infinite loop on a subsequent get — a hang with no obvious cause, long after the damage was done.

The thread-safe options

What to use instead

Instead ofUseNotes
HashMapConcurrentHashMapThe workhorse; fast under contention
ArrayList (mostly reads)CopyOnWriteArrayListWrites copy the whole array — reads must dominate
ArrayDeque as a queueConcurrentLinkedQueueNon-blocking, unbounded
Handing work between threadsArrayBlockingQueueBlocks when full or empty
TreeMapConcurrentSkipListMapSorted and concurrent
HashSetConcurrentHashMap.newKeySet()There is no ConcurrentHashSet

ConcurrentHashMap

The one you will use most.

import java.util.concurrent.ConcurrentHashMap;

Map<String, Integer> counts = new ConcurrentHashMap<>();

counts.put("intake", 1);
counts.merge("intake", 1, Integer::sum);        // atomic increment
counts.computeIfAbsent("shooter", k -> 0);      // atomic

Individual operations are atomic and safe. But combining two operations is not:

// BROKEN — another thread can act between the get and the put
if (!counts.containsKey(key)) {
    counts.put(key, 0);
}

// CORRECT — one atomic operation
counts.putIfAbsent(key, 0);

Atomic per operation, not per sequence

This is the most common misunderstanding about concurrent collections. Each method is individually safe; a sequence of them is not.

Use the compound methods, which do the whole thing atomically:

  • putIfAbsent(k, v) — insert only if absent
  • computeIfAbsent(k, fn) — insert a computed value if absent
  • merge(k, v, fn) — combine with any existing value
  • replace(k, old, new) — replace only if it currently holds old

If none fits, you need external synchronisation — and at that point ask whether the design should share less.

Blocking queues

The best tool for concurrency, because it lets threads hand work to each other rather than share variables.

import java.util.concurrent.*;

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

// producer
new Thread(() -> {
    try {
        for (int i = 0; i < 50; i++) {
            queue.put("frame " + i);       // blocks if the queue is full
        }
        queue.put("DONE");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

// consumer
new Thread(() -> {
    try {
        while (true) {
            String item = queue.take();    // blocks until something arrives
            if (item.equals("DONE")) break;
            process(item);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

put and take handle the waiting for you. No polling, no sleeping, and no shared variable that either thread writes.

Why bounded queues matter

ArrayBlockingQueue has a fixed capacity. When it fills, put blocks until the consumer catches up.

That is a feature. An unbounded queue with a producer faster than its consumer grows until memory runs out — and the failure appears far from the cause. A bounded queue applies back-pressure instead, slowing the producer to match.

Pick a capacity and let the queue enforce it.

The “DONE” marker is a common way to signal completion, since a queue has no end-of-stream concept of its own.

The four method families

Blocking queues offer four behaviours for the same operation:

NeedInsertRemoveInspect
Throws on failureaddremoveelement
Returns a signal valueofferpollpeek
Blocks until possibleputtake
Blocks with a timeoutoffer(e, t, u)poll(t, u)

Choose deliberately. Using add on a full queue throws; offer silently returns false and drops the item — a quiet data-loss bug if you ignore the return value.

CopyOnWriteArrayList

Safe for concurrent use, but every write copies the entire backing array:

List<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(listener);              // copies the whole array

for (Listener l : listeners) {        // never throws ConcurrentModificationException
    l.onEvent();
}

Only appropriate when reads vastly outnumber writes — a listener list is the classic case. Using it for a collection written in a loop is much worse than a lock.

Its iteration is a genuine advantage: you get a snapshot, so modification during iteration cannot break the loop.

Synchronized wrappers

The older approach still appears in existing code:

List<Integer> list = Collections.synchronizedList(new ArrayList<>());

Every method is individually synchronized, but iteration is not — you must lock manually:

synchronized (list) {
    for (int v : list) { ... }
}

Prefer the purpose-built concurrent collections. They are faster and do not have this trap.

The best option is still to share nothing

Ordered by preference

  1. Share nothing. Give each thread its own data and combine results at the end.
  2. Share immutable data. Anything that cannot change is safe to read from anywhere.
  3. Hand work over a BlockingQueue instead of sharing a variable.
  4. Use a concurrent collection when threads genuinely must share a structure.
  5. Synchronise manually only when nothing above fits.

Each step down is more code and more ways to be subtly wrong. Concurrent collections are a good tool, but needing one is worth a moment’s thought about whether the design could avoid it.

Common mistakes

  • Using HashMap or ArrayList across threads.
  • Combining two atomic calls and assuming the pair is atomic.
  • CopyOnWriteArrayList for write-heavy use.
  • Ignoring offer’s return value and silently dropping items.
  • An unbounded queue with a fast producer.
  • Iterating a synchronized wrapper without holding its lock.
  • Looking for ConcurrentHashSet. It does not exist — use ConcurrentHashMap.newKeySet().

Practice

  1. Have two threads add 10,000 items each to a shared ArrayList. Run it several times and record the sizes and any exceptions.
  2. Repeat with ConcurrentHashMap.newKeySet() and confirm the count is stable.
  3. Write a producer and consumer connected by an ArrayBlockingQueue of capacity 10, with the producer faster. Confirm it blocks rather than growing.
  4. Show that containsKey followed by put can misbehave under load, then fix it with putIfAbsent.
  5. Use a ConcurrentHashMap and merge to count word frequencies from several threads.
Hints
  1. The variety of failures is the point — sometimes short, sometimes an exception.
  2. Distinct values so the count is predictable.
  3. Print queue size in the producer to watch it cap out.
  4. Add a tiny sleep between the two calls to widen the window and make the race reliable.
  5. counts.merge(word, 1, Integer::sum).

Related