Why an unsynchronised HashMap is not merely unsafe

Collections · lesson 42 of 42 · 6 min read

Lost updates are the mild outcome; corruption and stale reads are the real risk.

Open this lesson in the learning hub

Key points

  • Concurrent writes to a HashMap can 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.synchronizedMap makes 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.
  • ConcurrentHashMap gives per-bin locking and atomic compound methods - putIfAbsent, computeIfAbsent, merge - which is the actual reason to prefer it.
  • The function passed to computeIfAbsent runs 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.