Thread-safe collections — when ConcurrentHashMap beats synchronized HashMap.
Published September 21, 2026
Ordinary collections (HashMap, ArrayList) are not safe to share between threads when at least one thread writes. Concurrent writes can lose updates, corrupt internal structure, or throw ConcurrentModificationException during iteration. The java.util.concurrent package provides collections designed for sharing, each tuned for a different access pattern. The two you'll use and be asked about most are ConcurrentHashMap and CopyOnWriteArrayList, plus the blocking queues behind producer–consumer designs.
Collections.synchronizedMap?Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
This puts one lock around every method. It's correct for single calls, but:
if (!map.containsKey(k)) map.put(k, v); is two separately locked calls, and another thread can slip in between.synchronized (map) { ... } yourself, or risk ConcurrentModificationException.Hashtable and Vector have the same single-lock design, which is why they're considered legacy.
ConcurrentHashMap (CHM) allows many threads to read and write at the same time:
synchronized on its first node, so threads writing to different buckets never block each other. (Java 7 used a fixed number of lock "segments" instead.)ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.merge(word, 1, Integer::sum); // atomic "increment or insert 1"
counts.putIfAbsent("config", 42); // atomic check-then-put
counts.computeIfAbsent(userId, id -> loadProfile(id)); // atomic "get or create"
counts.compute(key, (k, v) -> v == null ? 1 : v * 2); // atomic read-modify-write
Each runs atomically for that key. The classic bug is doing it in two steps:
// ❌ Race: two threads can both see null and both put 1 — one increment is lost
Integer old = counts.get(word);
counts.put(word, old == null ? 1 : old + 1);
// ✅
counts.merge(word, 1, Integer::sum);
Caveat: the function passed to compute/computeIfAbsent runs while the bucket is locked. Keep it short, and never modify the same map from inside it, since that can deadlock or throw.
put(key, null) throws NullPointerException. It's deliberate: in a concurrent map, get(k) == null must unambiguously mean "absent".ConcurrentModificationException and reflect the map at some point during iteration. They may or may not see concurrent updates. It isn't a frozen snapshot.size() and isEmpty() may already be out of date by the time you use them. Don't base decisions on them while other threads are writing.A list that, on every modification, copies the whole internal array, applies the change to the copy, and swaps the reference. Readers just read whatever array is current:
List<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(listener); // copies the array (O(n))
for (Listener l : listeners) { // iterates a snapshot — no locking, never throws CME
l.onEvent(event); // even if another thread adds/removes listeners meanwhile
}
iterator.remove() throws UnsupportedOperationException.Use it for read-mostly, write-rarely lists that are iterated often: event listeners, observers, routing tables, configuration. CopyOnWriteArraySet is the set equivalent.
A BlockingQueue adds operations that wait. put blocks while the queue is full, and take blocks while it's empty. It's the cleanest way to hand work from producer threads to consumer threads without writing wait/notify yourself:
BlockingQueue<Order> queue = new ArrayBlockingQueue<>(1_000); // bounded → natural backpressure
// producer
queue.put(order); // waits if consumers are behind
// consumer
while (!Thread.currentThread().isInterrupted()) {
Order o = queue.take(); // waits for work
process(o);
}
| Implementation | Behaviour |
|---|---|
ArrayBlockingQueue | Bounded, array-backed, one lock |
LinkedBlockingQueue | Optionally bounded (unbounded by default, so it can grow until memory runs out), separate locks for put and take |
PriorityBlockingQueue | Unbounded, ordered by priority |
DelayQueue | Elements become available after their delay expires |
SynchronousQueue | Zero capacity: each put waits for a matching take (direct hand-off) |
Prefer bounded queues. An unbounded queue hides a slow consumer until the JVM runs out of memory. A bounded one applies backpressure by slowing producers down. Timed variants (offer(e, timeout), poll(timeout)) let threads give up instead of waiting forever.
ConcurrentLinkedQueue is a non-blocking, lock-free queue for when you never want threads to wait, since poll() just returns null when it's empty.
AtomicLong hits = new AtomicLong();
hits.incrementAndGet(); // a CAS on one variable; retries under contention
LongAdder hits2 = new LongAdder();
hits2.increment(); // spreads updates across several cells under contention
long total = hits2.sum(); // adds the cells up when you read
Under heavy contention, many threads hammering one counter, LongAdder is much faster because threads update different cells. Use AtomicLong when you need exact read-modify-write semantics such as compareAndSet or unique ID generation. Use LongAdder for statistics that are mostly written and occasionally summed. ConcurrentHashMap<String, LongAdder> with computeIfAbsent(k, x -> new LongAdder()).increment() is an idiomatic high-throughput per-key counter.
ConcurrentHashMapConcurrentSkipListMap / ConcurrentSkipListSetCopyOnWriteArrayListBlockingQueue (or simply an ExecutorService, which contains one)LongAdderQ: Why doesn't ConcurrentHashMap allow null keys or values?
A: In a concurrent map, get(k) returning null must mean "not present". If null values were allowed, you couldn't tell "absent" from "present with null" without a second call, and another thread could change the map between the two calls. Banning nulls removes that ambiguity.
Q: Is if (!map.containsKey(k)) map.put(k, v) safe on a ConcurrentHashMap?
A: No. Each call is thread-safe, but the pair isn't atomic, so two threads can both see "absent" and both put. Use putIfAbsent, computeIfAbsent or merge, which perform the whole check-and-act atomically.
Q: How does ConcurrentHashMap achieve concurrency in Java 8+?
A: Lock-free reads using volatile semantics, CAS to insert into empty buckets, synchronized on the head node of a bucket for other writes (so only that bucket is locked), cooperative multi-threaded resizing, and conversion of long bucket chains into red-black trees, as in HashMap.
Q: When is CopyOnWriteArrayList the wrong choice?
A: Whenever writes are frequent or the list is large. Each write copies the whole array, so costs grow with size × write rate and create lots of garbage. For write-heavy shared lists, use a concurrent queue or deque, or protect an ArrayList with a lock and copy it only when you need a snapshot.
Q: Why prefer a bounded BlockingQueue?
A: It limits memory and applies backpressure: when consumers fall behind, put blocks, or offer fails, and producers slow down or shed load. An unbounded queue silently accumulates work until latency explodes or the heap is exhausted.