Back to Blog
Java

Java Concurrent Collections: A Practical Selection Guide

java concurrent collections: Learn how Java's concurrent collections work, when each type fits, and how to avoid common thread-safety pitfalls in multi-threaded applic...

ConcurrentHashMapThread SafetyBlockingQueueCopyOnWriteArrayListjava.util.concurrentConcurrency
Illustration of Java concurrent collections showing multiple threads accessing a segmented thread-safe map with lock striping.

When multiple threads share a HashMap or ArrayList without external synchronization, the results are unpredictable. A HashMap can corrupt its internal structure during concurrent resizing, causing lost entries or, in older JVM versions, infinite loops. Java concurrent collections in the java.util.concurrent package solve this by providing thread-safe implementations designed for specific access patterns.

Why Standard Collections Fail Under Concurrency

Standard collections like HashMap, ArrayList, and LinkedList are not thread-safe. They were designed for single-threaded access. When two threads modify a HashMap simultaneously, the internal table can be corrupted. Reads can return stale data, writes can be lost, and resizing during concurrent access can produce severe runtime failures.

The traditional fix is external synchronization: wrapping the collection with Collections.synchronizedMap() or synchronizing on a shared lock. This works but serializes all access. Every read and write acquires the same monitor, which becomes a bottleneck in multi-threaded applications.

Java's concurrent collections take a different approach. They use finer-grained locking, lock-free algorithms, or copy-on-write semantics to allow higher concurrency while maintaining thread safety.

The Concurrent Collection Families in java.util.concurrent

The java.util.concurrent package provides several collection types, each designed for a different access pattern:

CollectionInterfaceThread-safety mechanismBest for
ConcurrentHashMapMapLock striping / CASHigh-concurrency key-value access
CopyOnWriteArrayListListCopy-on-writeRead-heavy lists with rare writes
CopyOnWriteArraySetSetCopy-on-writeRead-heavy sets
ConcurrentLinkedQueueQueueLock-free (CAS)High-throughput FIFO
ConcurrentLinkedDequeDequeLock-free (CAS)Concurrent stack/queue
LinkedBlockingQueueBlockingQueueLocksProducer-consumer with bounded capacity
ArrayBlockingQueueBlockingQueueSingle lockBounded producer-consumer
ConcurrentSkipListMapSortedMapLock-free skiplistSorted keys with concurrent access
ConcurrentSkipListSetSortedSetLock-free skiplistSorted unique elements

The choice depends heavily on the access pattern: whether you need sorted order, blocking behavior, bounded capacity, or low-latency reads.

ConcurrentHashMap: Lock Stripping and Weak Consistency

ConcurrentHashMap is the most commonly used concurrent collection. It replaces Hashtable and Collections.synchronizedMap() for most map use cases.

In modern Java versions (8+), ConcurrentHashMap uses synchronized blocks on individual bins combined with CAS operations. The map is divided into bins, and operations lock only the bin they touch rather than the entire map. This allows multiple threads to read and write different bins simultaneously.

One important behavior to understand is weak consistency. Iterators returned by ConcurrentHashMap do not throw ConcurrentModificationException. They reflect the state of the map at some point during iteration and may not reflect subsequent modifications. This differs from the fail-fast behavior of HashMap iterators.

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>(); counts.put("requests", 1); // Atomic update without external locking counts.compute("requests", (key, value) -> value == null ? 1 : value + 1);

The compute() method is atomic. A naive read-modify-write sequence using get() followed by put() is not atomic and can lose updates when two threads race. The compute() and merge() methods handle this correctly.

ConcurrentHashMap also provides putIfAbsent(), remove(key, value), and replace(key, oldValue, newValue) for conditional atomic operations. These are useful for building caches and deduplication logic.

One limitation: ConcurrentHashMap does not allow null keys or null values. This is deliberate. Null values would be ambiguous in methods like get() that return null to indicate absence. If you need null support, use a regular HashMap with external synchronization or handle nulls explicitly.

CopyOnWriteArrayList: Read-Heavy Workloads

CopyOnWriteArrayList implements List with a different strategy. Every mutating operation (add, set, remove) creates a fresh copy of the underlying array. Reads never lock and never block.

This makes it excellent for scenarios where reads vastly outnumber writes, such as listener registries, configuration lists, or event handler collections. Readers see a consistent snapshot without synchronization overhead.

CopyOnWriteArrayList<String> listeners = new CopyOnWriteArrayList<>(); listeners.add("audit-logger"); listeners.add("metrics-collector"); for (String listener : listeners) { // Safe: iterates over a snapshot taken at iteration start dispatch(listener); }

The iterator returned by CopyOnWriteArrayList operates on the array snapshot at the time the iterator was created. Modifications made after iterator creation are not visible to that iterator, and the iterator does not throw ConcurrentModificationException.

The cost is memory and write latency. Every add or remove copies the entire array. With a large list, this can be expensive. CopyOnWriteArrayList is not suitable for lists that change frequently or hold large amounts of data.

BlockingQueue Implementations for Producer-Consumer

BlockingQueue is the interface for queues that support waiting for space or elements. The implementations differ in capacity and locking behavior.

ArrayBlockingQueue is a bounded queue backed by an array. It uses a single lock for both put and take operations. The capacity is fixed at construction time. When the queue is full, put() blocks until space is available; when empty, take() blocks until an element arrives.

LinkedBlockingQueue is optionally bounded. If created without a capacity, it defaults to Integer.MAX_VALUE, which effectively makes it unbounded. It uses two locks: one for put and one for take. This allows slightly higher throughput in some producer-consumer patterns because producers and consumers do not contend on the same lock.

BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100); // Producer queue.put(new Task("process-order")); // Consumer Task task = queue.take(); process(task);

