Skip to content
hector@hcmc:~/blog/hashmap-vs-concurrenthashmap

HashMap vs ConcurrentHashMap in Java

$ cat ~/blog/hashmap-vs-concurrenthashmap.mdx

The difference between HashMap and ConcurrentHashMap in Java: how each works inside, thread safety, nulls, and when to use which.

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 HashMap works
  • Why HashMap breaks under concurrency
  • How ConcurrentHashMap works
  • Comparison table
  • Common ConcurrentHashMap mistakes
  • 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.

java
// 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 8 entries and the table has at least 64 slots, the list becomes a red-black tree. Lookup in that bucket goes from O(n) to O(log n). It turns back into a list at 6.
  • Resize: when size > capacity * loadFactor (default load factor 0.75), the table doubles and every entry is moved.
  • Nulls: one null key and any number of null values 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.

java
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 size stops 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: synchronized on 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 ForwardingNode in 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. Use mappingCount() for a long result on very large maps.
  • Iterators are weakly consistent. They never throw ConcurrentModificationException and may or may not show changes made during iteration.
  • No nulls. null keys and values throw NullPointerException.

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

HashMapConcurrentHashMap
Thread-safeNoYes
Read lockingNoneNone (volatile reads)
Write lockingNoneCAS on empty bucket, otherwise lock one bucket
null key / valueAllowed (one null key)Not allowed (NullPointerException)
IteratorFail-fastWeakly consistent
size()ExactEstimate under concurrent writes
ResizeSingle threadMany threads help
Atomic compound opsNoputIfAbsent, compute, computeIfAbsent, merge, replace
Parallel bulk opsNoforEach, reduce, search with a parallelism threshold
Single-thread speedFastestSlightly 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.

java
// 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

java
// 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.

java
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.

java
// 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: Hashtable and Collections.synchronizedMap (one lock for everything).

Rule of Thumb

  • Use HashMap by default.
  • Switch to ConcurrentHashMap the moment a second thread can write.
  • With ConcurrentHashMap, express each update as one method call (merge, compute, computeIfAbsent).

See also: ConcurrentHashMap Javadoc