Definition
- HashMap: not thread-safe, allows one null key and multiple null values, fail-fast iterator, no atomic compound operations
- ConcurrentHashMap: thread-safe (per-bucket locking + CAS), no null keys/values, weakly consistent iterator, atomic operations (computeIfAbsent, merge, compute)
- HashMap under concurrent access causes data corruption silently; ConcurrentHashMap handles concurrent access correctly
- HashMap is the choice for single-threaded code; ConcurrentHashMap is the choice for multi-threaded code
HashMap is like a private office desk – you work fast because nobody else is there, but if two people use it at the same time, papers get mixed up and lost. ConcurrentHashMap is like a shared filing system with individual drawer locks – multiple people use it simultaneously without interfering, and reading a drawer never requires a key.
Understanding the Concepts
How does HashMap differ from ConcurrentHashMap?
The core difference is thread safety, but the implications go much deeper than just adding locks.
| Feature | HashMap | ConcurrentHashMap |
|---|---|---|
| Thread-safe | No | Yes (per-bucket) |
| Null key | 1 allowed | Not allowed (NPE) |
| Null value | Multiple allowed | Not allowed (NPE) |
| Read locking | None | None (volatile) |
| Write locking | None | CAS or sync(bucket) |
| Iterator | Fail-fast (CME) | Weakly consistent |
| computeIfAbsent | Not atomic | Atomic |
| merge | Not atomic | Atomic |
| Concurrent resize | No | Yes (multi-thread) |
| Performance (single) | Fastest | Slightly slower |
| Performance (multi) | Unsafe | Excellent |
package com.bank.service;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.*;
public class HashMapVsCHMDemo {
public static void main(String[] args) throws InterruptedException {
// Demonstrate the lost-update problem with HashMap
Map<String, Integer> unsafeMap = new HashMap<>();
unsafeMap.put("SAV2001", 0);
// 10 threads each incrementing 1000 times = expected 10000
int threads = 10;
int increments = 1000;
CountDownLatch latch = new CountDownLatch(threads);
for (int t = 0; t < threads; t++) {
new Thread(() -> {
for (int i = 0; i < increments; i++) {
// RACE CONDITION: read-modify-write is not atomic
unsafeMap.put("SAV2001", unsafeMap.get("SAV2001") + 1);
}
latch.countDown();
}).start();
}
latch.await();
System.out.println("HashMap (expected 10000): " + unsafeMap.get("SAV2001"));
// Same test with ConcurrentHashMap + atomic merge
ConcurrentHashMap<String, Integer> safeMap = new ConcurrentHashMap<>();
safeMap.put("SAV2001", 0);
CountDownLatch latch2 = new CountDownLatch(threads);
for (int t = 0; t < threads; t++) {
new Thread(() -> {
for (int i = 0; i < increments; i++) {
safeMap.merge("SAV2001", 1, Integer::sum); // atomic!
}
latch2.countDown();
}).start();
}
latch2.await();
System.out.println("CHM (expected 10000): " + safeMap.get("SAV2001"));
}
}
HashMap (expected 10000): 6847
CHM (expected 10000): 10000
Why are HashMap's compound operations unsafe?
The classic "check-then-act" pattern is not atomic in HashMap. Between the check and the action, another thread can modify the map.
// WRONG – race condition with HashMap
Map<String, List<String>> txnsByAccount = new HashMap<>();
// Thread 1 and Thread 2 both add a transaction for SAV2001:
if (!txnsByAccount.containsKey("SAV2001")) { // both threads see: absent
txnsByAccount.put("SAV2001", new ArrayList<>()); // both create a new list
} // Thread 2's list overwrites Thread 1's
txnsByAccount.get("SAV2001").add("TXN-001"); // Thread 1's transaction is LOST
// CORRECT – atomic with ConcurrentHashMap
ConcurrentHashMap<String, List<String>> safeTxns = new ConcurrentHashMap<>();
safeTxns.computeIfAbsent("SAV2001", k -> new CopyOnWriteArrayList<>())
.add("TXN-001"); // atomic: list created only once
What is the iterator behavior difference?
HashMap's iterator is fail-fast: if the map is structurally modified during iteration (except through the iterator's own remove), it throws ConcurrentModificationException. This is a safety mechanism, not a guarantee – it detects corruption on a best-effort basis.
ConcurrentHashMap's iterator is weakly consistent: it never throws CME, it may or may not reflect modifications made after the iterator was created, and it traverses elements as they existed at some point during iteration.
package com.bank.demo;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class IteratorDifferenceDemo {
public static void main(String[] args) {
// HashMap: modifying during iteration throws CME
Map<String, String> hm = new HashMap<>(Map.of("A", "1", "B", "2", "C", "3"));
try {
for (var entry : hm.entrySet()) {
if (entry.getKey().equals("B")) {
hm.remove("A"); // structural modification
}
}
} catch (ConcurrentModificationException e) {
System.out.println("HashMap: " + e.getClass().getSimpleName());
}
// ConcurrentHashMap: safe to modify during iteration
ConcurrentHashMap<String, String> chm =
new ConcurrentHashMap<>(Map.of("A", "1", "B", "2", "C", "3"));
for (var entry : chm.entrySet()) {
if (entry.getKey().equals("B")) {
chm.remove("A"); // no exception
}
}
System.out.println("CHM after: " + chm);
}
}
HashMap: ConcurrentModificationException
CHM after: {B=2, C=3}
What are common mistakes?
Mistake: Using HashMap with multiple threads and assuming it works
// WRONG – no exceptions thrown, but data is silently corrupted
Map<String, BigDecimal> balances = new HashMap<>();
// Thread 1: balances.put("SAV2001", new BigDecimal("50000"));
// Thread 2: balances.put("CUR3001", new BigDecimal("75000"));
// Possible outcomes: one entry lost, wrong bucket, size=1 instead of 2
// CORRECT
ConcurrentHashMap<String, BigDecimal> balances = new ConcurrentHashMap<>();
What should a senior engineer know?
- HashMap can be faster for read-heavy single-threaded code. ConcurrentHashMap has a small overhead from volatile reads and CAS operations. For pure single-threaded benchmarks, HashMap is 5-15% faster.
- ConcurrentHashMap.computeIfAbsent is blocking. When one thread is inside the compute function for a key, another thread calling computeIfAbsent for the SAME key will block until the first finishes. This prevents the compute function from being called twice, but long computations can create bottlenecks.
- HashMap size is exact; CHM size is approximate. HashMap.size() returns the exact count. ConcurrentHashMap.size() may be stale under concurrent modification because it reads a counter that is updated asynchronously.
- Serialization. Both are serializable. ConcurrentHashMap serializes using a custom format that reconstructs the internal structure during deserialization.
What are common interview follow-up questions?
- "Can you make HashMap thread-safe?" – Yes, with Collections.synchronizedMap or external synchronization, but ConcurrentHashMap is better.
- "What happens if two threads put to HashMap at the same time?" – Lost update, corrupted structure, or infinite loop (Java 7).
- "What is the difference between computeIfAbsent and putIfAbsent?" – computeIfAbsent takes a function (lazy), putIfAbsent takes the value (eager).
- "Is ConcurrentHashMap size() accurate?" – Approximate under concurrency; use mappingCount() for long values.
Visual Overview
HashMap vs ConcurrentHashMap under concurrent writes HashMap (unsafe): Thread-1: read count=0 ─┐ Thread-2: read count=0 ─┤ ← both read 0 Thread-1: write count=1 │ Thread-2: write count=1 ┘ ← Thread-1's update LOST Result: count=1 (should be 2) ConcurrentHashMap (safe): Thread-1: merge(key, 1, Integer::sum) → atomic read+modify+write Thread-2: merge(key, 1, Integer::sum) → waits if same bucket, then atomic Result: count=2 (correct)
Key Takeaway
HashMap and ConcurrentHashMap serve different worlds. HashMap is the fastest map when only one thread accesses it. ConcurrentHashMap handles concurrent access correctly with per-bucket locking and lock-free reads.
The most dangerous misconception is that HashMap "works" in multi-threaded code because it does not throw exceptions. It silently corrupts data: lost updates, wrong counts, entries in wrong buckets. In Java 7, concurrent resize could even cause infinite loops that freeze the JVM.
ConcurrentHashMap solves this with atomic compound operations. The merge method atomically reads the current value, applies a function, and stores the result. The computeIfAbsent method atomically checks for absence and inserts. These operations eliminate the race conditions that plague check-then-act patterns with HashMap.
In the National Bank of India's transaction counter, Rajesh used HashMap and found that 10 threads each incrementing 1000 times produced only 6847 instead of 10000. Switching to ConcurrentHashMap with merge gave exactly 10000 every time.
Remember one sentence: HashMap is fast but breaks silently under concurrent access; ConcurrentHashMap is the only correct choice for multi-threaded maps.
---