Java Concurrent Collections
Thread-safe collections in java.util.concurrent — ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue — and when to use each.
Java Concurrent Collections
HashMap, ArrayList, ArrayDeque — these are the everyday collections, and none of them are thread-safe. Using them from multiple threads without external synchronisation gives you lost updates, broken invariants, and the dreaded ConcurrentModificationException mid-iteration. The legacy answer was Collections.synchronizedMap(...), which wraps a regular map in a single big lock. That works, but it serialises every operation.
The java.util.concurrent package replaced the wrap-with-lock approach with collections designed for concurrent access from the ground up: lock-stripped, copy-on-write, and lock-free variants tuned for different read/write ratios. This chapter is the tour — what each class is best at and the failure modes you should know.
ConcurrentHashMap — the workhorse
The most-used concurrent collection in Java. A HashMap-shaped map you can use from many threads without external synchronisation:
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.put("hits", 1);
counts.merge("hits", 1, Integer::sum); // atomic add-or-increment
counts.computeIfAbsent("misses", k -> 0);
counts.computeIfPresent("hits", (k, v) -> v + 1);Three things make it fast under contention:
- Lock striping. Different keys are protected by different internal locks, so writes to unrelated keys don't block each other.
- Lock-free reads. Reads don't take a lock at all (in the steady state). A reader can race against a writer; the result is the old value or the new, never a corrupt one.
- Atomic compound updates.
merge,compute,computeIfAbsent, andputIfAbsentdo their check-then-act atomically. Without them, the unsynchronised patternif (!map.containsKey(k)) map.put(k, v)has a race window between the two calls; the atomic methods close it.
Use ConcurrentHashMap whenever a HashMap is touched by more than one thread. It's the right default — faster than Hashtable, faster than synchronizedMap, and supports atomic compound updates the others don't.
The one rule: null keys and null values are not allowed. containsKey(k) is reliable; map.get(k) == null is ambiguous (key absent vs. value null). Forbidding nulls removes the ambiguity.
ConcurrentSkipListMap — sorted concurrent map
When you need a TreeMap-shape (sorted by key) and concurrent access:
ConcurrentSkipListMap<Long, Event> byTimestamp = new ConcurrentSkipListMap<>();
byTimestamp.put(1700000000000L, e1);
byTimestamp.put(1700000005000L, e2);
byTimestamp.firstEntry(); // earliest
byTimestamp.lastEntry(); // latest
byTimestamp.subMap(start, end); // range queryBacked by a skip list (a probabilistic alternative to a balanced tree that's easier to make lock-free). It supports the full NavigableMap API. Slower than ConcurrentHashMap for plain key lookup; the right pick when you need ordered iteration or range queries.
CopyOnWriteArrayList — read-heavy small list
CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(myListener);
for (Listener l : listeners) l.onEvent(e); // never throws ConcurrentModificationExceptionEvery write copies the underlying array. Reads are wait-free — no lock, no synchronisation, no CME. The trade is obvious: every add/remove/set is O(n) because it copies the whole array.
That's a terrible trade for write-heavy workloads. It's a perfect trade for the workload it was designed for:
- A small list (tens, maybe hundreds, of items).
- Reads vastly outnumber writes.
- Iteration is common; it must never throw
CME.
The textbook use case: a list of event listeners, configuration entries, or registered subscribers. Reads happen on every event; writes happen at startup or when a component registers.
Don't use CopyOnWriteArrayList for "anything I might put in an ArrayList." For mutable shared collections that aren't tiny and read-mostly, use Collections.synchronizedList around an ArrayList, or rethink the data structure.
BlockingQueue — the producer/consumer queue
The single most useful abstraction in java.util.concurrent:
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1024);
queue.put(task); // blocks if full
queue.offer(task, 100, TimeUnit.MILLISECONDS); // blocks up to deadline
queue.add(task); // throws if full
Task t = queue.take(); // blocks if empty
Task t2 = queue.poll(100, TimeUnit.MILLISECONDS); // blocks up to deadline
Task t3 = queue.poll(); // returns null if emptyput and take are the blocking operations: they wait until the queue is non-full / non-empty. That's the entire backbone of the executor framework — every ThreadPoolExecutor internally holds a BlockingQueue of pending tasks; workers take from it; the public execute puts into it.
Common implementations:
| Class | Bounded? | When to use |
|---|---|---|
ArrayBlockingQueue(cap) | Yes — fixed cap | Fixed-size buffer; back-pressure on producer |
LinkedBlockingQueue() | No (or capped) | High-throughput general-purpose queue |
SynchronousQueue | 0 — direct handoff | Each put waits for a take; thread-to-thread handoff |
PriorityBlockingQueue | No | Tasks ordered by priority (not insertion) |
DelayQueue | No | Each element has a delay; only taken when delay expires |
ArrayBlockingQueue is the production default — it caps the in-flight work, which is essential for back-pressure. LinkedBlockingQueue without a cap is the trap behind Executors.newFixedThreadPool (unbounded queue → unbounded memory).
ConcurrentLinkedQueue and ConcurrentLinkedDeque — the unbounded lock-free ones
ConcurrentLinkedQueue<Event> events = new ConcurrentLinkedQueue<>();
events.add(e);
Event e = events.poll(); // null if empty; doesn't blockNon-blocking, lock-free, unbounded. poll returns null instead of blocking; there's no take. Best when:
- You want high throughput.
- You can tolerate "queue empty" being a fast return rather than a wait.
- You don't need back-pressure.
These are not BlockingQueues — pick them when you genuinely don't want the blocking semantics.
Iteration: weak consistency
A HashMap iterator throws ConcurrentModificationException if the map changes during iteration. Concurrent collections do something different: their iterators are weakly consistent. That means:
- They will not throw
ConcurrentModificationExceptioneven if other threads modify the collection. - They are guaranteed to see every element that was present when the iterator was created.
- They may or may not reflect modifications made after the iterator was created.
This is fine for most uses — a snapshot iterator is exactly what concurrent code wants. The trade: size() is also "weakly consistent" — for ConcurrentHashMap it's a best-effort count, not a guaranteed snapshot value. If you're treating size() as authoritative, you're misusing the API.
When to reach for which
A rough decision tree:
- Key-value store →
ConcurrentHashMap(default),ConcurrentSkipListMap(need sorted/range). - Read-heavy list of listeners →
CopyOnWriteArrayList. - Producer–consumer task queue →
ArrayBlockingQueue(bounded),LinkedBlockingQueue(don't need a cap),SynchronousQueue(direct handoff). - Priority queue across threads →
PriorityBlockingQueue. - Delayed schedule-this-later queue →
DelayQueue. - High-throughput lock-free non-blocking →
ConcurrentLinkedQueue/ConcurrentLinkedDeque. - Set →
ConcurrentHashMap.newKeySet(),CopyOnWriteArraySet,ConcurrentSkipListSet.
Whenever a regular collection is touched by more than one thread, pick a concurrent collection or wrap it with Collections.synchronizedX — never just hope it works.
A worked example: each collection doing its job
The program below demonstrates four concurrent collections under a shared workload — a ConcurrentHashMap counting events, a CopyOnWriteArrayList of listeners, an ArrayBlockingQueue for producer/consumer, and a ConcurrentLinkedQueue for lock-free append.
What to take from the run:
- Section 1's
ConcurrentHashMap.mergeproduced the exact expected count40,000. The merge function (Integer::sum) ran atomically per key, so two threads incrementing the same key never lost an update — the atomic compound update is the whole point. With a plainHashMap+put, you'd get a fraction of the expected value and probably also a corrupt internal state. - Section 2's
CopyOnWriteArrayListiterator saw[a, b, c](the snapshot at the moment the iterator was created). The writes that addeddandeduring iteration did not throwConcurrentModificationExceptionand were not seen by the in-flight iterator. The final list contained all five — the writes did happen, they were just invisible to the iterator that was already started. - Section 3's
ArrayBlockingQueuewith capacity 4 forced the producer to block onputwhenever the queue was full. The output showed the queue filling to 4, then producer pauses while consumer drains, then producer resumes. That's back-pressure done by the data structure: the producer can't run faster than the consumer, even without coordination code. - Section 4's
ConcurrentLinkedQueueaccepted writes from four threads with no blocking and no lock contention. The final drained count matched the appended count exactly — every element written was successfully read. The cost: notake()to wait on an empty queue;poll()returnsnulland you have to handle that yourself. - Throughout, the concurrent collections never threw
ConcurrentModificationException. That exception is a feature of the non-concurrent collections — it's the JVM's way of saying "you broke this." The concurrent collections are designed to be modified from multiple threads, so they don't need that signal.
What's next
The next chapter, Java Virtual Threads, covers the Java 21 feature that changes how you think about thread counts — lightweight threads scheduled by the JVM that make blocking I/O cheap again.
Practice
You need a thread-safe `Map` that's modified by many threads and supports atomic 'increment a counter under a key.' Which is the right pick?