Java Concurrent Collections: ConcurrentHashMap, BlockingQueue
Java concurrent collections deep dive: ConcurrentHashMap, BlockingQueue, CopyOnWriteArrayList, ConcurrentLinkedQueue, and choosing the right structure.
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
Java ships five BlockingQueue implementations. Each one trades off memory, throughput, and coordination semantics differently. The table below summarizes the key dimensions, and the sections that follow dig into the ones that cause the most confusion.
| 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
The ConcurrentMap interface extends Map with atomic operations that eliminate the need for external synchronization. Two implementations matter in practice. ConcurrentHashMap is the workhorse for general concurrent mapping — it segments its data into buckets with independent locks, so readers access volatile fields directly without acquiring any lock. Writers lock only the specific segment containing their target bucket, leaving other segments free for concurrent access.
ConcurrentSkipListMap is the ordered alternative. It maintains keys in sorted order and supports range operations like subMap, headMap, and tailMap that a hash map cannot provide. Performance is O(log n) for most operations. If you need ordering guarantees alongside concurrent access, this is your choice.
graph TD
subgraph ConcurrentMap
CM1[ConcurrentHashMap] --> CM2[Segment-based locking]
CM3[ConcurrentSkipListMap] --> CM4[Red-black tree, lock-free]
end
BlockingQueue Overview
BlockingQueue extends Queue with operations that wait for the queue to be non-empty (on poll) or have available capacity (on put). This is the natural fit for producer-consumer patterns: producers block when the queue is full, consumers block when it is empty.
Four implementations cover most coordination scenarios. LinkedBlockingQueue is the most commonly used — linked-node based with optional capacity bounds, so it works as both a bounded and unbounded queue. ArrayBlockingQueue is array-backed with fixed capacity and better cache locality, but no dynamic growth. PriorityBlockingQueue orders elements by comparator or natural order, which is useful for priority scheduling where insertion order does not matter. DelayQueue holds elements that implement Delayed — an element only comes out of the queue via take() once its delay has expired, making it suitable for scheduled task executors.
SynchronousQueue has no storage capacity at all. A put() must wait for a take() and vice versa, which means zero buffering. Thread pools use it internally for direct handoff between threads.
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
CopyOnWriteArrayList is the only implementation in this category. Every mutative operation — add, remove, set — copies the entire underlying array. Iterators hold a reference to the snapshot taken at creation time and never change it, which is why iteration never throws ConcurrentModificationException and never blocks readers.
The tradeoff is deliberate: writers pay the copy cost so readers pay nothing. This only makes sense when writes are infrequent. Listener lists, event handler registries, and read-heavy configuration caches fit this profile. If your list is being modified constantly, the constant array copying will eat your memory and garbage collection budget alive.
graph TD
subgraph CopyOnWrite
COW1[CopyOnWriteArrayList] --> COW2[Full copy on write]
end
LockFree
Lock-free collections use atomic Compare-And-Set (CAS) operations instead of locks. ConcurrentLinkedQueue and ConcurrentLinkedDeque are the two linked-structure implementations, both built on CAS with no internal locking.
ConcurrentLinkedQueue is a FIFO queue backed by a singly-linked structure. offer() appends to the tail using CAS, and poll() removes from the head. The algorithm is optimistic: it retries if another thread modified the target node between the read and the CAS attempt. Under moderate contention this works well; under heavy contention on the same queue, retries pile up.
ConcurrentLinkedDeque extends the same idea to a double-ended queue, supporting offerFirst(), offerLast(), pollFirst(), and pollLast(). This makes it useful for work-stealing patterns where multiple consumers pull from different ends. Both structures never block — offer() returns immediately even when the queue appears full, which distinguishes them from BlockingQueue.
graph TD
subgraph LockFree
LF1[ConcurrentLinkedQueue] --> LF3[CAS operations]
LF2[ConcurrentLinkedDeque] --> LF3
end
Production Failure Scenarios
Scenario 1: Memory Explosion with CopyOnWriteArrayList
CopyOnWriteArrayList reallocates its entire underlying array on every write. For a list with a handful of elements, that cost is manageable. It becomes a problem when writes happen frequently. A logging list that gets a new entry every second will allocate a fresh array, copy all existing entries, and drop the old one — every second. Under sustained load, this churn floods the old generation with short-lived arrays, triggering repeated GC cycles and dragging throughput down.
The fix is to use a concurrent queue instead. ConcurrentLinkedQueue uses lock-free CAS operations and never copies the full array on writes.
// 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
LinkedBlockingQueue has no hard upper bound by default — it grows as fast as producers can feed it. On the surface this seems convenient: producers never block on put(). The hidden cost is that if producers consistently outpace consumers, the queue absorbs unlimited objects in memory until the process runs out of heap and dies with an OutOfMemoryError. There is no backpressure signal, no alert, no throttling — just memory growth until the crash.
The fix is to always set an explicit capacity that reflects what your consumer side can actually handle. This cap turns the queue into a pressure valve: producers block on put() when the queue is full, which backpressures the upstream work source and prevents runaway memory growth.
// 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
ConcurrentHashMap deliberately rejects null for both keys and values. This was a deliberate design choice, not an oversight. It eliminates an entire class of ambiguity in concurrent programs. When map.get(key) returns null, you need to know whether the key was absent or whether the value stored for that key was actually null. In a concurrent setting, another thread might have removed the value between your check and your read, so a null check-then-get is itself racy. By forbidding nulls, ConcurrentHashMap makes the return value unambiguous: null always means the key is absent.
If you try to store a null value, you get an immediate NullPointerException rather than silent data loss or subtle corruption. The same exception fires on putIfAbsent, get, and any other method that would need to distinguish a missing key from a null value.
// 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
The OrderProcessor shows producer-consumer in practice. The constructor spins up a fixed thread pool, each consumer running processOrders in a loop that calls take() to wait for work. submitOrder calls put(), which blocks when the queue is full — the 500-item cap gives you backpressure so producers cannot flood memory. On shutdown, shutdownNow interrupts the consumers, the InterruptedException fires, and the loop breaks cleanly.
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
This DataCache adds lazy initialization to ConcurrentHashMap via a loader function. Call get(key), and computeIfAbsent checks whether the key exists and computes the value on demand — atomically, holding a lock on the specific bucket. Only one thread runs the loader for a given key; the rest wait for that result.
The problem comes when the loader is slow or does I/O. Every thread that hits the same missing key serializes on that computation, which is how you get a thundering herd. For real caches backed by network calls or database queries, use Caffeine or Guava’s LoadingCache instead — they deduplicate concurrent requests to the same key so the loader only runs once.
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
- Java Atomics and VarHandle — Lock-free alternatives for high-contention scenarios
- java.util.concurrent: Thread Pools, ForkJoinPool, Synchronizers — Executors and coordination primitives
- Oracle ConcurrentHashMap Documentation — Official CHM reference
- Oracle BlockingQueue Documentation — Producer-consumer queue interface
- Advanced Java & JVM Internals Roadmap — Parent roadmap for this topic
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.