Concurrent Collections
When multiple threads read and write shared data structures at the same time, classic collections like ArrayList and HashMap break in subtle, hard-to-debug ways. Java’s java.util.concurrent package ships a set of concurrent collections — purpose-built data structures that are thread-safe, performant, and far easier to use than hand-rolled synchronization.
Why Not Just Use synchronized?
You could wrap any collection with Collections.synchronizedList(...) or write synchronized blocks everywhere, but that approach has real costs:
- Coarse-grained locking — the entire structure is locked for every read and write, so threads constantly block each other.
- Compound operations aren’t atomic — a check-then-act pattern (e.g., “if absent, add”) is still a race condition even with a synchronized wrapper.
- Iterator failures — iterating a synchronized collection while another thread modifies it still throws
ConcurrentModificationException.
Concurrent collections solve all three problems with finer-grained or lock-free designs.
Note: All concurrent collections live in
java.util.concurrent. Most also implement the standardjava.utilinterfaces (List,Map,Queue), so you can swap them in with minimal code changes.
ConcurrentHashMap
ConcurrentHashMap<K, V> is a drop-in replacement for HashMap in concurrent code. It divides the map’s internal buckets into independent segments (Java 7) or uses CAS (Compare-And-Swap) operations on individual nodes (Java 8+), so multiple threads can read and write different parts of the map simultaneously.
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentMapDemo {
public static void main(String[] args) throws InterruptedException {
ConcurrentHashMap<String, Integer> scores = new ConcurrentHashMap<>();
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
scores.merge("player1", 1, Integer::sum); // atomic merge
}
};
Thread t1 = new Thread(increment);
Thread t2 = new Thread(increment);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("Final score: " + scores.get("player1"));
}
}
Output:
Final score: 2000
Key atomic operations you should prefer over the raw get/put pair:
| Method | What it does atomically |
|---|---|
putIfAbsent(k, v) | Inserts only if key is absent |
computeIfAbsent(k, fn) | Computes and inserts only if absent |
merge(k, v, fn) | Combines existing value with new one |
compute(k, fn) | Updates value in-place atomically |
Warning:
ConcurrentHashMapdoes not allownullkeys ornullvalues. Usingnullthrows aNullPointerException. This is intentional —nullwould make it impossible to distinguish “key not present” from “key mapped to null.”
CopyOnWriteArrayList
CopyOnWriteArrayList<E> implements List and is optimised for read-heavy workloads where writes are rare. Every mutating operation (add, set, remove) creates a fresh copy of the underlying array, applies the change to the copy, then atomically swaps the reference.
Reads (including iteration) always see a consistent snapshot and never throw ConcurrentModificationException.
import java.util.concurrent.CopyOnWriteArrayList;
public class CowListDemo {
public static void main(String[] args) {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("Alpha");
list.add("Beta");
list.add("Gamma");
// Safe to iterate while another thread could be adding
for (String s : list) {
System.out.println(s);
}
list.add("Delta"); // creates a new internal array
System.out.println("Size after add: " + list.size());
}
}
Output:
Alpha
Beta
Gamma
Size after add: 4
Tip: Use
CopyOnWriteArrayListfor things like a list of event listeners or plugin registrations — rarely written, frequently iterated. Avoid it for high-write workloads; copying the array on every write becomes expensive.
Similarly, CopyOnWriteArraySet<E> provides a thread-safe Set backed by CopyOnWriteArrayList.
BlockingQueue
BlockingQueue<E> (an interface) adds two powerful behaviours on top of a regular Queue:
put(e)— blocks if the queue is full until space is available.take()— blocks if the queue is empty until an element arrives.
This is the foundation of the classic producer-consumer pattern, eliminating the need for wait/notify entirely.
ArrayBlockingQueue
Backed by a fixed-size array. You must specify the capacity at construction.
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class ProducerConsumer {
public static void main(String[] args) {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 5; i++) {
queue.put(i);
System.out.println("Produced: " + i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
int val = queue.take();
System.out.println("Consumed: " + val);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
}
}
Output (order may vary slightly):
Produced: 1
Consumed: 1
Produced: 2
Consumed: 2
...
LinkedBlockingQueue
Optionally bounded (defaults to Integer.MAX_VALUE). Uses separate locks for the head and tail, so producers and consumers rarely block each other.
PriorityBlockingQueue
An unbounded queue that orders elements by their natural order or a Comparator. It does not block on put (it’s unbounded), but take blocks when empty.
import java.util.concurrent.PriorityBlockingQueue;
public class PriorityQueueDemo {
public static void main(String[] args) throws InterruptedException {
PriorityBlockingQueue<Integer> pq = new PriorityBlockingQueue<>();
pq.put(50);
pq.put(10);
pq.put(30);
System.out.println(pq.take()); // 10 (smallest first)
System.out.println(pq.take()); // 30
System.out.println(pq.take()); // 50
}
}
Output:
10
30
50
Common BlockingQueue Implementations at a Glance
| Class | Bounded? | Ordering | Best Use |
|---|---|---|---|
ArrayBlockingQueue | Yes (fixed) | FIFO | Fixed-capacity work queues |
LinkedBlockingQueue | Optional | FIFO | High-throughput pipelines |
PriorityBlockingQueue | No | Priority | Task scheduling |
DelayQueue | No | Delay-based | Scheduled task execution |
SynchronousQueue | 0 capacity | Hand-off | Direct thread-to-thread transfer |
ConcurrentLinkedQueue and ConcurrentLinkedDeque
ConcurrentLinkedQueue<E> is a non-blocking, lock-free unbounded FIFO queue based on the Michael-Scott algorithm using CAS operations. It offers extremely high throughput when many threads are producing and consuming.
import java.util.concurrent.ConcurrentLinkedQueue;
public class CLQDemo {
public static void main(String[] args) {
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.offer("First");
queue.offer("Second");
System.out.println(queue.poll()); // First
System.out.println(queue.poll()); // Second
}
}
ConcurrentLinkedDeque<E> extends this to a double-ended queue (deque), allowing thread-safe insertion and removal from both ends.
Note: Unlike
BlockingQueue, these do not block.poll()returnsnullwhen empty rather than waiting. Use them when you want non-blocking behaviour with lock-free speed.
Under the Hood
ConcurrentHashMap Internals (Java 8+)
The Java 8 rewrite replaced segment-level locking with a node-level approach:
- The table is an array of
Node<K,V>references stored withvolatilefor visibility. - Reads are lock-free — they read directly from the volatile node reference.
- Writes CAS the head node of a bucket. Only when there’s a collision (two threads racing on the same bucket) does a
synchronizedblock kick in — on just that single bucket node. - When a bucket’s chain grows beyond 8 entries, it’s converted to a red-black tree (
TreeNode) for O(log n) lookup — the same treeification used inHashMap.
This means ConcurrentHashMap can support true parallel reads and near-parallel writes with minimal contention.
CopyOnWriteArrayList Internals
The internal array is held in a volatile field. Writers acquire a ReentrantLock, copy the array, mutate the copy, then do a volatile write to swap the reference. Readers see a stable snapshot because they read the volatile reference once at the start of iteration and then walk the fixed-length array — no lock needed.
False Sharing and Cache Lines
High-throughput concurrent collections are carefully padded to avoid false sharing — a performance problem where two unrelated volatile variables end up on the same CPU cache line, causing threads to bounce the line between cores even when they’re accessing different data. ConcurrentHashMap’s counter cells (LongAdder internally) use @Contended padding to prevent this.
Choosing the Right Concurrent Collection
| Scenario | Use |
|---|---|
| Shared key-value store, many threads | ConcurrentHashMap |
| Read-heavy list (event listeners, callbacks) | CopyOnWriteArrayList |
| Producer-consumer with backpressure | ArrayBlockingQueue / LinkedBlockingQueue |
| Non-blocking high-throughput queue | ConcurrentLinkedQueue |
| Priority-based task queue | PriorityBlockingQueue |
| Thread-safe set, rare writes | CopyOnWriteArraySet |
Related Topics
- HashMap — understand the single-threaded map that
ConcurrentHashMapimproves upon - Synchronization — the manual locking alternative and why concurrent collections are often better
- Thread Pools & Executors —
BlockingQueuepowers the work queues insideThreadPoolExecutor - Multithreading — foundational concepts for writing concurrent Java programs
- Volatile Keyword — the memory visibility guarantee that concurrent collections rely on internally
- Deadlock — a hazard that concurrent collections help you avoid by replacing manual synchronized blocks