ConcurrentHashMap is the workhorse concurrent collection in Java: a hash map that many threads read and write simultaneously with almost no contention. It replaced both Hashtable and Collections.synchronizedMap(), which serialise every operation behind one lock. Understanding how it achieves concurrency — and the surprising consequences for operations like size() and check-then-act — is essential for writing correct concurrent code.
How it achieves concurrency
Reads are entirely lock-free: the internal nodes use volatile fields, so a get() never blocks and never takes a lock, even while other threads are writing. Writes lock only the single bin (bucket) being modified, not the whole map. In modern implementations (Java 8+) that per-bin lock is the bin's head node via synchronized, with a CAS fast-path for inserting into an empty bin.
The pre-Java-8 design used a fixed number of coarse 'segments'; Java 8 replaced that with per-bin locking plus tree-ification of long collision chains (a bin with many colliding keys converts from a linked list to a red-black tree, bounding worst-case lookup at O(log n)). The net effect: concurrency scales with the number of bins, not a fixed segment count.
The null rule
Unlike HashMap, ConcurrentHashMap forbids null keys and null values — both throw NullPointerException. This is deliberate: in a concurrent map, map.get(k) == null is ambiguous. It could mean 'key absent' or 'key present with a null value', and there is no way to disambiguate atomically without a lock. Forbidding null values removes the ambiguity, so a null return unambiguously means 'not present'.
map.put("k", null); // throws NullPointerException
map.get("missing"); // null unambiguously means absentAtomic compound operations
The methods that make it genuinely useful are the atomic read-modify-write operations. A plain if (!map.containsKey(k)) map.put(k, v) is a race — two threads can both pass the check. The atomic methods close that gap:
| Method | Atomic behaviour |
|---|---|
putIfAbsent(k, v) | insert only if absent; returns existing value if present |
computeIfAbsent(k, fn) | compute and insert only if absent, atomically |
compute(k, fn) | atomically recompute the mapping |
merge(k, v, fn) | combine new value with existing via fn — ideal for counters |
// atomic frequency counter -- no race, no explicit lock
map.merge(word, 1, Integer::sum);
// atomic lazy initialisation of a per-key list
map.computeIfAbsent(key, k -> new CopyOnWriteArrayList<>()).add(item);The mapping function in compute* runs while the bin is locked, so it must be short and must not try to update the same map (re-entrant modification can deadlock or throw). Keep it fast and side-effect-free on the map itself.
Why size() and isEmpty() are estimates
Because writes proceed concurrently across bins with no global lock, there is no single consistent moment at which to count entries. size() returns a value that was accurate at some instant but may be stale by the time it returns; under active modification it is fundamentally an estimate. mappingCount() (returning long) is the preferred modern accessor for large maps. The practical rule: never use size() for control flow in concurrent code — rely on the atomic per-operation methods, which reflect the true state at the instant they run.
Bulk operations and weakly consistent iteration
Java 8 added parallel bulk operations — forEach, search, and reduce — that split the work across the common ForkJoinPool when the map is large enough (governed by a parallelism threshold). Iterators and these bulk ops are weakly consistent: they reflect the map at some point during traversal, never throw ConcurrentModificationException, and may or may not see concurrent updates. This is what makes iterating a live, concurrently-mutated map safe — you get a fuzzy but crash-free traversal rather than a fail-fast exception.
When to reach for something else
ConcurrentHashMap is the default for a concurrent map, but not universal. If reads vastly outnumber writes and the key set is small and stable, Collections.unmodifiableMap over an immutable copy avoids all synchronization. For maps where iteration must be a true snapshot, or writes are rare and expensive to synchronize, a copy-on-write approach fits. And when you need ordering (ConcurrentSkipListMap for sorted, concurrent navigation) or a bounded evicting cache (Caffeine or Guava), those specialised structures beat retrofitting ConcurrentHashMap.
ConcurrentHashMap gives lock-free reads and per-bin-locked writes — scaling far past Hashtable's single lock. It forbids null keys/values to keep 'get returns null' unambiguous. Use the atomic merge/compute*/putIfAbsent methods for check-then-act, never a separate contains-then-put. Treat size() as an estimate and iterators as weakly consistent.