Definition
- HashMap (Java 1.2): not thread-safe, allows one null key and multiple null values, fail-fast iterator, best performance for single-threaded code
- Hashtable (Java 1.0): every method synchronized on one lock, does not allow null keys or values, 2-4x slower than ConcurrentHashMap under contention
- ConcurrentHashMap (Java 5+): per-bucket locking (Java 8: CAS + synchronized on first node), lock-free reads, weakly consistent iterator, atomic compound operations
- Rule: use HashMap for single-threaded code, ConcurrentHashMap for multi-threaded code, never Hashtable
HashMap is a bank ledger with no lock – the fastest to use, but only one person should use it at a time. Hashtable is a ledger with one giant lock on the whole book – only one person can read or write at any time, even if ten people want to read different pages. ConcurrentHashMap is a ledger with a lock on each individual page – ten people can read and write different pages simultaneously, and reading never requires a lock at all.
Understanding the Concepts
What is the difference between HashMap, Hashtable, and ConcurrentHashMap?
These three classes all implement the Map interface, but they differ in thread safety, null handling, performance, and design era.
| Feature | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| Introduced | Java 1.2 | Java 1.0 | Java 5 |
| Thread-safe | No | Yes (coarse lock) | Yes (fine-grained) |
| Null key | 1 allowed | Not allowed | Not allowed |
| Null value | Multiple allowed | Not allowed | Not allowed |
| Locking | None | Entire map (one lock) | Per-bucket (Java 8+) |
| Read locking | None | Same global lock | None (volatile reads) |
| Iterator | Fail-fast (throws CME) | Fail-fast (Enumeration) | Weakly consistent |
| Atomic ops | No | No | Yes (computeIfAbsent, merge) |
| Performance | Fastest (no sync) | Slowest (global lock) | Fast (concurrent) |
| Recommendation | Single-threaded | Never use | Multi-threaded |
package com.bank.demo;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class MapComparisonDemo {
public static void main(String[] args) {
// HashMap – fast, not thread-safe
Map<String, BigDecimal> hashMap = new HashMap<>();
hashMap.put("SAV2001", new BigDecimal("150000.00"));
hashMap.put(null, BigDecimal.ZERO); // null key OK
hashMap.put("CUR3001", null); // null value OK
System.out.println("HashMap: " + hashMap);
// ConcurrentHashMap – thread-safe, no null
Map<String, BigDecimal> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("SAV2001", new BigDecimal("150000.00"));
try {
concurrentMap.put(null, BigDecimal.ZERO); // NullPointerException!
} catch (NullPointerException e) {
System.out.println("CHM rejects null key");
}
try {
concurrentMap.put("CUR3001", null); // NullPointerException!
} catch (NullPointerException e) {
System.out.println("CHM rejects null value");
}
}
}
HashMap: {null=0, SAV2001=150000.00, CUR3001=null}
CHM rejects null key
CHM rejects null value
Why is Hashtable slow?
Every public method in Hashtable is declared synchronized. This means that before any operation – even size() or get() – the calling thread must acquire the object's intrinsic lock.
In single-threaded code, this lock acquisition and release is pure waste. In multi-threaded code, all threads compete for a single lock on the entire table. While one thread is reading, no other thread can read or write anything.
// Hashtable internals (simplified)
public class Hashtable<K,V> {
public synchronized V get(Object key) { /* ... */ } // locked
public synchronized V put(K key, V value) { /* ... */ } // locked
public synchronized V remove(Object key) { /* ... */ } // locked
public synchronized int size() { /* ... */ } // even size!
public synchronized boolean contains(Object value) { /* ... */ } // locked
}
// 10 threads reading 10 different keys? Queued behind ONE lock.
Why is ConcurrentHashMap fast?
ConcurrentHashMap (Java 8+) uses a fundamentally different approach:
- Reads are completely lock-free. The
get()method reads the bucket array and node values using volatile semantics. Multiple threads can read simultaneously without any coordination. - Writes to empty buckets use CAS. When inserting into an empty bucket, ConcurrentHashMap uses Compare-And-Swap (a hardware-level atomic operation) – no lock at all.
- Writes to non-empty buckets lock only that bucket. The
synchronizedblock is on the first node of the specific bucket, not on the entire map. Other buckets remain accessible.
This means that with 1000 buckets, 1000 threads can potentially write to 1000 different buckets simultaneously.
package com.bank.service;
import java.math.BigDecimal;
import java.util.concurrent.*;
public class ConcurrentMapDemo {
public static void main(String[] args) throws InterruptedException {
ConcurrentHashMap<String, BigDecimal> balances = new ConcurrentHashMap<>();
// Lock-free read
balances.put("SAV2001", new BigDecimal("150000"));
BigDecimal bal = balances.get("SAV2001"); // no lock, volatile read
// Atomic compound operations
balances.computeIfAbsent("CUR3001", k -> BigDecimal.ZERO);
balances.merge("SAV2001", new BigDecimal("5000"), BigDecimal::add);
System.out.println("After merge: " + balances.get("SAV2001"));
// Safe iteration – weakly consistent, never throws CME
balances.forEach((account, balance) ->
System.out.println(account + ": " + balance));
}
}
After merge: 155000
CUR3001: 0
SAV2001: 155000
Why does ConcurrentHashMap not allow null?
In a concurrent context, get(key) returning null is ambiguous. Does it mean the key does not exist, or does the key exist but maps to null? In a single-threaded HashMap, you can call containsKey() to resolve this ambiguity. In a concurrent map, the state can change between get() and containsKey(), so the ambiguity cannot be resolved safely.
Doug Lea (the author of ConcurrentHashMap) made the design decision to reject null keys and values to avoid this ambiguity.
// The ambiguity problem:
Map<String, String> map = new ConcurrentHashMap<>();
String value = map.get("SAV2001"); // returns null
// Does "SAV2001" not exist? Or does it map to null?
// In a concurrent context, containsKey() might return a stale answer.
// Solution: reject null entirely.
What are the atomic compound operations in ConcurrentHashMap?
ConcurrentHashMap provides several methods that perform read-modify-write as a single atomic operation, eliminating race conditions:
package com.bank.service;
import java.math.BigDecimal;
import java.util.concurrent.ConcurrentHashMap;
public class AtomicOpsDemo {
public static void main(String[] args) {
ConcurrentHashMap<String, BigDecimal> balances = new ConcurrentHashMap<>();
// computeIfAbsent: insert only if key absent (atomic)
balances.computeIfAbsent("SAV2001", k -> new BigDecimal("50000"));
balances.computeIfAbsent("SAV2001", k -> new BigDecimal("99999")); // skipped
System.out.println("After computeIfAbsent: " + balances.get("SAV2001"));
// merge: combine old and new values (atomic)
balances.merge("SAV2001", new BigDecimal("10000"), BigDecimal::add);
System.out.println("After merge: " + balances.get("SAV2001"));
// compute: update value based on key and current value (atomic)
balances.compute("SAV2001", (k, v) ->
v == null ? BigDecimal.ZERO : v.subtract(new BigDecimal("5000")));
System.out.println("After compute: " + balances.get("SAV2001"));
// putIfAbsent: insert only if key absent (returns existing or null)
BigDecimal existing = balances.putIfAbsent("CUR3001", new BigDecimal("75000"));
System.out.println("putIfAbsent returned: " + existing); // null (was absent)
// replace: replace only if key exists
boolean replaced = balances.replace("CUR3001",
new BigDecimal("75000"), new BigDecimal("80000")); // CAS-style
System.out.println("Replaced: " + replaced);
}
}
After computeIfAbsent: 50000
After merge: 60000
After compute: 55000
putIfAbsent returned: null
Replaced: true
What happens if you use HashMap in multi-threaded code?
Using a plain HashMap across threads without synchronization causes silent data corruption. There are no exceptions – the data is just wrong.
- Lost updates: two threads put different values for the same key simultaneously; one update is silently lost.
- Infinite loop (Java 7): concurrent resize could create a cycle in the linked list, causing
get()to loop forever, consuming 100% CPU. - Corrupted structure: entries end up in wrong buckets, size counter becomes incorrect, or entries become unreachable.
Java 8 HashMap uses a different resize algorithm that avoids the infinite loop, but lost updates and corrupted state are still possible.
// WRONG – HashMap across threads without synchronization
Map<String, Integer> counts = new HashMap<>();
// Thread 1: counts.put("SAV2001", counts.getOrDefault("SAV2001", 0) + 1);
// Thread 2: counts.put("SAV2001", counts.getOrDefault("SAV2001", 0) + 1);
// Both read 0, both write 1 → count is 1, should be 2. LOST UPDATE.
// CORRECT – ConcurrentHashMap with atomic merge
ConcurrentHashMap<String, Integer> safeCounts = new ConcurrentHashMap<>();
// Thread 1: safeCounts.merge("SAV2001", 1, Integer::sum);
// Thread 2: safeCounts.merge("SAV2001", 1, Integer::sum);
// Both execute atomically → count is 2. CORRECT.
What are common mistakes?
Mistake 1: Using Hashtable because it is "thread-safe"
// WRONG – Hashtable is legacy, slow, and compound operations are still racy
Map<String, String> map = new Hashtable<>();
if (!map.containsKey("SAV2001")) {
map.put("SAV2001", "Rajesh"); // RACE CONDITION between check and put!
}
// CORRECT – ConcurrentHashMap with atomic operation
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
map.putIfAbsent("SAV2001", "Rajesh"); // atomic check-and-put
Mistake 2: Wrapping ConcurrentHashMap with Collections.synchronizedMap
// WRONG – defeats the purpose of ConcurrentHashMap
Map<String, String> map = Collections.synchronizedMap(new ConcurrentHashMap<>());
// Now EVERY operation goes through a single synchronized block – slow!
// CORRECT – use ConcurrentHashMap directly
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
Mistake 3: Storing null in ConcurrentHashMap
// WRONG – throws NullPointerException
concurrentMap.put("SAV2001", null);
// CORRECT – use a sentinel value or Optional
concurrentMap.put("SAV2001", BigDecimal.ZERO); // sentinel for "no balance"
What about iteration behavior?
- HashMap: fail-fast iterator. If the map is structurally modified during iteration (except through the iterator's own remove), it throws
ConcurrentModificationException. - ConcurrentHashMap: weakly consistent iterator. It never throws CME. It reflects the state of the map at some point during iteration and may or may not reflect modifications made after the iterator was created.
package com.bank.demo;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class IteratorBehaviorDemo {
public static void main(String[] args) {
// HashMap: fail-fast
Map<String, String> hashMap = new HashMap<>(Map.of("A", "1", "B", "2"));
try {
for (String key : hashMap.keySet()) {
hashMap.put("C", "3"); // ConcurrentModificationException!
}
} catch (ConcurrentModificationException e) {
System.out.println("HashMap: CME during iteration");
}
// ConcurrentHashMap: weakly consistent
ConcurrentHashMap<String, String> chm = new ConcurrentHashMap<>(Map.of("A", "1", "B", "2"));
for (String key : chm.keySet()) {
chm.put("C", "3"); // no exception! Weakly consistent.
}
System.out.println("CHM after iteration: " + chm);
}
}
HashMap: CME during iteration
CHM after iteration: {A=1, B=2, C=3}
What should a senior engineer know?
- ConcurrentHashMap.size() vs mappingCount(). size() returns an int, which overflows for maps with more than Integer.MAX_VALUE entries. mappingCount() returns a long. Under concurrency, both are approximate – they reflect the count at some point during execution.
- ConcurrentHashMap and streams. CHM provides reduceValues(), searchKeys(), and forEachEntry() that run in parallel using the ForkJoinPool. These are more efficient than creating a stream from entrySet().
- Hashtable was retrofitted. Hashtable was made to implement Map when the Collections Framework was introduced in Java 1.2. Its original API used Enumeration instead of Iterator.
- IdentityHashMap. Uses reference equality (==) instead of equals() for key comparison. Useful for serialization frameworks and class-loading scenarios.
- WeakHashMap. Keys are held via WeakReference. When a key is no longer strongly referenced elsewhere, the entry is automatically removed by GC. Useful for caches where you want entries to disappear when the key objects are garbage collected.
What are common interview follow-up questions?
- "How does ConcurrentHashMap achieve thread safety without locking reads?" – Volatile reads of node values.
- "What happens if you use HashMap concurrently without synchronization?" – Silent data corruption, lost updates, potentially infinite loop (Java 7).
- "Why doesn't ConcurrentHashMap allow null?" – Ambiguity: get() returning null could mean absent or mapped-to-null.
- "What is the difference between computeIfAbsent and putIfAbsent?" – computeIfAbsent takes a function and only calls it if key is absent; putIfAbsent takes the value directly.
Visual Overview
Locking comparison
Hashtable (single lock):
┌──────────────────────────────────┐
│ Thread-1: get("A") → LOCK whole │
│ Thread-2: put("B") → WAIT │
│ Thread-3: size() → WAIT │
│ All operations serialized! │
└──────────────────────────────────┘
ConcurrentHashMap (per-bucket):
┌──────────────────────────────────┐
│ Bucket 0: Thread-1 writes → LOCK │
│ Bucket 1: Thread-2 writes → LOCK │
│ Bucket 2: Thread-3 reads → FREE │ ← no lock for reads!
│ Bucket 3: Thread-4 reads → FREE │
│ All proceed in parallel! │
└──────────────────────────────────┘
Null handling:
HashMap: null key OK, null value OK
Hashtable: null key NO, null value NO
ConcurrentHashMap: null key NO, null value NO
Key Takeaway
HashMap is the fastest map for single-threaded code. It has no synchronization overhead, allows one null key and multiple null values, and uses a fail-fast iterator. But use it across threads without synchronization and you get silent data corruption – not exceptions, just wrong data.
Hashtable was Java's first thread-safe map, but its design is fundamentally flawed. It synchronizes every method on a single lock, so even 10 threads reading 10 different entries are serialized. It is 2-4x slower than ConcurrentHashMap under contention. Never use it in new code.
ConcurrentHashMap is the modern solution. It reads without any locking (volatile reads), writes to empty buckets without locking (CAS), and writes to non-empty buckets by locking only the specific bucket. It provides atomic compound operations like computeIfAbsent and merge that eliminate race conditions. Its weakly consistent iterator never throws ConcurrentModificationException.
In the National Bank of India's real-time balance cache, Rajesh switched from Hashtable to ConcurrentHashMap and saw throughput jump from 500K to 2M lookups per second. The key was eliminating the single-lock bottleneck.
Remember one sentence: HashMap for single-threaded, ConcurrentHashMap for multi-threaded, never Hashtable.
---