Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

ConcurrentHashMap vs Hashtable vs synchronizedMap

How ConcurrentHashMap achieves concurrency without a global lock, why segments disappeared in Java 8, the computeIfAbsent deadlock, and why size() is only an estimate.

Advanced7 min readUpdated
On this page

ConcurrentHashMap is what you should reach for whenever a map is shared between threads, and the interview question is almost always "how is it different from Hashtable and from Collections.synchronizedMap?" The answer is about lock granularity, and it has changed once.

Key Takeaways

  • Hashtable and synchronizedMap hold one lock for the whole map. Every operation serialises.
  • ConcurrentHashMap locks one bin at a time, and uses a CAS when the bin is empty — no lock at all in the common case.
  • Reads are lock-free always, made safe by volatile node fields.
  • Iterators are weakly consistent: no ConcurrentModificationException, but no snapshot either.
  • The atomic compound operations — putIfAbsent, computeIfAbsent, merge — are the reason to use it beyond thread safety.

Three ways to share a map

HashtableCollections.synchronizedMapConcurrentHashMap
Lock scopeWhole mapWhole map (a mutex object)One bin
Reads blockYesYesNo
Null key/valueNeitherDepends on the wrapped mapNeither
IterationFail-fast, needs external syncFail-fast, needs external syncWeakly consistent
Atomic compound opsOnly putIfAbsentNoneFull set
IntroducedJava 1.0Java 1.2Java 5

Hashtable is a Java 1.0 class kept for compatibility; every method is synchronized, including get. There is no reason to use it in new code.

Collections.synchronizedMap(new HashMap<>()) wraps each method in a synchronized block on a shared mutex. It is marginally more flexible — you choose the underlying map — and has exactly the same throughput problem. Worse, it has a trap:

the wrapper does not make you safe
Map<String, Integer> m = Collections.synchronizedMap(new HashMap<>());
 
// Each call is atomic. The SEQUENCE is not — two threads can both read absent
// and both write, losing one increment.
if (!m.containsKey(k)) {
    m.put(k, 1);
}
 
// Iteration is not synchronised at all. The javadoc requires this:
synchronized (m) {
    for (String key : m.keySet()) { ... }
}

That second point catches people out constantly: synchronizedMap synchronises the methods, not the iterator, so iterating without an explicit synchronized block on the map risks ConcurrentModificationException.

How ConcurrentHashMap works

An empty bin is filled with a compare-and-swap. A non-empty bin locks only its own head node, so writers to other bins never wait.

Three mechanisms combine:

Compare-and-swap for empty bins. Most bins in a well-distributed map are empty or hold one entry. Inserting into an empty bin is a single atomic CPU instruction with no lock acquisition, no contention and no blocking.

Per-bin locking for occupied bins. When a bin already has a head node, the writer synchronises on that node object. Two threads writing to different bins never interact. With a 1024-slot table you effectively have 1024 independent locks, which is why concurrency scales with the map rather than being capped.

Volatile reads. Node.val and Node.next are volatile, so a reader always sees a consistent, recently written value without acquiring anything. Reads never block, never contend, and never prevent a writer from proceeding.

Java 7's design was different: the map was split into 16 (configurable) Segment objects, each a small independent hash table with its own ReentrantLock. The concurrencyLevel constructor parameter set that number, and it was a hard ceiling on write parallelism. Java 8 deleted the whole mechanism; the constructor parameter still exists and is now only a sizing hint.

The atomic operations

This is the part that matters most in day-to-day code, and it is what synchronizedMap cannot give you.

atomic compound operations
ConcurrentHashMap<String, AtomicLong> counters = new ConcurrentHashMap<>();
 
// Insert only if absent — atomic, returns the existing value if there was one.
counters.putIfAbsent(key, new AtomicLong());
 
// Compute the value only if absent. The function runs at most once per key
// even under contention, which putIfAbsent cannot promise (it constructs
// the value eagerly, then throws it away on a race).
counters.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
 
// Read-modify-write in one atomic step.
ConcurrentHashMap<String, Long> totals = new ConcurrentHashMap<>();
totals.merge(key, 1L, Long::sum);
 
