Every Java backend eventually shares a map across threads — a cache, a session store, a metrics counter. Pick HashMap for that job and you get bugs that pass every test on your laptop and surface only under production load. Pick ConcurrentHashMap and the same code just works.
This comparison shows exactly what breaks, why, and how ConcurrentHashMap fixes it without the performance penalty of the old synchronized approach. It assumes you know how HashMap works internally — buckets, chains, resizing — because the thread-safety story happens inside those structures.
Verdict at a glance
| Dimension | HashMap | ConcurrentHashMap |
|---|---|---|
| Thread-safe | no | yes |
| Read performance | fastest | near-identical, lock-free |
| Write locking | none (unsafe) | per-bucket (CAS + synchronized on bucket head) |
| Null key / values | one null key, null values OK | both forbidden |
| Iterator | fail-fast (throws CME) | weakly consistent (never throws CME) |
| Atomic compound ops | no (has the methods, not atomic) | yes — putIfAbsent, compute, merge are atomic |
size() |
exact | estimate under concurrent updates |
| Since | Java 1.2 | Java 5 (rewritten in Java 8) |
| Use when | single thread / thread-confined | shared mutable state |
Verdict: thread-confined map → HashMap. Map shared by concurrently writing threads → ConcurrentHashMap. There is no third case that survives code review.
What actually breaks with a shared HashMap
"HashMap is not thread-safe" stays abstract until you watch it lose data. This program runs two threads, each putting 50,000 distinct keys:
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class LostUpdates {
public static void main(String[] args) throws InterruptedException {
Map<Integer, Integer> unsafe = new HashMap<>();
Map<Integer, Integer> safe = new ConcurrentHashMap<>();
System.out.println("HashMap size: " + fill(unsafe));
System.out.println("ConcurrentHashMap size: " + fill(safe));
}
static int fill(Map<Integer, Integer> map) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50_000; i++) map.put(i, i);
});
Thread t2 = new Thread(() -> {
for (int i = 50_000; i < 100_000; i++) map.put(i, i);
});
t1.start(); t2.start();
t1.join(); t2.join();
return map.size();
}
}
Expected size: 100,000, since every key is unique. The ConcurrentHashMap reports exactly that. The HashMap typically reports less — entries vanish. Why: two threads append to the same bucket simultaneously, each reads the same chain head, and the second write overwrites the first thread's link. During a resize the window gets worse, because entries are being redistributed while another thread inserts.
Java 7's resize could even produce a circular linked list, hanging threads in an infinite get() loop at 100% CPU. Java 8's order-preserving resize removed that specific failure, but lost updates, wrong sizes and broken invariants all remain. The absence of a crash is not the presence of safety.
Common mistake: A common mistake beginners make is assuming a map that is only read by many threads but written during startup is fine as a HashMap. It is fine — but only if the handover between the writing phase and the reading phase has proper synchronization (a
finalfield, avolatilepublish, or framework-managed initialization). Without a safe publication point, readers may see a partially built map. The rules come from the Java memory model, covered in synchronization in Java.
How ConcurrentHashMap locks: the Java 8 design
The naive fix — one big lock, as in Hashtable or Collections.synchronizedMap() — makes every thread queue for every operation. ConcurrentHashMap is smarter, and the design changed significantly over time:
Java 5–7: segment locking. The map was split into 16 segments by default, each a small hashtable with its own lock. Sixteen writers could proceed in parallel if they hit different segments.
Java 8+: per-bucket CAS + synchronized. Segments were retired. The map is now a single table, like HashMap's, and locking happens at the finest possible grain:
- Insert into an empty bucket → a lock-free compare-and-swap (CAS) places the node. No lock at all.
- Write to an occupied bucket →
synchronizedon that bucket's head node only. Writers to other buckets proceed untouched. - Reads → no locks, ever.
Node.valandNode.nextarevolatile, so readers always see completed writes. - Resize → cooperative: writer threads that notice a resize in progress help transfer buckets instead of blocking behind one resizer.
With the default table of thousands of buckets under real load, two writers colliding on the same bucket at the same instant is rare — so contention stays near zero while correctness stays absolute.
Interview note: "How does ConcurrentHashMap work internally?" is usually a follow-up to the HashMap internals question. The high-value keywords are: CAS for empty buckets, synchronized on the bucket head (not the whole map), volatile reads with no read locks, segment locking as the pre-Java-8 historical answer, and treeification carried over from HashMap. Candidates who still describe 16 segments as the current design date their knowledge to Java 7.
Atomic compound operations: the real superpower
Thread safety per method call is not enough. The classic race is check-then-act:
if (!map.containsKey(key)) { // thread B can slip in here
map.put(key, expensiveCreate(key));
}
Even on a fully synchronized map, two threads can both pass the check and both create the value. ConcurrentHashMap's compound methods run the check and the write atomically under the bucket lock:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
public class AtomicOps {
public static void main(String[] args) throws InterruptedException {
Map<String, Integer> hits = new ConcurrentHashMap<>();
CountDownLatch done = new CountDownLatch(4);
for (int t = 0; t < 4; t++) {
new Thread(() -> {
for (int i = 0; i < 25_000; i++) {
hits.merge("/home", 1, Integer::sum); // atomic count
}
done.countDown();
}).start();
}
done.await();
System.out.println(hits.get("/home")); // always exactly 100000
}
}
Four threads increment the same counter 100,000 times in total, and the result is exact every run — no synchronized block in sight. The same guarantee backs putIfAbsent() (insert exactly once), computeIfAbsent() (build-once caches) and compute() (read-modify-write). On a plain HashMap, these default methods exist but are not atomic.
One discipline to keep: mapping functions passed to compute*() run while the bucket is locked, so keep them short and never touch the same map inside them.
The null rule and weakly consistent iteration
Two behavioral differences trip up people migrating from HashMap:
No nulls, by design. map.get(k) == null must mean exactly one thing in a concurrent world: the key is absent. Allowing null values would make that ambiguous, and resolving the ambiguity with containsKey() is itself a race. If you stored null to mean "known to be missing", store a sentinel object or use Optional as the value instead.
Weakly consistent iterators. HashMap iterators throw ConcurrentModificationException if the map changes mid-iteration. ConcurrentHashMap iterators never throw — they reflect the map at some point since creation and may or may not show concurrent updates. Related: size() is an estimate while writers are active, so never build logic on an exact live count.
Performance: measure the right thing
Single-threaded, HashMap wins by a small margin — ConcurrentHashMap pays for volatile semantics and CAS bookkeeping even with no contention. That is why the answer is not "use ConcurrentHashMap everywhere".
Multi-threaded, the ranking inverts hard. Under mixed read/write load from several threads, ConcurrentHashMap scales close to linearly with cores because reads never block and writes rarely collide, while Hashtable and synchronizedMap flatline — every operation queues on one lock. (And HashMap is disqualified: fast wrong answers are still wrong.) The HashMap vs Hashtable comparison covers that older single-lock story in depth.
Both classes share the same asymptotics — average O(1) get/put, treeified buckets capping the worst case at O(log n) — so the choice is purely about the threading model, not about big-O.
Choose HashMap if / choose ConcurrentHashMap if
Choose HashMap if:
- The map lives and dies inside one method, one request, or one thread.
- A framework guarantees single-threaded access (e.g., a per-request context).
- You need null values, or the last few percent of single-thread speed.
- You want the simplest mental model while learning collections.
Choose ConcurrentHashMap if:
- Multiple threads write the map at any point in its life — caches, registries, counters, session stores.
- You need atomic check-then-act semantics (
putIfAbsent,computeIfAbsent,merge). - You iterate while other threads update, and want no
ConcurrentModificationException. - You are building on thread pools or executors — anything touched by an ExecutorService callback counts as shared state.
For freshers: the 30-second interview answer
"HashMap is not thread-safe — concurrent writes can lose updates and corrupt the bucket structure, and in Java 7 a concurrent resize could even loop forever. ConcurrentHashMap is thread-safe with fine-grained locking: since Java 8 it CASes into empty buckets, synchronizes only on a bucket's head node for writes, and reads are lock-free via volatile fields. It bans nulls to keep get() unambiguous, its iterators are weakly consistent instead of fail-fast, and it adds atomic operations like putIfAbsent and merge. So: HashMap for thread-confined maps, ConcurrentHashMap for shared ones."
If you can then field the follow-ups — why nulls are banned, segments vs CAS, why size() is approximate — you are ahead of most candidates at the 2–4 year level. Drill the whole cluster with the Java collections interview questions, and pressure-test your delivery in a mock interview before the real one.
Frequently Asked Questions
What happens if multiple threads use a plain HashMap?
How does ConcurrentHashMap achieve thread safety without locking the whole map?
Why does ConcurrentHashMap not allow null keys or values?
Is ConcurrentHashMap better than Collections.synchronizedMap()?
Does ConcurrentHashMap make check-then-put code safe?
Should I just use ConcurrentHashMap everywhere to be safe?
Want to Build Your Career in Java Full Stack with AI?
Join CodeBegun and train with working industry engineers — Explore the Program

