Concurrent Collections: Thread-Safe Data Structures
Replace HashMap, ArrayList, and Collections.synchronizedX with purpose-built concurrent collections -- ConcurrentHashMap's atomic compound operations, CopyOnWriteArrayList for read-heavy lists, ConcurrentSkipListMap for sorted access, and when a plain volatile field is actually enough.
Learning objectives
- Explain concretely why a plain HashMap or ArrayList is unsafe under concurrent access
- Use ConcurrentHashMap's atomic compound methods to avoid check-then-act races
- Choose CopyOnWriteArrayList correctly for read-heavy, rarely-written collections
- Explain why Collections.synchronizedMap/List is usually the wrong choice for new code
- Know when a plain volatile field is enough and when it silently isn't
◆ Story
Imagine ten people editing the same physical paper ledger at once, with no rule about who writes where and no coordination of any kind. Some entries get overwritten before anyone reads them. Two people cross out and rewrite the same line, producing garbage neither of them intended. Someone flips to a page another person is mid-sentence on. That's a plain HashMap or ArrayList handed to multiple threads at once — nothing about either was ever designed to notice, let alone survive, simultaneous edits.
Here's a fact that surprises people who've only ever used HashMap in single-threaded code: hand the same instance to two threads that both call put() at nearly the same moment, and on older JVMs the worst case wasn't merely a lost entry — it was an infinite loop, one thread spinning at 100% CPU forever, because a concurrent resize could corrupt a bucket's linked list into a cycle. Modern JVMs closed that specific catastrophic failure mode, but the underlying cause — zero internal synchronization of any kind — never went away, and lost updates and visibility problems remain very real.
Java's java.util.concurrent package provides a purpose-built, thread-safe replacement for nearly every standard collection. Knowing which one actually fits a given access pattern — and understanding precisely why the seemingly obvious fix, Collections.synchronizedMap, is usually the wrong one — is the kind of knowledge that separates code that merely works from code that keeps working under real production load.
A plain HashMap under concurrent writes fails in three distinct ways. Lost updates happen when two threads compute the same bucket index, both read the current head of that bucket's chain, and both prepend their own node — one of the two updates simply vanishes. On Java 7 and earlier, a concurrent resize could corrupt a bucket's linked list into a circular reference, sending a later get() on that bucket into a genuine infinite loop, burning 100% CPU forever (fixed structurally in Java 8's tree-bin escalation, but still not made safe for concurrent writes). And even where nothing corrupts, visibility can still fail silently: an update made by one thread may not be observable by another thread without an explicit happens-before relationship, meaning a get() can return null even though a value was, provably, already put().
ConcurrentHashMap solves this without paying for it the way one single global lock would. Rather than one lock guarding the entire map, each bucket effectively guards itself: reads are lock-free (backed by volatile-style reads), and a write only ever locks the specific bucket it's modifying — sixteen threads writing to sixteen different keys can genuinely run at the same time with zero contention between them. Buckets that grow large (beyond eight nodes, once the map itself is large enough) escalate internally from a linked list to a small red-black tree, keeping worst-case lookup at O(log N) instead of O(N) even under adversarial hash collisions.
◆ Under the hood
ConcurrentHashMap.size() is not a true instantaneous count — it's built on a distributed counter conceptually similar to LongAdder, aggregated on demand. It's accurate enough for monitoring and dashboards, but treating it as an exact snapshot to drive a control decision ("if size == expectedCount, proceed") is a mistake, since concurrent writes during the count can leave it very slightly stale by the time it's read.
💻 Code example
package com.crackedlabs.concurrency.collections; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class ConcurrentWriteDemo { public static void main(String[] args) throws Exception { // Deliberately the SAFE choice -- substituting a plain HashMap // here is exactly the mistake this class exists to warn against. Map<String, Integer> sessionHits = new ConcurrentHashMap<>(); ExecutorService pool = Executors.newFixedThreadPool(4); for (int i = 0; i < 1000; i++) { String sessionId = "session-" + i; pool.submit(() -> sessionHits.put(sessionId, 1)); } pool.shutdown(); pool.awaitTermination(5, TimeUnit.SECONDS); // Because ConcurrentHashMap never loses concurrent writes, this // is reliably exactly 1000 on every single run. System.out.println("Total sessions recorded: " + sessionHits.size()); } }
Here's the trap that catches developers who correctly learned "ConcurrentHashMap is thread-safe" but stopped one step too early: thread-safe describes what happens within a single method call, not across a sequence of several. if (!map.containsKey(k)) map.put(k, v); — each individual call is internally safe and can't corrupt the map on its own, but the two-call sequence is a textbook check-then-act race, structurally identical to an unsynchronized bank-balance bug, just running on top of a collection that happens to be individually thread-safe.
If two threads both run that pattern for the same key at nearly the same moment, both can observe containsKey(key) == false before either has called put(), and both then proceed to put() — the second call silently overwrites the first with the same value instead of the count correctly reaching two. Two logical increments happened; the counter only advanced by one. This class of bug rarely shows up in casual testing, since low concurrency tends to "happen to work," and appears almost exclusively under real, concurrent production traffic — exactly the conditions where it's hardest to reproduce and diagnose.
The fix is to fuse the check and the act into one atomic call. compute(key, function) atomically reads the current value (or null if absent) and replaces it with the function's result as a single indivisible step — no other thread can interleave a change to that key in between. merge(), computeIfAbsent(), and putIfAbsent() cover the same idea for their respective specific shapes. The rule that falls out of this: always reach for one of these atomic compound methods instead of hand-rolling a check-then-act sequence, even on a collection that is, individually, completely thread-safe.
💻 Code example
package com.crackedlabs.concurrency.collections; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class HitCounter { private final ConcurrentMap<String, Integer> hits = new ConcurrentHashMap<>(); // UNSAFE: containsKey() then put() are two separate calls. Another // thread can run in the gap between them, causing a lost update. public void unsafeRecord(String page) { if (!hits.containsKey(page)) { hits.put(page, 1); } else { hits.put(page, hits.get(page) + 1); // itself another check-then-act pair } } // SAFE: merge() reads the current value and writes the new one as a // single atomic step -- no window for another thread to interleave. public void safeRecord(String page) { hits.merge(page, 1, Integer::sum); } public static void main(String[] args) { HitCounter counter = new HitCounter(); counter.safeRecord("/home"); counter.safeRecord("/home"); counter.safeRecord("/pricing"); System.out.println(counter.hits); // {/home=2, /pricing=1} } }
▲ Common mistake
Using a plain HashMap or ArrayList "just this once" in code that appears single-threaded today but later gets called from a thread pool, a scheduled task, or a request handler that turns out to run concurrently. The bug is invisible at the moment it's written and shows up only once real concurrent traffic exercises the exact same field from more than one thread — often long after the original author has moved on.
Check-then-act sequences on ConcurrentHashMap — containsKey() followed by put(), or get() followed by put() — remain races even though the collection itself is thread-safe, precisely because thread-safety is a per-call guarantee, not a per-sequence one. This is worth internalizing as a rule rather than something to reason through case by case: any time a decision depends on reading a value and then writing based on what was read, reach for compute()/merge()/putIfAbsent() instead.
Reaching for Collections.synchronizedMap()/synchronizedList() in new code is usually the wrong instinct, even though it technically produces something thread-safe. It wraps every method with a single coarse-grained lock, so even two threads that are only reading contend with each other — none of ConcurrentHashMap's concurrent-read performance survives the wrapping. Iterating a synchronized collection also still requires the caller to manually wrap the loop in a synchronized block on the collection itself, something nothing about the type signature reminds anyone to do.
Using CopyOnWriteArrayList for a list that's modified frequently is a performance mistake in the other direction: every single write copies the entire backing array, which is fine for a handful of listeners but becomes both slow and heavy on garbage collection for a large, frequently-mutated list.
CopyOnWriteArrayList's iteration guarantee is a specific, deliberate tradeoff: every write builds a brand-new copy of the array and atomically swaps the reference, so an iterator captures whatever array reference existed the instant it started and reads only that snapshot for its entire lifetime. This makes ConcurrentModificationException structurally impossible — but it also means an iteration in progress never sees any write that happens after it began, which is a feature for a listener-notification loop and a real surprise if the caller expected to see live updates mid-iteration.
ConcurrentSkipListMap fills the one notable gap ConcurrentHashMap leaves open: ordering. ConcurrentHashMap makes no guarantee at all about iteration order or the ability to efficiently ask "what's the smallest key" or "all keys between X and Y." ConcurrentSkipListMap answers both, backed by a skip list rather than a locked tree — lock-free reads, CAS-based writes at the node level, and live, still-thread-safe range views via headMap()/tailMap()/subMap(). The cost is real: without an ordering requirement, ConcurrentSkipListMap gives up some raw lookup throughput compared to ConcurrentHashMap for no benefit, so it's worth reaching for specifically when sorted iteration or range queries are actually needed.
▲ Edge case
private volatile List<String> items = new ArrayList<>(); protects only the reference itself — reading items is guaranteed to see whichever list was most recently assigned. It does nothing to protect the ArrayList instance that reference currently points to: if two threads both call items.add(...) on that same list, volatile provides zero protection, because ArrayList is still not internally thread-safe. volatile is sufficient only for a field that's fully replaced on every write (items = newList;, never mutated in place) — the moment in-place mutation from multiple threads is required, the fix is a genuinely concurrent collection, not a volatile reference to a plain one.
In-memory caching layers — configuration caches, feature-flag stores, permission lookups — routinely use ConcurrentHashMap.computeIfAbsent() to implement cache population without a check-then-act race: the very first thread to request a missing key computes and stores it atomically, and any thread that arrives while that computation is in flight waits for the same single computation rather than triggering a duplicate one.
Application frameworks commonly use ConcurrentHashMap internally for bean registries, class-metadata caches, and similar lookup structures that are read constantly during request handling but written only occasionally, during startup or a hot-reload — a natural fit for its lock-free-read design.
Event listener and observer registries — analytics SDKs, UI event dispatchers, plugin hook systems — are a textbook use case for CopyOnWriteArrayList: listeners are registered rarely and read (iterated, to notify) constantly, and the snapshot-iteration guarantee means firing an event while a listener is being added or removed elsewhere never throws or produces inconsistent notifications.
ConcurrentSkipListMap shows up wherever both sorted order and concurrent access matter together — a time-series index keyed by timestamp, a leaderboard keyed by score, or a scheduler keyed by next-run-time, where range queries ("everything before now," "the top ten scores") need to run correctly and efficiently while other threads are still inserting.
Service-discovery clients and similar read-mostly registries commonly cache a live set of known endpoints behind a single volatile reference, swapped wholesale on each periodic refresh rather than mutated in place — exactly the pattern where a plain volatile field, not a concurrent collection, is the correct and sufficient tool.
Q: What are the specific ways a plain HashMap can fail under concurrent writes?
A: Lost updates when two threads write to the same bucket at once, a historical infinite-loop risk on Java 7 and earlier from a corrupted bucket chain during a concurrent resize, and visibility failures where a read on one thread doesn't see a write already made on another thread, absent an explicit happens-before relationship.
Q: How does ConcurrentHashMap achieve thread safety without one global lock?
A: Each bucket effectively guards itself -- reads are lock-free, and a write only locks the specific bucket being modified, so threads writing to different keys can run fully concurrently with zero contention between them.
Q: Why is containsKey() followed by put() still a race, even on a ConcurrentHashMap?
A: Because thread-safety describes each individual call, not a sequence of calls. Two threads can both observe containsKey() == false before either calls put(), and both proceed to insert, silently losing one of the two logical updates -- the fix is an atomic compound method like compute() or putIfAbsent().
Q: Why is Collections.synchronizedMap usually the wrong choice for new code?
A: It wraps every method in one coarse-grained lock, so even two threads that only read still serialize behind each other -- unlike ConcurrentHashMap's concurrent reads. It also doesn't make compound operations atomic, and safe iteration still requires the caller to manually synchronize on the map themselves.
Q: When is a plain volatile field enough to share state safely, and when does it stop being enough?
A: It's enough when the shared value is fully replaced on every write -- a flag, or a reference swapped wholesale to a new object. It stops being enough the moment code needs to mutate the referenced object in place from multiple threads, since volatile only guarantees visibility of the reference itself, not thread safety of whatever it points to.
Want a visual for this concept?
Generate a diagram tailored to “Concurrent Collections: Thread-Safe Data Structures” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →