Concurrent Collections
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 of | Use | Notes |
|---|---|---|
HashMap | ConcurrentHashMap | The workhorse; fast under contention |
ArrayList (mostly reads) | CopyOnWriteArrayList | Writes copy the whole array — reads must dominate |
ArrayDeque as a queue | ConcurrentLinkedQueue | Non-blocking, unbounded |
| Handing work between threads | ArrayBlockingQueue | Blocks when full or empty |
TreeMap | ConcurrentSkipListMap | Sorted and concurrent |
HashSet | ConcurrentHashMap.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 absentcomputeIfAbsent(k, fn)— insert a computed value if absentmerge(k, v, fn)— combine with any existing valuereplace(k, old, new)— replace only if it currently holdsold
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:
| Need | Insert | Remove | Inspect |
|---|---|---|---|
| Throws on failure | add | remove | element |
| Returns a signal value | offer | poll | peek |
| Blocks until possible | put | take | — |
| Blocks with a timeout | offer(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
- Share nothing. Give each thread its own data and combine results at the end.
- Share immutable data. Anything that cannot change is safe to read from anywhere.
- Hand work over a
BlockingQueueinstead of sharing a variable. - Use a concurrent collection when threads genuinely must share a structure.
- 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
HashMaporArrayListacross threads. - Combining two atomic calls and assuming the pair is atomic.
CopyOnWriteArrayListfor 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 — useConcurrentHashMap.newKeySet().
Practice
- Have two threads add 10,000 items each to a shared
ArrayList. Run it several times and record the sizes and any exceptions. - Repeat with
ConcurrentHashMap.newKeySet()and confirm the count is stable. - Write a producer and consumer connected by an
ArrayBlockingQueueof capacity 10, with the producer faster. Confirm it blocks rather than growing. - Show that
containsKeyfollowed byputcan misbehave under load, then fix it withputIfAbsent. - Use a
ConcurrentHashMapandmergeto count word frequencies from several threads.
Hints
- The variety of failures is the point — sometimes short, sometimes an exception.
- Distinct values so the count is predictable.
- Print queue size in the producer to watch it cap out.
- Add a tiny sleep between the two calls to widen the window and make the race reliable.
counts.merge(word, 1, Integer::sum).