Java Concurrent Collections: ConcurrentHashMap, BlockingQueue
Java concurrent collections deep dive: ConcurrentHashMap, BlockingQueue, CopyOnWriteArrayList, ConcurrentLinkedQueue, and choosing the right structure.
Java Concurrent Collections: ConcurrentHashMap, BlockingQueue, and More
The synchronized collections (Collections.synchronizedMap()) serialize all access to your map. That’s fine for low contention, but hammer it with multiple threads and you’ll watch your CPU utilization drop while threads wait in line. Java’s concurrent collections are designed for actual concurrent access. Let’s see where each one belongs.
Introduction
synchronized collections serialize all access. One thread goes, everyone else waits. This works for low contention, but hammer a Collections.synchronizedMap() with multiple threads and you watch CPU utilization drop while threads queue up. Java’s concurrent collections are designed for actual concurrent access, letting readers proceed without blocking and writers proceed with fine-grained locking rather than global serialization.
The concurrent collections hierarchy serves different access patterns. ConcurrentHashMap segments its data into buckets with independent locks, allowing concurrent reads and writes on different buckets with minimal contention. BlockingQueue adds blocking operations for producer-consumer patterns where producers need to wait when the queue is full and consumers need to wait when it is empty. CopyOnWriteArrayList copies its entire array on every write, making writes expensive but reads never block or see partial modifications—ideal for listener lists where reads vastly outnumber writes.
This post covers how ConcurrentHashMap achieves concurrent reads without locking, when to use bounded versus unbounded queues, why SynchronousQueue is a zero-capacity handoff rather than a traditional queue, how LongAdder outperforms AtomicLong under high contention by spreading updates across striped cells, and the common pitfalls that catch engineers: null policy surprises in ConcurrentHashMap, unbounded queues causing OOM, and the thundering herd problem when multiple threads hit computeIfAbsent for the same missing key.
ConcurrentHashMap
Why Not Just Use synchronizedMap?
// This serializes all access - one thread at a time
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
syncMap.put("key", 1); // Only one thread can touch this at a time
// ConcurrentHashMap allows concurrent reads and writes
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
chm.put("key", 1); // Multiple threads can write concurrently
How ConcurrentHashMap Works
ConcurrentHashMap segments its data into buckets, each protected by its own lock. Reads don’t block at all - they see the latest written values thanks to volatile semantics.
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// Atomic operations - no synchronized needed
map.putIfAbsent("key", 0); // Put only if absent
map.replace("key", 0, 1); // Replace only if current value matches
map.computeIfAbsent("key", k -> expensiveComputation()); // Lazy init
map.merge("key", 1, Integer::sum); // Combine values
// Bulk operations - iterate without ConcurrentModificationException
map.forEach(1, (k, v) -> System.out.println(k + "=" + v));
map.search(1, (k, v) -> v > 100 ? k : null); // Search with concurrency
Iteration in ConcurrentHashMap
Unlike regular iterators, ConcurrentHashMap iterators are weakly consistent:
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("a", 1);
map.put("b", 2);
// This won't throw ConcurrentModificationException
for (Map.Entry<String, Integer> entry : map.entrySet()) {
// May or may not see updates that happened during iteration
// but won't repeat any entries
System.out.println(entry.getKey() + "=" + entry.getValue());
}
The iterator may reflect changes that occurred during iteration, but it won’t throw CME and won’t revisit entries.
Null Handling
Important: ConcurrentHashMap does not allow null keys or values. This was a deliberate design decision to avoid ambiguity in concurrent contexts.
map.put(null, 1); // NullPointerException
map.get(null); // NullPointerException
If you need null support, consider Collections.synchronizedMap() or a plain map with external synchronization.
BlockingQueue
BlockingQueue adds blocking operations for producer-consumer patterns. Producers block when the queue is full, consumers block when empty.
BlockingQueue<Order> queue = new LinkedBlockingQueue<>(1000); // Bounded
// Producer side - blocks if queue is full
public void enqueue(Order order) throws InterruptedException {
queue.put(order); // Blocks until space available
}
// Consumer side - blocks if queue is empty
public Order dequeue() throws InterruptedException {
return queue.take(); // Blocks until item available
}
// Non-blocking alternatives
queue.offer(order, 1, TimeUnit.SECONDS); // Wait up to 1 second
queue.poll(1, TimeUnit.SECONDS); // Wait up to 1 second
Bounded vs Unbounded Queues
// Unbounded - grows as needed, can cause OOM
LinkedBlockingQueue<Order> unbounded = new LinkedBlockingQueue<>();
// Bounded - limits memory usage
LinkedBlockingQueue<Order> bounded = new LinkedBlockingQueue<>(1000);
// Array-based - more predictable performance
ArrayBlockingQueue<Order> arrayQueue = new ArrayBlockingQueue<>(1000);
// Priority-based - keeps elements sorted
PriorityBlockingQueue<Order> priorityQueue = new PriorityBlockingQueue<>();
Implementations
| Implementation | Bounded | Notes |
|---|---|---|
| LinkedBlockingQueue | Optional | Most common |
| ArrayBlockingQueue | Yes | Fixed size, array-backed |
| PriorityBlockingQueue | No | Priority queue |
| DelayQueue | No | Elements with delays |
| SynchronousQueue | Yes (capacity 0) | Handoff - no storage |
SynchronousQueue for Handoffs
SynchronousQueue has no capacity - a put must wait for a take and vice versa:
SynchronousQueue<Task> handoff = new SynchronousQueue<>();
// Producer
new Thread(() -> {
try {
// Blocks until consumer takes
handoff.put(task);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
// Consumer
Task task = handoff.take(); // Blocks until producer puts
Use this when you want direct handoff between threads without buffering.
CopyOnWriteArrayList
CopyOnWriteArrayList creates a fresh copy on every write. Reads are lock-free and never see partial modifications.
CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
// Readers - never block, never see partial writes
public void notifyListeners() {
for (Listener listener : listeners) {
listener.onEvent(); // Safe iteration
}
}
// Writers - expensive, but no readers ever blocked
public void addListener(Listener listener) {
listeners.add(listener); // New copy created
}
When to Use CopyOnWriteArrayList
Good for:
- Listener lists where reads vastly outnumber writes
- Cached configurations read frequently but updated rarely
- Situations where iteration must not throw CME
Bad for:
- Write-heavy workloads (every write copies the entire array)
- Large lists (copying costs grow with size)
- When you need to modify during iteration (use iterator.remove())
When NOT to Use Concurrent Collections
Concurrent collections are not always the right call, and using them reflexively creates problems. If your collection is only ever accessed from one thread — which is common in simpler applications or narrow scopes — a plain ArrayList, HashMap, or ArrayDeque is faster and introduces no concurrency overhead. The lock-free algorithms in ConcurrentHashMap and its siblings perform memory ordering operations on every read and write, and that cost is wasted if no other thread is actually competing.
Snapshot isolation catches people off guard. Concurrent collection iterators are weakly consistent: they may or may not reflect updates that happened during iteration, and they will not throw ConcurrentModificationException — but they also will not give you a point-in-time snapshot. If you need consistent iteration over a collection’s state, you must copy it first (new ArrayList<>(source)). Making decisions based on weakly consistent iteration is how subtle correctness bugs creep in.
ConcurrentHashMap gets overused as a catch-all cache or counter. The computeIfAbsent method looks handy for building caches, but if your loader blocks or does expensive computation, you create a thundering herd where dozens of threads all compute the same key at once. Use LoadingCache from Guava or Caffeine instead. And if you are using ConcurrentHashMap as a counter with incrementAndGet() under heavy contention, you are likely slower than LongAdder — it spreads contention across internal cells instead of all hitting the same CAS. See Java Atomics and VarHandle for lock-free alternatives that handle high-contention scenarios better.
ConcurrentLinkedQueue and Deque
Lock-free linked structures for high throughput:
ConcurrentLinkedQueue<Task> queue = new ConcurrentLinkedQueue<>();
ConcurrentLinkedDeque<Task> deque = new ConcurrentLinkedDeque<>();
// Queue operations
queue.offer(task); // Add to tail
Task task = queue.poll(); // Remove from head
// Deque operations - can operate on both ends
deque.offerFirst(task);
deque.offerLast(task);
Task first = deque.pollFirst();
Task last = deque.pollLast();
Choosing a Concurrent Collection
// Need a map with concurrent access?
// - High read, low write: ConcurrentHashMap
// - Need ordering: ConcurrentSkipListMap
// Need a queue for producer-consumer?
// - Bounded: LinkedBlockingQueue or ArrayBlockingQueue
// - Handoff (no buffer): SynchronousQueue
// - Priority-based: PriorityBlockingQueue
// Need a list with safe iteration?
// - Read-heavy, few updates: CopyOnWriteArrayList
// - Otherwise: ConcurrentLinkedQueue for queue semantics
// Need a set?
// - ConcurrentHashMap.newKeySet() - most efficient
// - ConcurrentSkipListSet - if ordering needed
Architecture Diagram
ConcurrentMap
graph TD
subgraph ConcurrentMap
CM1[ConcurrentHashMap] --> CM2[Segment-based locking]
CM3[ConcurrentSkipListMap] --> CM4[Red-black tree, lock-free]
end
BlockingQueue
graph TD
subgraph BlockingQueue
BQ1[LinkedBlockingQueue] --> BQ5[Bounded/unbounded linked]
BQ2[ArrayBlockingQueue] --> BQ6[Fixed array, bounded]
BQ3[PriorityBlockingQueue] --> BQ7[Heap, priority ordered]
BQ4[SynchronousQueue] --> BQ8[No storage, direct handoff]
end
CopyOnWrite
graph TD
subgraph CopyOnWrite
COW1[CopyOnWriteArrayList] --> COW2[Full copy on write]
end
LockFree
graph TD
subgraph LockFree
LF1[ConcurrentLinkedQueue] --> LF3[CAS operations]
LF2[ConcurrentLinkedDeque] --> LF3
end
Production Failure Scenarios
Scenario 1: Memory Explosion with CopyOnWriteArrayList
// BROKEN - writing frequently causes memory churn
CopyOnWriteArrayList<LogMessage> logs = new CopyOnWriteArrayList<>();
executor.scheduleAtFixedRate(() -> {
logs.add(new LogMessage("periodic update")); // Copies entire list every second!
}, 1, 1, TimeUnit.SECONDS);
// FIXED - use a queue or concurrent collection
ConcurrentLinkedQueue<LogMessage> logs = new ConcurrentLinkedQueue<>();
Scenario 2: BlockingQueue Without Capacity
// BROKEN - unbounded queue can grow without limit
BlockingQueue<Task> queue = new LinkedBlockingQueue<>();
// If producers outpace consumers, this grows until OOM
// FIXED - always set reasonable capacity
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(1000);
Scenario 3: ConcurrentHashMap and null Values
// BROKEN - NPE will occur
ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();
cache.putIfAbsent("user", null); // NPE
// FIXED - use a different marker value
cache.putIfAbsent("user", NULL_PLACEHOLDER);
Trade-off Table
| Collection | Reads | Writes | Memory | Use When |
|---|---|---|---|---|
| ConcurrentHashMap | Lock-free | Lock per segment | Low | General concurrent map |
| Hashtable | Lock whole | Lock whole | Medium | Legacy compatibility |
| synchronizedMap | Lock whole | Lock whole | Medium | Need null keys |
| CopyOnWriteArrayList | Lock-free | Expensive copy | High per write | Read-heavy, few writes |
| LinkedBlockingQueue | Blocking | Blocking | Moderate | Producer-consumer |
| ArrayBlockingQueue | Blocking | Blocking | Fixed | Bounded producer-consumer |
| SynchronousQueue | Blocking | Blocking | None | Direct handoff |
Implementation Snippets
Producer-Consumer with BlockingQueue
public class OrderProcessor {
private final BlockingQueue<Order> orders = new LinkedBlockingQueue<>(500);
private final ExecutorService consumerPool;
public OrderProcessor(int consumerCount) {
consumerPool = Executors.newFixedThreadPool(consumerCount);
for (int i = 0; i < consumerCount; i++) {
consumerPool.submit(this::processOrders);
}
}
public void submitOrder(Order order) throws InterruptedException {
orders.put(order);
}
private void processOrders() {
while (!Thread.currentThread().isInterrupted()) {
try {
Order order = orders.take();
process(order);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
public void shutdown() {
consumerPool.shutdownNow();
}
}
Cache with ConcurrentHashMap
public class DataCache<K, V> {
private final ConcurrentHashMap<K, V> cache = new ConcurrentHashMap<>();
private final Function<K, V> loader;
public DataCache(Function<K, V> loader) {
this.loader = loader;
}
public V get(K key) {
return cache.computeIfAbsent(key, loader);
}
public void invalidate(K key) {
cache.remove(key);
}
public void clear() {
cache.clear();
}
}
Observability Checklist
- Do you know which collections allow null values?
- Are your bounded queues actually bounded?
- Can you detect queue saturation in monitoring?
- Are you using CopyOnWriteArrayList for read-heavy workloads only?
- Do you have appropriate queue capacity for backpressure?
- Are consumers faster than producers on average?
Security Notes
Collections can expose data if not properly secured:
- ConcurrentHashMap doesn’t prevent unauthorized read access within the same JVM
- Use defensive copies when passing collections to untrusted code
- Consider immutability wrappers for shared data:
Collections.unmodifiableMap()
Common Pitfalls / Anti-Patterns
- Using synchronized collections under load: They’ll serialize access and kill throughput
- CopyOnWriteArrayList for write-heavy use: Every write copies the entire array
- Unbounded queues: Can cause OutOfMemoryError
- Assuming iteration is point-in-time: In concurrent collections, it’s weakly consistent
- Ignoring null policies: ConcurrentHashMap rejects nulls
- Queue capacity mis-tuning: Too small causes blocking, too large wastes memory
Quick Recap Checklist
- ConcurrentHashMap uses segment-based locking, allows concurrent reads/writes
- BlockingQueue blocks producers on full queue, consumers on empty
- CopyOnWriteArrayList copies entire array on write, great for read-heavy
- SynchronousQueue is zero-capacity handoff, not a queue
- Bounded queues provide backpressure
- ConcurrentHashMap doesn’t allow null keys/values
- Iteration is weakly consistent in concurrent collections
Interview Questions
ConcurrentHashMap divides its data into segments (default 16), each with its own lock. Reads access volatile fields directly without locking. Writes only lock the specific segment containing the target bucket.
Since Java 8, it also uses CAS operations for some updates and reduced locking. The key insight is that different segments can be accessed concurrently, so threads don't wait for unrelated buckets.
Use CopyOnWriteArrayList when reads vastly outnumber writes and you need to iterate without ConcurrentModificationException. Classic use case: listener/callback lists that are read frequently but modified rarely.
Don't use it if writes are frequent - every add() and remove() copies the entire array, which gets expensive fast. For most concurrent scenarios, ConcurrentLinkedQueue or ConcurrentHashMap.newKeySet() are better choices.
BlockingQueue operations block: put() blocks when full, take() blocks when empty. This makes it ideal for producer-consumer patterns where consumers need to wait for work.
ConcurrentLinkedQueue uses lock-free CAS operations, never blocks, and is generally higher throughput. Use it when you want non-blocking behavior and can handle the queue being empty or full yourself.
The decision was made to eliminate ambiguity in concurrent contexts. If map.get(key) returns null, you can't tell if the key wasn't present or if the value was actually null. With concurrent access, another thread might have just removed the value.
In single-threaded maps you can check with containsKey(), but in concurrent scenarios this check-then-get is itself racy. Forcing non-null values eliminates this ambiguity.
ArrayBlockingQueue has a fixed-size array and better memory locality since elements are contiguous. It has lower memory overhead per element.
LinkedBlockingQueue has optional capacity (unbounded by default) and uses separate nodes with next pointers. It can handle more elements without resizing but has higher per-element memory overhead and worse cache locality.
For bounded producer-consumer queues with predictable throughput, ArrayBlockingQueue is often better. For unbounded or very large queues, LinkedBlockingQueue's dynamic nature helps.
When multiple threads hit computeIfAbsent simultaneously for the same missing key, they all may invoke the loader/computer function simultaneously—wasted work that a single thread could have done.
The loader returns the same result for the same key, so only one thread's work is useful. Under high concurrency for the same key, this causes CPU contention, memory pressure from intermediate objects, and potential performance collapse. Use a cache like Caffeine or Guava's LoadingCache, which handles this via chunking or locking per key.
SynchronousQueue has capacity zero—it doesn't actually store elements. A put() must wait for a take(), and vice versa. This produces direct handoff between threads with no buffering.
Use it when you want true handoff semantics: producer and consumer must meet at the same time. This is useful for thread pools that execute tasks submitted by callers directly (Executors.newCachedThreadPool() uses a SynchronousQueue internally), or for heavyweight tasks where buffering would waste memory.
CopyOnWriteArrayList creates a fresh copy of its internal array on every write operation (add, remove). Iterators hold a reference to the immutable snapshot taken at creation time.
Since the snapshot never changes, the iterator never needs to modify it — reads proceed against a stable snapshot without synchronization. This avoids ConcurrentModificationException, but means iterators may reflect stale state if the list was modified after creation.
DelayQueue holds elements that implement the Delayed interface—each element has a getDelay(TimeUnit) method returning its remaining delay. An element only becomes available for removal (via take() or poll()) when its delay has expired.
Useful for scheduling tasks that should run after a fixed delay, such as retry with backoff, session expiration, or cached items with TTL. Elements with negative or zero delay are at the head and will be retrieved immediately.
Weakly consistent means the iterator may or may not reflect updates that occurred after iteration began, but it will never throw ConcurrentModificationException and will not repeat any elements.
In ConcurrentHashMap specifically, the iterator can tolerate concurrent modification, may proceed with stale reads, and won't block waiting for ongoing writes. This is a deliberate design trade-off for throughput—no locking on reads—which differs from the fail-fast iterators of synchronized collections.
ConcurrentHashMap segments are independent locks, each guarding a subset of buckets. Readers access volatile fields without locking. Writers lock only the specific segment containing their target bucket. Scaling depends on access patterns: if different threads access different segments, they proceed concurrently. Under high contention on a single bucket, you get serialization. The default 16 segments creates a floor on concurrent access (maximum 16 concurrent writers before lock contention). Java 8+ uses CAS operations reducing locking granularity to single bucket operations and improved scalability.
Stale data refers to items that have been polled from a queue but represent state from before the poll operation began. In concurrent queues, poll() atomically removes and returns an element, but between the check for empty and the removal, state could change. drainTo() removes multiple elements in a single operation compared to repeated poll() calls, reducing the window where stale reads could occur in the consuming code. For queue monitoring, drainTo() batches removals which is more efficient than individual polls in a loop.
ConcurrentSkipListMap is a concurrent, lock-free sorted map based on skiplists. ConcurrentHashMap is an unsorted hash map with segment-based locking. Use ConcurrentSkipListMap when you need key ordering (sorted by key) or range operations (subMap, headMap, tailMap). ConcurrentSkipListMap provides log-time average performance for containsKey, get, put, remove, size operations. ConcurrentHashMap provides constant-time average performance but no ordering guarantees.
ConcurrentLinkedQueue uses optimistic linked nodes with CAS for enqueue and dequeue operations. This avoids locks and blocking under contention, providing better throughput thanBlockingQueue for many producer-consumer scenarios. The trade-off is that operations may retry under contention (the ABA problem applies), and iteration is weakly consistent. The queue never blocks, so offer() returns immediately even when the queue appears full - a design choice that differs fromLinkedBlockingQueue semantics.
ArrayBlockingQueue uses a fixed-size circular array with a single lock for both producers and consumers, resulting in lower memory overhead per element but higher contention. LinkedBlockingQueue uses separate nodes with next pointers, higher per-element memory overhead, but supports optional capacity (unbounded) and has better cache locality per element. ArrayBlockingQueue's fairness option ensures waiting threads are served in FIFO order; LinkedBlockingQueue uses an Linked list with independent nodes and may have more unpredictable ordering under heavy load.
putIfAbsent(key, value) inserts the value only if key is absent and returns the existing value (or null if absent). It always performs a no-op read and conditional write even when absent. computeIfAbsent(key, mappingFunction) lazily computes the value only if key is absent using the mappingFunction, and returns the result. If the mappingFunction is expensive, computeIfAbsent avoids that cost when the key is already present. However, computeIfAbsent with a slow loader can cause thundering herd problems under concurrent access for the same key.
put() blocks the calling thread indefinitely until space is available. offer() with timeout blocks for the specified duration before returning false. For bounded queues, use offer(timeout) to avoid indefinite blocking in producer threads. For producer-consumer patterns where blocking is expected, put() is simpler. offer() returns immediately with false for non-blocking producers that should fail fast rather than wait.
CopyOnWriteArrayList copies its entire array on every write operation (add, remove, set). Under frequent writes, this causes constant full-array copying and garbage collection churn. For many writes per second, memory usage can spike dramatically. The copy overhead grows with list size. For write-heavy scenarios, CopyOnWriteArrayList is contraindicated - use concurrent collections like ConcurrentLinkedQueue or synchronized alternatives without copying. The read optimization only pays off when reads vastly outnumber writes (e.g., listener lists, configuration).
size() in ConcurrentHashMap counts elements across all segments, requiring segment traversal and synchronization. This is O(segment count) and may be slightly stale. isEmpty() similarly reports a point-in-time assessment. Both operations have weak consistency semantics and may not reflect recent concurrent inserts/removes. For accurate size in highly concurrent scenarios, consider external counters, or accept approximate counts. The weak consistency is by design because locking all segments to compute exact size would be prohibitively expensive.
ConcurrentLinkedQueue is a single-ended queue (FIFO) supporting offer(), poll(), peek(). ConcurrentLinkedDeque is a double-ended queue allowing operations at both ends: offerFirst(), offerLast(), pollFirst(), pollLast(). Use ConcurrentLinkedQueue for simple producer-consumer with a single consumer-producer pair. Use ConcurrentLinkedDeque when you need LIFO (stack) semantics, multiple producers and consumers on different ends, or work-stealing patterns. Both are lock-free and non-blocking.
Further Reading
- ConcurrentHashMap internals and segmentation — How segment-based locking works under the hood
- BlockingQueue implementations comparison — Bounded vs unbounded and array vs linked trade-offs
- CopyOnWriteArrayList memory behavior — When the copy cost is worth it
- Weakly consistent iterators in Java concurrent collections — Guarantees and limitations
- Caffeine Cache: High-performance caching — Why ConcurrentHashMap computeIfAbsent can cause thundering herd
Conclusion
You now understand Java’s concurrent collection hierarchy from ConcurrentHashMap to CopyOnWriteArrayList. Apply the right collection for your access pattern: segment-locked maps for general concurrent access, blocking queues for producer-consumer workflows, and copy-on-write lists for read-heavy listener patterns. Always use bounded queues to introduce backpressure, and remember that ConcurrentHashMap does not allow null keys or values. Continue with Java Atomics and VarHandle to explore lock-free alternatives to synchronized collections.
Category
Related Posts
Java Atomics and VarHandle: Low-Level Concurrency
Understanding Java atomic operations: AtomicInteger, AtomicReference, VarHandle, compareAndSet, atomics vs locks, and lock-free programming patterns.
Java Memory Model: Happens-Before, Volatile, and Final Fields
Understanding happens-before guarantees, volatile field semantics, and final field safety in the Java Memory Model for correct concurrent code.
java.util.concurrent: Thread Pools, ForkJoinPool, Synchronizers
Master Java's java.util.concurrent: Executors, ForkJoinPool, CountDownLatch, CyclicBarrier, Phaser, and thread pool patterns for high-performance apps.