ConcurrentHashMap Explained
KOTLIN › Concurrency
ConcurrentHashMap is usually the right answer the moment a map is shared across threads, but the interesting interview question is never *that* it's thread-safe, it's *how*. Collections.synchronizedMap(HashMap()) does it the blunt way: one lock wraps every method, so ten threads calling get() or put() at the same time all queue up behind the same monitor, no matter which keys they're touching.
ConcurrentHashMap was built to avoid exactly that. It gives you fully concurrent reads and high-throughput writes with no global lock anywhere in the class. Two threads writing to different keys almost never wait on each other, and reads never wait on writes at all. That single design choice, contention scoped down from "the whole map" to "this one bucket", is what the rest of this lesson explains piece by piece: how writes stay safe without a big lock, how reads stay safe without any lock, and where the map still asks you to do some of the work yourself.
Writes are where the concurrency actually comes from, and the mechanism is per-bucket, not per-map. Hash the key to a bin. If that bin is empty, the new node goes in with a **CAS**, a compare-and-swap, the same lock-free primitive AtomicInteger.incrementAndGet() uses under the hood. Nothing was there to race against, so no lock is needed at all.
If the bin already holds something, the write locks only that bin's head node with synchronized, then walks the chain, or the tree once a bin gets long enough to treeify, to append or update. That's the whole trick: lock granularity is one bucket, not the table. Two threads writing to different buckets run fully in parallel; only two threads landing on the exact same bucket ever actually block each other.
This replaces the older segment-striping design, where a fixed set of locks was shared across ranges of buckets. That design is gone. Modern ConcurrentHashMap never locks anything bigger than a single bin.
Reads don't need any of that locking machinery. get() never takes a lock, full stop, because the node's value and next fields are declared **volatile**. Any thread traversing a bin's chain sees safely published nodes and values, never a torn or half-built entry, because that's exactly what the volatile guarantee promises.
val v = cache[key] // no lock, safe even while other threads are writing elsewhere
Worst case, a read sees the state as of some very recent moment rather than this exact instant, but it's never corrupted. That's the payoff of the write path from the last chunk: because writers never touch a global lock, readers never have anything global to wait on either.
Growing the table works the same way: no global stop. A plain HashMap rehashes everything in one stop-the-world pass. ConcurrentHashMap's resize is **cooperative** instead: writer threads that notice a resize underway pitch in and help transfer bins to the new table themselves, splitting the work between them rather than leaving it to one thread.
Once a bin has been moved, it's marked with a special **ForwardingNode**. Any reader that lands on that bin during the resize just follows the pointer straight into the new table and keeps going, no blocking, no waiting for the resize to finish. It's the same philosophy as the read and write paths: nothing ever grabs the whole structure, contention stays local to whatever bin is actually in motion.
All three of those mechanics, CAS writes, volatile reads, cooperative resize, exist to back up one promise the documentation states explicitly: actions a thread performs before placing an object into the map **happen-before** actions another thread performs after retrieving that object. In plain terms, if you fully build an object and then put it, any thread that later gets it back sees it exactly as fully built, with no extra volatile fields or locks of your own required.
val cfg = Config(load()) // built completely, on this thread
map["cfg"] = cfg // publish
// another thread's map["cfg"] sees a fully-built Config, guaranteed
That guarantee is scoped tightly, though: it covers state that existed *before* the put. It says nothing about mutations made to that object afterward. If a thread pulls cfg back out and later threads keep mutating fields on it, that ongoing mutation needs its own thread-safety story, the map already did its job the moment it handed the object over. Store immutable values where you can, or route later changes back through the map itself with something like replace(), rather than mutating a stored object in place.
Here's the trap that catches people who assume "thread-safe map" means any sequence of calls on it is safe: atomicity is per operation, not per sequence. get() is atomic. put() is atomic. Chaining them yourself is not.
val hits = ConcurrentHashMap<String, Int>()
fun recordHit(endpoint: String) {
hits[endpoint] = (hits[endpoint] ?: 0) + 1
}
Called concurrently for the same key, this races: two threads can both read the same old count, both compute the same incremented value, and both write it, and one hit silently disappears. The map never let either thread see a torn value, it just didn't stop them from acting on stale ones. The fix is always one of the map's own atomic compound operations, ones that perform the whole read-combine-write as a single indivisible step instead of two separate calls with a gap between them.
The fix lives in the map's compound operations, each of which locks the bin for its entire check-and-mutate step rather than just one call at a time. Two of them are for "only if not already there". putIfAbsent(key, value) inserts only if the key is absent, and tells you what happened through its return value: if your call was the one that inserted, it returns null; if someone beat you to it, it returns the value that's already there, and your value is discarded.
val prev = map.putIfAbsent(key, candidate)
val actual = prev ?: candidate // everyone ends up agreeing on one instance
computeIfAbsent(key) { load(it) } goes further: instead of supplying a value up front, you supply a function that computes one, and that function only runs if the key turns out to be absent, while the bin stays locked. Racing callers on the same missing key don't each run the loader and throw a result away, they queue behind that one bin, and every one of them ends up with the same single result. That's a meaningfully stronger guarantee than "check, then put": the loader itself runs at most once, not just the insert.
Two more compound ops round out the toolkit. merge(key, value, function) is the idiomatic fix for the recordHit bug from a few chunks back: it inserts your value if the key is absent, or otherwise combines the existing value with your value using the function you give it, all as one atomic per-key step.
hits.merge(endpoint, 1, Int::plus) // atomic per-key increment, replaces the racy version
replace(key, expected, newValue) is different in shape: it's compare-and-set at the map level. You read the current value, compute a new one from it, and replace only if nobody else changed that key in the meantime; if someone did, you retry.
do {
val cur = map[k] ?: break
val next = cur.withCount(cur.count + 1)
} while (!map.replace(k, cur, next))
That loop is how you get an atomic read-modify-write out of values the map doesn't have a dedicated combining function for, without ever locking a bin for the whole computation. compute and computeIfPresent round out the family, "always transform" and "transform only if present", following the same rule as everything else here: your function runs under that key's bin lock, as one atomic step.
All of these compound operations share one rule: whatever function you pass, whether it's compute's transform or computeIfAbsent's loader, runs while that bin is locked. Two consequences follow. First, keep it short and fast: a slow loader stalls every other thread waiting on that same bin, not just you. Second, and more sharply, never touch the same map from inside that function. A recursive or re-entrant call back into the map can throw IllegalStateException or deadlock outright.
// broken: recursion re-enters computeIfAbsent while a bin may still be locked
fun fib(n: Int): Long = memo.computeIfAbsent(n) { fib(n - 1) + fib(n - 2) }
Here the lambda for fib(n) calls fib(n - 1) and fib(n - 2), and each of those calls computeIfAbsent again on the very same map, potentially while a bin from the outer call is still locked. The fix is to move the recursion outside the mapping function entirely: compute the value with plain recursive calls first, then store the finished result with a simple putIfAbsent once you actually have it.
Two more behaviors are worth knowing cold, because both look like bugs until you know they're the documented contract. Iteration is **weakly consistent**: iterators never throw ConcurrentModificationException the way a plain HashMap's would, and each entry is visited at most once, but there's no snapshot taken, so an iterator may or may not reflect updates made by other threads while it runs.
for ((k, v) in map) { report(k, v) } // safe alongside writers, just not a fixed snapshot
size() and isEmpty() are similarly **estimates**, aggregated from counters that are deliberately spread across the table to avoid contention, while writers keep changing them concurrently. They're fine for logging or metrics. They are not fine for gating a decision.
if (map.size < LIMIT) map[k] = v // racy: size() can be stale by the time you act on it
And this generalizes further than counting: none of the map's atomic operations protect an invariant that spans the whole collection, only per-key ones. If you need a hard cap on total entries, checking size() first and then inserting is exactly that racy check-then-act pattern again, just at the level of the whole map instead of one key. Enforcing a real capacity limit needs something outside the map itself, an AtomicInteger permit counter you compareAndSet before every insert, for instance, since no compound operation on ConcurrentHashMap alone can make "insert, but only if the whole map is still under N" atomic.
One more rule the map enforces hard: no null keys, no null values. map[key] = null throws immediately, it doesn't silently accept it.
map[key] = null // NullPointerException
map[key] = Value.None // model absence explicitly instead
The reason traces straight back to what get() has to mean. In a plain HashMap, a null return is ambiguous between "the key isn't there" and "the key is there, mapped to null", so you disambiguate with a follow-up containsKey() call. On a concurrent map that second call races with whatever other threads are doing, and can give you a different answer than the first call did. Banning null entirely keeps things simple: get() == null always means absent, full stop, no second call needed to be sure. If you genuinely need to represent "present but empty", model that with a sentinel or a sealed type instead of reaching for null.
A few extras round out the picture. ConcurrentHashMap.newKeySet() gives you a concurrent Set backed by the same map internals, lock-free reads, per-bin writes, weakly consistent iteration, useful any time you need a set of things many threads add to and remove from at once, like in-flight request ids.
val inFlight = ConcurrentHashMap.newKeySet<String>()
if (inFlight.add(id)) launchRequest(id) // add() is atomic, no id is ever added twice
The map also has bulk parallel operations, forEach, search, and reduce, that take a parallelismThreshold and can fan the work itself across threads for large maps, useful when you need to process every entry and the work per entry is nontrivial.
One last subtlety, because it trips people up in Kotlin specifically: getOrPut on a ConcurrentMap is built on putIfAbsent, not on computeIfAbsent. Its default-value function runs outside any lock, before the map is touched at all, so under a race two threads can both run that function, and only one result gets stored and handed back to everyone. If the loader is cheap and side-effect-free, that's fine. If it's expensive or has side effects, reach for computeIfAbsent instead, which locks the bin around the whole function and guarantees it runs at most once.
cache.getOrPut(k) { load(k) } // load() may run twice, one result wins
cache.computeIfAbsent(k) { load(it) } // load() runs once, bin locked meanwhile
Put it all together and the interview answer is one coherent story: writes CAS into empty bins and synchronize only on occupied ones, reads never lock because the fields they walk are volatile, resizes are cooperative instead of stop-the-world, and none of that turns a sequence of calls into a transaction, so compound logic always goes through putIfAbsent, computeIfAbsent, merge, or replace instead of a hand-rolled check-then-act. Know that story, know the null ban and the weak-consistency contract, and you've covered what an interviewer is actually listening for.