HashMap vs ConcurrentHashMap: What Actually Happens Under Concurrent Access

HashMap isn’t thread-safe; ConcurrentHashMap is built for concurrent access. This post runs two small Java programs, each compiled and run for real, that put both maps under the same concurrent access and show what actually happens.

Writing from multiple threads at once

ConcurrentWritesDemo spawns 4 threads that each put 500 distinct keys into the same map (2000 puts total, no two threads touching the same key), first against a plain HashMap, then against a ConcurrentHashMap.

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentWritesDemo {
    static final int THREAD_COUNT = 4;
    static final int PER_THREAD = 500;

    public static void main(String[] args) throws InterruptedException {
        int expected = THREAD_COUNT * PER_THREAD;

        int hashMapSize = runWithMap(new HashMap<>());
        System.out.println("HashMap:           expected " + expected + ", got " + hashMapSize);

        int concurrentSize = runWithMap(new ConcurrentHashMap<>());
        System.out.println("ConcurrentHashMap:  expected " + expected + ", got " + concurrentSize);
    }

    static int runWithMap(Map<Integer, Integer> map) throws InterruptedException {
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int t = 0; t < THREAD_COUNT; t++) {
            final int base = t * PER_THREAD;
            threads[t] = new Thread(() -> {
                for (int i = 0; i < PER_THREAD; i++) {
                    int key = base + i;
                    map.put(key, key);
                }
            });
        }
        for (Thread th : threads) th.start();
        for (Thread th : threads) th.join();
        return map.size();
    }
}

Real javac+java output, JDK 25 (Zulu):

HashMap:           expected 2000, got 1789
ConcurrentHashMap:  expected 2000, got 2000

The HashMap run landed on 1789 entries instead of the expected 2000 — 211 put calls were lost to the race between threads. The ConcurrentHashMap run landed on exactly 2000.

Iterating while another thread writes

IterationDemo seeds a map with 1000 entries, starts a thread that puts 1000 more, and iterates the map’s keySet() on the main thread while that writer thread is running — 3 trials against HashMap, then 3 against ConcurrentHashMap.

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class IterationDemo {
    public static void main(String[] args) throws InterruptedException {
        for (int trial = 0; trial < 3; trial++) {
            System.out.println("--- HashMap trial " + trial + " ---");
            runHashMap();
        }
        for (int trial = 0; trial < 3; trial++) {
            System.out.println("--- ConcurrentHashMap trial " + trial + " ---");
            runConcurrentHashMap();
        }
    }

    static void runHashMap() throws InterruptedException {
        Map<Integer, Integer> map = new HashMap<>();
        for (int i = 0; i < 1000; i++) map.put(i, i);
        Thread writer = new Thread(() -> {
            for (int i = 1000; i < 2000; i++) {
                map.put(i, i);
            }
        });
        try {
            writer.start();
            int count = 0;
            for (Integer key : map.keySet()) {
                count++;
            }
            writer.join();
            System.out.println("iterated " + count + " entries, no exception");
        } catch (java.util.ConcurrentModificationException e) {
            System.out.println("threw " + e.getClass().getSimpleName());
            writer.join();
        }
    }

    static void runConcurrentHashMap() throws InterruptedException {
        Map<Integer, Integer> map = new ConcurrentHashMap<>();
        for (int i = 0; i < 1000; i++) map.put(i, i);
        Thread writer = new Thread(() -> {
            for (int i = 1000; i < 2000; i++) {
                map.put(i, i);
            }
        });
        try {
            writer.start();
            int count = 0;
            for (Integer key : map.keySet()) {
                count++;
            }
            writer.join();
            System.out.println("iterated " + count + " entries, no exception");
        } catch (java.util.ConcurrentModificationException e) {
            System.out.println("threw " + e.getClass().getSimpleName());
            writer.join();
        }
    }
}

Real javac+java output, JDK 25 (Zulu):

--- HashMap trial 0 ---
threw ConcurrentModificationException
--- HashMap trial 1 ---
threw ConcurrentModificationException
--- HashMap trial 2 ---
threw ConcurrentModificationException
--- ConcurrentHashMap trial 0 ---
iterated 2000 entries, no exception
--- ConcurrentHashMap trial 1 ---
iterated 1000 entries, no exception
--- ConcurrentHashMap trial 2 ---
iterated 1000 entries, no exception

All 3 HashMap trials threw ConcurrentModificationException mid-iteration. None of the 3 ConcurrentHashMap trials threw — the iteration finished each time, landing on 2000, 1000, and 1000 entries seen across the three runs.

Takeaway

Same concurrent access pattern, two different outcomes: the HashMap runs lost writes (1789 of 2000) and threw ConcurrentModificationException on every concurrent iteration, while the ConcurrentHashMap runs kept all 2000 writes and never threw while iterating during a concurrent write.