The choice between ArrayBlockingQueue and LinkedBlockingQueue depends on whether bounded capacity is required and whether the lock behavior matters. ArrayBlockingQueue is more predictable in memory usage because the array is preallocated. LinkedBlockingQueue allocates a node per element.

For high-throughput scenarios where blocking is undesirable, ConcurrentLinkedQueue provides a lock-free unbounded queue. It never blocks and uses CAS operations. However, it does not implement BlockingQueue, so there is no take() that waits. You must poll() and handle the null case yourself.

ConcurrentLinkedQueue and ConcurrentSkipListMap

ConcurrentLinkedQueue is an unbounded, lock-free queue based on the Michael-Scott algorithm. It is thread-safe and provides FIFO ordering. Because it is lock-free, it has no blocking operations. poll() returns null when the queue is empty, and you must decide how to handle that.

ConcurrentSkipListMap is a sorted map that maintains keys in natural order or according to a Comparator. It is the concurrent equivalent of TreeMap. Operations like put, get, remove, and range queries are thread-safe. The skiplist structure provides O(log n) average time for most operations.

ConcurrentSkipListMap<Long, String> events = new ConcurrentSkipListMap<>(); events.put(System.currentTimeMillis(), "startup"); // Find the first entry with key >= a threshold Map.Entry<Long, String> entry = events.ceilingEntry(threshold);

ConcurrentSkipListMap is useful when you need sorted iteration, range queries, or headMap/subMap views on a concurrent map. ConcurrentHashMap does not provide ordering.

Choosing the Right Concurrent Collection

The decision depends on the access pattern and the required semantics.

Use ConcurrentHashMap when you need a key-value map with high concurrency and do not need ordering.

Use CopyOnWriteArrayList when reads dominate, writes are rare, and the list is small enough that copying is acceptable.

Use ArrayBlockingQueue or LinkedBlockingQueue when you need blocking producer-consumer behavior with bounded capacity.

Use ConcurrentLinkedQueue when you need an unbounded, lock-free queue and can handle empty results via polling.

Use ConcurrentSkipListMap when you need a sorted concurrent map with range operations.

RequirementRecommended collection
Key-value access, no orderingConcurrentHashMap
Sorted keys, range queriesConcurrentSkipListMap
Read-heavy list, rare writesCopyOnWriteArrayList
Blocking producer-consumer, boundedArrayBlockingQueue
Blocking producer-consumer, unboundedLinkedBlockingQueue
Lock-free unbounded queueConcurrentLinkedQueue

Performance and Memory Tradeoffs

The performance characteristics of concurrent collections come from specific mechanisms, and each has a cost.

ConcurrentHashMap avoids global locking but pays for the complexity of CAS operations and bin-level synchronization. Under low contention, it is fast. Under high contention on the same bin, threads still block, but only on that bin.

CopyOnWriteArrayList trades memory and write cost for lock-free reads. Every write allocates a new array. If the list holds 100,000 elements and updates happen frequently, the allocation and copy overhead can dominate.

BlockingQueue implementations use locks. ArrayBlockingQueue uses one lock; LinkedBlockingQueue uses two. The lock contention depends on the ratio of producers to consumers and the queue size. A bounded queue that fills up forces producers to wait, which is often the intended backpressure mechanism.

ConcurrentLinkedQueue avoids locks entirely but uses more memory per element because each node contains references for the linked structure and the CAS fields. It also has no capacity bound, so a slow consumer can lead to unbounded memory growth.

There is no universal winner. The correct choice is the collection whose tradeoffs match the workload.

Common Pitfalls and Misuse

Several mistakes appear regularly when developers use concurrent collections.

Treating ConcurrentHashMap as fully consistent: Iterators are weakly consistent. If you iterate while another thread modifies the map, you may see a partial state. For operations that require a consistent snapshot, copy the entries into a regular map first.

Using get() then put() for atomic updates: This race condition loses updates. Use compute(), merge(), or putIfAbsent() instead.

Using null values with ConcurrentHashMap: It throws NullPointerException. Design your data so absence is represented by a sentinel or an Optional wrapper.

Assuming CopyOnWriteArrayList is cheap: Every write copies the array. For large lists with frequent writes, this causes GC pressure and latency spikes.

Using ConcurrentLinkedQueue with take()-style blocking: It does not block. If you need blocking behavior, use a BlockingQueue implementation.

Forgetting that size() is not exact on concurrent collections: In ConcurrentHashMap, size() is an approximation because the map changes while the count is being computed. The same applies to other concurrent collections. Do not rely on size() for decisions that require an exact count; use a LongAdder or an atomic counter maintained separately if you need precise tracking.

java concurrent collections: Practical Usage and Code Exampl | RYUSLOG DEV