The direct answer: HashMap is for one thread at a time, and ConcurrentHashMap is for maps that several threads read and write at once. HashMap does no synchronisation, so concurrent updates can silently lose data. ConcurrentHashMap is built for concurrency: reads don’t lock, writes lock only a small part of the table, and it has atomic methods for read-modify-write updates. It also refuses null keys and values, which HashMap allows.
Interviewers usually add a third option, Collections.synchronizedMap(new HashMap<>()), and ask why you’d pick ConcurrentHashMap over it. That comparison, plus the iteration and null rules, is where most of the marks are.
The three options compared
HashMap |
Collections.synchronizedMap |
ConcurrentHashMap |
|
|---|---|---|---|
| Thread-safe | No | Yes, per method call | Yes, per method call |
| Locking | None | One lock for the whole map | No lock for reads; one bin locked per write |
| Concurrent readers | Unsafe if anyone writes | Take turns on the lock | Run in parallel |
null keys / values |
One null key, any null values |
Whatever the wrapped map allows | Neither (NullPointerException) |
| Iteration while others write | Fail-fast, best-effort ConcurrentModificationException |
Must hold the map’s lock manually | Weakly consistent, never throws CME |
| Atomic compound updates | No | Only if you lock around them yourself | merge, compute, computeIfAbsent, putIfAbsent, replace |
size() under writes |
Can be wrong | Exact | Estimate while updates run |
Hashtable is the legacy version of the middle column: every method is synchronized and it rejects null. There’s no reason to choose it in new code.
What actually goes wrong with HashMap
HashMap assumes nobody else touches it during an operation. A put reads the bucket, links in a node, increments size and may resize the whole table. If two threads interleave these steps, one thread’s node can overwrite the other’s, size can drift from the real count, and a resize can lose entries that were being moved. Nothing throws. You just get a map with missing data.
A word counter shows it. Four threads each record 100,000 words into one map:
Map<String, Integer> counts = new HashMap<>(); // the bug
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int t = 0; t < 4; t++) {
pool.submit(() -> {
for (int i = 0; i < 100_000; i++) {
counts.merge("word-" + (i % 1_000), 1, Integer::sum);
}
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
int total = counts.values().stream().mapToInt(Integer::intValue).sum();
System.out.println(total); // should be 400000With HashMap the total can come out below 400,000 and change from run to run, and depending on timing a thread can also hit an exception from a half-updated table. Change the first line to new ConcurrentHashMap<>() and it’s always 400,000, because ConcurrentHashMap.merge performs the read, the addition and the write as one atomic step for that key. (This snippet, like the others here, is illustrative Java 17 and wasn’t executed for this page; race outcomes depend on the machine anyway.)
You may hear that concurrent resizing makes HashMap loop forever. That was a real failure in Java 7 and earlier, where a resize reversed bucket lists and two threads could build a cycle. Java 8 changed resizing to preserve order, so that particular hang is gone, but the map is still unsafe. Mentioning the history and the fix shows you know the difference between “the famous symptom went away” and “it’s thread-safe now”.
Why a synchronized wrapper isn’t enough
Collections.synchronizedMap(map) returns a wrapper whose every method locks one shared mutex before delegating. That makes individual calls safe, but it has two weaknesses.
First, everything waits on that one lock. Eight threads doing get take turns, even though reading a map is harmless when nobody writes. Under contention this becomes the bottleneck of the service.
Second, safety stops at method boundaries. Iteration is many calls (hasNext, next, …) and the wrapper can’t hold the lock across them, so the Javadoc tells you to synchronise manually:
Map<String, Session> sessions = Collections.synchronizedMap(new HashMap<>());
synchronized (sessions) { // without this block: possible ConcurrentModificationException
for (Session s : sessions.values()) {
audit(s);
}
}While that block runs, every other thread that touches sessions is blocked. Compound actions have the same issue: if (!m.containsKey(k)) m.put(k, v) is two locked calls with a gap between them where another thread can insert first.
How ConcurrentHashMap avoids the bottleneck
Since Java 8, ConcurrentHashMap stores entries in an array of bins. Reads are lock-free: fields are read with the memory-visibility guarantees of volatile, so a get sees a fully constructed entry without taking a lock. Writing to an empty bin is a single compare-and-swap. Writing to an occupied bin synchronises on that bin’s first node only, so threads updating different keys rarely block each other. Resizing is shared: threads that arrive during a resize help move bins instead of waiting. (The Java 7 implementation used a fixed number of lock “segments”; you’ll still see interview answers describe segments, which is outdated.)
The useful API is the atomic per-key methods:
ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent(path, p -> new LongAdder()).increment(); // create once, then count
ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
stock.merge("sku-42", 5, Integer::sum); // atomic add
stock.computeIfPresent("sku-42", (k, v) -> v > 1 ? v - 1 : null); // returning null removes the keyA sequence of these calls isn’t atomic as a whole, though. get followed by put still races; use one merge or compute instead. Keep the functions you pass short and side-effect free, since the bin stays locked while they run, and never update another key of the same map from inside them: since Java 9 that can fail with IllegalStateException: Recursive update. The details are in Java concurrent collections and compound operations.
Iterators are weakly consistent. They never throw ConcurrentModificationException, they see each entry at most once, and they may or may not reflect updates made after the iterator was created. That’s fine for monitoring and cleanup, but not when you need a consistent snapshot. Likewise size() is accurate when the map is quiet and an estimate while threads are writing; mappingCount() returns a long for maps that could exceed Integer.MAX_VALUE entries.
The null rule and why it exists
ConcurrentHashMap.put("k", null) throws NullPointerException, and so does a null key. The reason is ambiguity. In a HashMap, get(k) == null can mean “absent” or “mapped to null”, and you resolve it with containsKey(k). In a concurrent map, another thread can change the entry between those two calls, so the check is meaningless. Banning null makes null from get always mean “absent”, which is also what putIfAbsent, computeIfAbsent and friends rely on.
If you’re migrating a HashMap that stores null values, model absence explicitly: remove the key, or store an Optional or a sentinel object.
Choosing in practice
- Confined to one thread (a local variable, a request-scoped object, a map built then published immutably):
HashMap. It’s the fastest, and adding concurrency control costs something for nothing. - Shared and mutated by several threads (caches, registries, counters):
ConcurrentHashMap. - Built once and only read afterwards: build a
HashMap, then publishMap.copyOf(it)safely (through afinalfield, for example). Immutable maps need no locking at all. synchronizedMap: mainly when you need to wrap a map you don’t control, such as aTreeMaporLinkedHashMapwith access order. For sorted concurrent maps,ConcurrentSkipListMapis usually better.
Practice question: the session sweeper
A web app keeps sessions in Collections.synchronizedMap(new HashMap<>()). Request threads add and remove sessions. A scheduled job loops over sessions.entrySet() every minute and removes expired ones with the iterator’s remove(). A few times a day the job dies with ConcurrentModificationException. Explain why, and give two fixes with their trade-offs.
Answer
Each method on the wrapper is synchronised, but the loop isn’t one method. Between the sweeper’s next() calls, a request thread can lock the map, add a session and release the lock. The underlying HashMap’s iterator notices that the map changed underneath it (its modification count moved) and throws. It’s intermittent because it needs a write to land in the middle of a sweep.
Fix one: wrap the loop in synchronized (sessions) { ... }, which is what the synchronizedMap Javadoc asks for. It’s correct, but every request that touches sessions stalls while the sweep runs, which grows with the number of sessions.
Fix two, usually better: switch to ConcurrentHashMap and sweep with sessions.entrySet().removeIf(e -> e.getValue().expiresAt().isBefore(now)). Its iterators are weakly consistent, so there’s no exception and no global lock; requests carry on during the sweep. The trade-off is that the sweep might miss a session that expires or appears while it runs, which is harmless because the next sweep catches it. If any code stored null values in the old map, that has to change first.