// Conditional replace — the CAS of the map world.
totals.replace(key, expectedOld, newValue);
 
// Remove only if the value matches.
totals.remove(key, expectedValue);

computeIfAbsent versus putIfAbsent is a good interview distinction. putIfAbsent(k, new Expensive()) constructs the value before the call regardless — the same evaluation trap as Optional.orElse. computeIfAbsent defers construction and guarantees the function runs at most once for a given key.

The same rule applies to compute, merge and forEach with a mutating action. Long-running work inside any of them blocks every other writer to that bin.

Weakly consistent iteration

no exception, and no snapshot
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(...);
 
for (Map.Entry<String, Integer> e : map.entrySet()) {
    map.put("new-" + e.getKey(), 0);   // legal — no ConcurrentModificationException
}

A ConcurrentHashMap iterator reflects the state of the map at some point at or after creation. It never throws ConcurrentModificationException, will traverse each element at most once, and may or may not observe modifications made after it started.

That is a genuinely different contract from HashMap's fail-fast iterator, and both are defensible: fail-fast catches a programming error in single-threaded code, while weak consistency is the only thing that can be offered without locking the whole map for the duration of a traversal. The cost is that you cannot iterate a ConcurrentHashMap and assume a coherent view — computing a total by iterating may include entries added mid-traversal and miss others.

size(), and why it lies

The map does not keep one counter, because a single shared counter would be the contention point the whole design exists to avoid. Instead it uses striped counters — an array of cells, each updated by a different thread, summed on demand. That is the same technique as LongAdder.

size() returns an int and is documented as an estimate. mappingCount() returns a long and is the preferred method — the map can genuinely hold more than Integer.MAX_VALUE entries.

In practice the distinction rarely matters, because in a concurrent map any size is out of date the moment it is returned. Code that reads size() and then acts on it is racy regardless of accuracy.

Bulk parallel operations

Java 8 added parallel bulk methods that use the common ForkJoinPool:

parallel bulk operations
// The first argument is a parallelism threshold: below this many elements,
// run sequentially. Long.MAX_VALUE means "always sequential".
map.forEach(1000, (k, v) -> process(k, v));
 
String found = map.search(1000, (k, v) -> v.isExpired() ? k : null);
 
long total = map.reduceValues(1000, Order::amount, 0L, Long::sum);

They are rarely the right tool — the same caveats as parallel streams apply, plus the risk of running application code while holding bin locks — but knowing they exist, and that the first parameter is a parallelism threshold rather than a thread count, is a good detail to have.

What gets asked

"How does ConcurrentHashMap achieve thread safety without locking the whole map?" is the core question, and the complete answer is: CAS for empty bins, synchronise on the bin's head node otherwise, volatile reads so readers never block. Then: what changed in Java 8 (segments removed); why nulls are disallowed (ambiguity between absent and null); and why you would choose it over synchronizedMap (throughput, plus atomic compound operations). If you can add the computeIfAbsent recursion hazard unprompted, the question is usually over.

Frequently Asked Questions

Does ConcurrentHashMap still use segments?
No. Segment-based locking was the Java 7 design, where the map was divided into 16 independent sub-maps each with its own lock, giving a fixed concurrency level. Java 8 removed it entirely in favour of locking the first node of an individual bin, with a compare-and-swap for the common case of inserting into an empty bin. Concurrency now scales with the table size rather than being capped at the segment count.
Why is ConcurrentHashMap.size() approximate?
Because maintaining an exact count would require a single shared counter that every write contends on, which would serialise the whole map. Instead the count is spread across striped cells summed on demand, so the result is accurate at some instant but may be stale by the time you read it. In a concurrent map any size is stale immediately anyway — use mappingCount(), which returns a long and is honest about being an estimate.
Can computeIfAbsent deadlock?
Yes. The mapping function runs while the bin is locked, so if it modifies the same map — inserting another key that hashes to the same bin, or recursively calling computeIfAbsent — the thread blocks on a lock it already holds, or corrupts the map. Since Java 9 the implementation detects recursive updates and throws IllegalStateException instead of hanging, but the rule stands: the mapping function must be short, side-effect free, and must not touch the map.

Related tutorials