Why an unsynchronised HashMap is not merely unsafe
Lost updates are the mild outcome; corruption and stale reads are the real risk.
Open this lesson in the learning hubKey points
- Concurrent writes to a
HashMapcan lose updates, which is the outcome people expect. The worse outcomes are corrupted structure and permanently stale reads. - Java 7 had an infinite-loop failure where a concurrent resize produced a circular linked list, spinning a CPU forever. Java 8 changed the resize so that specific loop is gone.
- The remaining problems are real: entries lost during resize, and a reader seeing a partially published table because there is no happens-before edge between the writer and the reader.
Collections.synchronizedMapmakes each method atomic but not a sequence of them. A check-then-put across two calls is still a race, so compound operations need external synchronisation.ConcurrentHashMapgives per-bin locking and atomic compound methods -putIfAbsent,computeIfAbsent,merge- which is the actual reason to prefer it.- The function passed to
computeIfAbsentruns while the bin is locked, so it must be short and must not touch the same map - doing so can deadlock or throw.
Example
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class HashMapConcurrency {
public static void main(String[] args) throws Exception {
int threads = 8, perThread = 5_000;
int expected = threads * perThread;
System.out.println("expected entries: " + expected);
System.out.println(" plain HashMap : "
+ fill(new HashMap<>(), threads, perThread) + " <- lost updates");
System.out.println(" synchronizedMap : "
+ fill(Collections.synchronizedMap(new HashMap<>()), threads, perThread));
System.out.println(" ConcurrentHashMap : "
+ fill(new ConcurrentHashMap<>(), threads, perThread));
// synchronizedMap makes each CALL atomic, not a SEQUENCE of calls.
Map<String, Integer> sync = Collections.synchronizedMap(new HashMap<>());
ExecutorService pool = Executors.newFixedThreadPool(8);
AtomicInteger bothSawAbsent = new AtomicInteger();
CountDownLatch go = new CountDownLatch(1);
for (int i = 0; i < 8; i++) {
pool.submit(() -> {
try { go.await(); } catch (InterruptedException e) { return; }
// check-then-act across TWO calls: still a race
if (!sync.containsKey("k")) {
bothSawAbsent.incrementAndGet();
sync.put("k", 1);
}
});
}
go.countDown();
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println();
System.out.println("threads that saw the key absent: " + bothSawAbsent.get()
+ " (1 would mean no race)");
// The atomic version - one call, no external lock needed.
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
System.out.println("putIfAbsent first : " + chm.putIfAbsent("k", 1));
System.out.println("putIfAbsent second : " + chm.putIfAbsent("k", 2)
+ " <- existing value returned, not overwritten");
}
static int fill(Map<Integer, Integer> map, int threads, int perThread) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch go = new CountDownLatch(1);
for (int t = 0; t < threads; t++) {
int base = t * perThread;
pool.submit(() -> {
try { go.await(); } catch (InterruptedException e) { return; }
for (int i = 0; i < perThread; i++) { map.put(base + i, i); }
});
}
go.countDown();
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
return map.size();
}
}
synchronizedMap makes single calls atomic, not sequences - ConcurrentHashMap exists for the compound operations.
This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Collections course, and every lesson in it is listed on the Collections contents page.