HashMap and ConcurrentHashMap share the same interface and almost the same internal shape. The difference is what happens when more than one thread touches the map at the same time.
- How
HashMapworks - Why
HashMapbreaks under concurrency - How
ConcurrentHashMapworks - Comparison table
- Common
ConcurrentHashMapmistakes - Which one to pick
All details below are for Java 8 and later.
How HashMap Works
A HashMap is an array of buckets (the "table"). Each entry goes into a bucket chosen by its key's hash.
// Simplified from java.util.HashMap
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
int index = (table.length - 1) & hash(key);
- Table size: always a power of two (default
16), so& (n - 1)replaces a slower%. - Collisions: keys in the same bucket form a linked list.
- Treeification: when one bucket holds
8entries and the table has at least64slots, the list becomes a red-black tree. Lookup in that bucket goes fromO(n)toO(log n). It turns back into a list at6. - Resize: when
size > capacity * loadFactor(default load factor0.75), the table doubles and every entry is moved. - Nulls: one
nullkey and any number ofnullvalues are allowed. - Iterators: fail-fast. Changing the map while iterating (other than through the iterator) throws
ConcurrentModificationException, on a best-effort basis.
Why HashMap Breaks Under Concurrency
HashMap has no synchronization. Two threads writing at once can corrupt it.
Map<String, Integer> map = new HashMap<>();
// Two threads run this at the same time
for (int i = 0; i < 10_000; i++) {
map.put("key-" + i, i);
}
System.out.println(map.size()); // Often less than 10000
What goes wrong:
- Lost updates: both threads read the same empty bucket and write to it. One entry disappears.
- Broken resize: one thread moves entries to a new table while another still writes to the old one. Entries vanish or
sizestops matching the real content. - Infinite loop (Java 7 and earlier): resize inserted at the head of each list, which reversed it. Two threads resizing together could create a cycle, and the next
get()would spin at 100% CPU forever. Java 8 inserts at the tail, so the cycle is gone, but lost data is still possible. - Stale reads: without a happens-before edge, a reader thread may never see a write from another thread.
HashMap is fine when only one thread uses it, or when the map is built once and then only read after being safely published (for example, assigned to a final field, or wrapped with Map.copyOf).
How ConcurrentHashMap Works
Java 7: Segments
The old design split the map into segments (default 16), each a small hash table guarded by its own ReentrantLock. Up to 16 writers could work in parallel, one per segment. The concurrencyLevel constructor argument set the segment count.
Java 8+: Lock per bucket
Java 8 removed segments. The map is a single table, like HashMap, with finer locking:
- Reads take no lock. Bucket heads and node values are read with volatile semantics, so
get()always sees the latest completed write. - Writing to an empty bucket: a single CAS (compare-and-swap) installs the new node. No lock.
- Writing to a non-empty bucket:
synchronizedon the first node of that bucket only. Threads writing to other buckets are not blocked. - Resize is shared work. The thread that triggers a resize starts moving buckets to the new table and leaves a
ForwardingNodein each moved bucket. Other threads that arrive during the resize help move buckets instead of waiting. - Size counting uses striped counters (like
LongAdder) to avoid one hot counter. Under concurrent writes,size()is an estimate. UsemappingCount()for alongresult on very large maps. - Iterators are weakly consistent. They never throw
ConcurrentModificationExceptionand may or may not show changes made during iteration. - No nulls.
nullkeys and values throwNullPointerException.
concurrencyLevel still exists in the constructor but is now only a sizing hint.
Why ConcurrentHashMap Doesn't Allow Null
In a HashMap, get(key) returning null is ambiguous: the key is missing, or it maps to null. In a single thread you can check with containsKey. In a concurrent map, another thread can change the map between get and containsKey, so the check is meaningless. Banning null makes null always mean "absent".
HashMap vs ConcurrentHashMap: Comparison Table
HashMap | ConcurrentHashMap | |
|---|---|---|
| Thread-safe | No | Yes |
| Read locking | None | None (volatile reads) |
| Write locking | None | CAS on empty bucket, otherwise lock one bucket |
null key / value | Allowed (one null key) | Not allowed (NullPointerException) |
| Iterator | Fail-fast | Weakly consistent |
size() | Exact | Estimate under concurrent writes |
| Resize | Single thread | Many threads help |
| Atomic compound ops | No | putIfAbsent, compute, computeIfAbsent, merge, replace |
| Parallel bulk ops | No | forEach, reduce, search with a parallelism threshold |
| Single-thread speed | Fastest | Slightly slower (volatile reads, CAS) |
What About Hashtable and synchronizedMap?
Hashtable and Collections.synchronizedMap(new HashMap<>()) are thread-safe too, but every method locks the whole map. Only one thread can read or write at a time. ConcurrentHashMap scales much better under load and is the default choice for a shared map today.
Common ConcurrentHashMap Mistakes
1. Check-then-act
Each call on ConcurrentHashMap is atomic. A sequence of calls is not.
// Wrong: another thread can put between containsKey and put
if (!map.containsKey(key)) {
map.put(key, createValue());
}
// Right: one atomic call
map.computeIfAbsent(key, k -> createValue());
2. Counting with get + put
// Wrong: two threads read 5, both write 6
map.put(word, map.getOrDefault(word, 0) + 1);
// Right
map.merge(word, 1, Integer::sum);
// Also right, and faster under heavy contention
ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
counts.computeIfAbsent(word, k -> new LongAdder()).increment();
3. Mutable values
The map protects its own structure, not the objects you store in it.
ConcurrentHashMap<String, List<String>> map = new ConcurrentHashMap<>();
// Wrong: ArrayList is not thread-safe
map.computeIfAbsent(user, k -> new ArrayList<>()).add(event);
// Right: use a thread-safe value
map.computeIfAbsent(user, k -> new CopyOnWriteArrayList<>()).add(event);
Or do the whole update inside compute, which holds the bucket lock while it runs.
4. Heavy work inside compute
compute, computeIfAbsent and merge hold the bucket lock while the function runs. Keep the function short. Never modify the same map from inside it: it can block other writers or fail with IllegalStateException: Recursive update.
// Wrong: recursive update on the same map
map.computeIfAbsent(a, k -> map.computeIfAbsent(b, k2 -> 1));
5. Trusting size() for decisions
size() and isEmpty() can be outdated the moment they return. Use them for monitoring, not for control flow like "if the map has fewer than 100 entries, add one".
Which One to Pick
- One thread only, or built once then read-only:
HashMap. - Shared between threads with writes:
ConcurrentHashMap. - Need sorted keys and concurrency:
ConcurrentSkipListMap. - Need a cache with eviction: a library like Caffeine, which is built on
ConcurrentHashMap. - Avoid in new code:
HashtableandCollections.synchronizedMap(one lock for everything).
Rule of Thumb
- Use
HashMapby default. - Switch to
ConcurrentHashMapthe moment a second thread can write. - With
ConcurrentHashMap, express each update as one method call (merge,compute,computeIfAbsent).
See also: ConcurrentHashMap Javadoc