Concurrent collections and blocking queues
Concurrent collections and blocking queues#
TL;DR#
java.util.concurrent provides purpose-built thread-safe collections -
ConcurrentHashMap, CopyOnWriteArrayList, and the BlockingQueue family - that beat a
plain collection wrapped in your own locking on both correctness (they expose atomic
compound operations like computeIfAbsent) and performance (internally striped or
lock-free, instead of one coarse external lock). BlockingQueue additionally solves
thread hand-off: producer threads block when full, consumer threads block when empty, no
manual wait/notify required.
The idea#
java-concurrency/05 showed that wrapping a plain HashMap in an external lock (or using
Collections.synchronizedMap) still leaves compound operations (check-then-act, iterate-
while-mutating) unsafe unless you hold that lock across the entire compound sequence -
and holding one coarse lock across everything kills scalability under contention.
java.util.concurrent's collections were purpose-built to fix both problems: they provide
atomic versions of the compound operations you actually need, and they achieve
thread safety via internal designs (lock striping, copy-on-write, lock-free algorithms)
that scale far better than one external lock ever could.
How it works#
ConcurrentHashMap: the default concurrent map#
Internally partitions its keyspace so that different threads updating different keys
mostly don't contend with each other at all (historically via lock striping across
segments; current JDK implementations use finer-grained, largely lock-free
node-level synchronization) - unlike Collections.synchronizedMap(new HashMap<>()),
which serializes every access through one lock regardless of which keys are touched.
Critically, it exposes atomic compound operations that solve the exact check-then-act
hazard from java-concurrency/01 and java-concurrency/04 without you managing a lock:
ConcurrentHashMap<String, Widget> cache = new ConcurrentHashMap<>();
// Atomic "insert only if absent" - no race window between check and insert.
Widget w = cache.putIfAbsent(key, new Widget(key));
// Atomic "compute on demand" - the mapping function runs at most once per key,
// even under concurrent calls for the same key (the "expensive lazy init" pattern).
Widget w2 = cache.computeIfAbsent(key, k -> expensiveCreate(k));
// Atomic conditional update.
cache.replace(key, oldWidget, newWidget); // only replaces if current value == oldWidget
Iteration is weakly consistent, not fail-fast: iterating a ConcurrentHashMap while
another thread mutates it never throws ConcurrentModificationException (unlike a plain
HashMap), and is guaranteed to reflect the state at some point during the iteration,
but may or may not reflect updates made concurrently with the iteration itself. This is
usually exactly what you want (no crash, no external locking needed to iterate safely)
but means you cannot treat a concurrent iteration as an atomic snapshot.
size() is not necessarily exact under concurrent modification (it's an estimate for
performance reasons in some implementations/versions) - don't rely on it for anything
requiring precision under contention.
CopyOnWriteArrayList / CopyOnWriteArraySet#
Every mutating operation (add, remove, set) copies the entire underlying array and
atomically swaps the reference - readers always see a stable, unchanging snapshot with
zero locking overhead (a beneficial consequence of the immutability idiom from
java-concurrency/02 applied to the collection's storage). Iterators never throw
ConcurrentModificationException and never reflect concurrent mutations (they iterate the
snapshot taken at iterator-creation time).
This trades write cost (every write is O(n), a full array copy) for read cost (reads are plain array access, no locking, no CAS retry). Only appropriate when reads vastly outnumber writes - e.g. a list of registered event listeners, rarely modified but iterated on every event.
BlockingQueue: thread hand-off, not just storage#
A BlockingQueue<E> adds blocking semantics to a queue: put(e) blocks the calling
thread if the queue is full (for bounded implementations) until space is available;
take() blocks if the queue is empty until an element is available. This directly
implements the classic producer-consumer pattern without any manual
wait/notify/Object.wait() bookkeeping (java-concurrency/11 covers what a
BlockingQueue is built from internally, for when you need to build your own
synchronizer).
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100); // bounded, capacity 100
// Producer thread
queue.put(task); // blocks if queue is full - natural backpressure
// Consumer thread
Task t = queue.take(); // blocks if queue is empty - no busy-waiting/polling needed
Bounded vs. unbounded, and why bounding matters. LinkedBlockingQueue can be
constructed bounded or unbounded (default Integer.MAX_VALUE capacity - effectively
unbounded). An unbounded queue in a producer-consumer system with a producer faster than
its consumer will grow without limit, eventually exhausting heap memory - a slow-motion
OutOfMemoryError, not a fast, loud failure. A bounded queue converts that into
backpressure: producers naturally block (or, with offer, can be told immediately "no
room") once the consumer falls behind, which is almost always the behavior you actually
want in a real system (java-concurrency/15 builds on this for resilient service design).
Blocking queue implementations, compared#
| Implementation | Backing | Bounded? | Notes |
|---|---|---|---|
ArrayBlockingQueue |
fixed array | always bounded | single lock for put/take; simple, predictable |
LinkedBlockingQueue |
linked nodes | optional | separate put/take locks - higher throughput under concurrent producers and consumers |
PriorityBlockingQueue |
binary heap | unbounded | orders by Comparable/Comparator, not FIFO |
SynchronousQueue |
none (zero capacity) | n/a | put blocks until a take is waiting to receive that exact element - a direct thread hand-off with no buffering at all |
DelayQueue |
heap of delayed elements | unbounded | elements only become available for take() after their configured delay expires - useful for scheduling/retry-after |
Non-blocking variants: offer/poll with and without timeout#
Every BlockingQueue also supports non-blocking (offer(e) returns false instead of
blocking if full; poll() returns null instead of blocking if empty) and timed
(offer(e, timeout, unit), poll(timeout, unit)) variants - useful when unconditional
blocking isn't acceptable (e.g. you want to detect and log backpressure rather than
silently stall a thread forever).
Worked example: a bounded producer-consumer pipeline#
BlockingQueue<LogEntry> pending = new ArrayBlockingQueue<>(1000);
// N producer threads (e.g. request handlers)
void logAsync(LogEntry e) {
boolean accepted = pending.offer(e, 50, TimeUnit.MILLISECONDS);
if (!accepted) metrics.increment("log.dropped"); // explicit backpressure handling
}
// 1 consumer thread (the actual disk-writing worker)
void run() {
while (!Thread.currentThread().isInterrupted()) {
LogEntry e = pending.take(); // blocks until work arrives
writeToDisk(e);
}
}
This pipeline decouples request-handling threads (fast, must not block on disk I/O) from
the actual write (slow). The bounded capacity plus a timed offer gives an explicit,
observable failure mode (a dropped/counted log entry) instead of either unbounded memory
growth or an indefinitely blocked request thread.
Pros#
- Atomic compound operations (
putIfAbsent,computeIfAbsent,replace) remove entire classes of check-then-act bugs without any lock code in your own class. - Internal lock striping / lock-free design scales far better under contention than one external lock around a plain collection.
BlockingQueueimplements producer-consumer hand-off correctly and efficiently without hand-writtenwait/notify.- Weakly-consistent iteration means no
ConcurrentModificationExceptionrisk during concurrent use, unlike plain collections.
Cons#
- Weakly-consistent iteration is not a snapshot guarantee - don't rely on
ConcurrentHashMapiteration reflecting either all-before or all-after state for concurrent modifications. CopyOnWriteArrayList's O(n) writes make it a poor fit for write-heavy workloads.- An unbounded queue converts backpressure into a memory leak under sustained overload - almost always the wrong default for a production pipeline.
size()on some concurrent collections/queues can be approximate or O(n) to compute precisely - don't use it as a fast, exact check in a hot path.
Alternatives#
- Hand-rolled locking around a plain collection (
java-concurrency/04,java-concurrency/05) - only preferable when you need semantics no concurrent collection provides, and even then, usually better to compose from concurrent primitives than to lock a plainHashMap/ArrayListyourself. Exchanger(java-concurrency/11) - for a strict one-to-one, single-element hand-off between exactly two threads, rather than a general multi-producer/multi- consumer queue.- External message broker (Kafka, RabbitMQ) - when the producer-consumer
relationship needs to cross process/machine boundaries, persistence, or replay -
BlockingQueueis strictly in-process, in-memory.
When to use it#
Default to ConcurrentHashMap for any shared mutable map and BlockingQueue (bounded,
almost always) for any in-process producer-consumer hand-off. Use
CopyOnWriteArrayList specifically for small, rarely-mutated, frequently-iterated
collections like listener lists.
When NOT to use it#
Don't use CopyOnWriteArrayList for a collection with frequent writes or a large element
count - the per-write copy cost dominates. Don't use an unbounded queue in a producer-
consumer pipeline unless you've deliberately decided unlimited buffering is acceptable
(rare) - default to bounded with an explicit backpressure policy. Don't rely on
ConcurrentHashMap's weakly-consistent iteration where you need a true point-in-time
snapshot - build an immutable copy first if that's required.
Key takeaways / mental model#
Reach for java.util.concurrent's collections before hand-rolling locks around a plain
collection - they give you atomic compound operations and better scalability for free.
For any producer-consumer relationship, BlockingQueue handles the blocking and hand-off
correctly; the design decision that matters most is bounded-vs-unbounded, because that's
really a decision about your backpressure policy under overload.
Self-check questions#
- Why does
ConcurrentHashMap.computeIfAbsentavoid the check-then-act race that a manualif (!map.containsKey(k)) map.put(k, ...)on any map would have? - Explain why
CopyOnWriteArrayListis a poor choice for a collection with frequent writes, in terms of what happens on every mutating call. - What specifically goes wrong in a producer-consumer pipeline that uses an unbounded
LinkedBlockingQueuewhen the producer is consistently faster than the consumer? - Compare
ArrayBlockingQueueandSynchronousQueue: what does "capacity zero" mean forSynchronousQueue'sput/takesemantics?
References#
- Java Concurrency in Practice (Goetz, Peierls, Bloch, Bowbeer, Holmes, Lea), Chapter 5: "Building Blocks."