How ConcurrentHashMap Replaces a Single Collection-Wide Lock with Striping

The problem

When multiple threads need to share a mutable collection, the naive approach is Collections.synchronizedMap(new HashMap<>()). That wrapper wraps every get, put, and remove call in a single synchronized(this) block — there is exactly one monitor guarding the entire map. Under contention with eight or more writer threads, that means only one thread ever makes progress while the rest spin on the same lock.

ConcurrentHashMap was introduced in Java 5 to solve this exact bottleneck. Rather than protecting the whole data structure with one monitor, it partitions the map into segments (in Java 7) or locks individual buckets at a time (in Java 8 and later), letting multiple threads read and write simultaneously as long as they touch different parts of the table.

The code

The benchmark below launches eight writer threads, each performing 250,000 put operations against a small 100-entry map. The small map size maximizes contention because every thread is fighting over the same keys and buckets.

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;

public class ConcurrentHashMapBenchmark {

    static final int NUM_THREADS = 8;
    static final int OPS_PER_THREAD = 250_000;
    static final int MAP_SIZE = 100;

    public static void main(String[] args) throws Exception {
        System.out.println("=== ConcurrentHashMap Striping Benchmark ===");

        // Synchronized HashMap — single lock for the entire map
        Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
        long t0 = System.nanoTime();
        runWriteBenchmark(syncMap);
        long elapsedSync = System.nanoTime() - t0;
        double opsPerSecSync = (NUM_THREADS * OPS_PER_THREAD * 1_000_000_000.0) / elapsedSync;

        // ConcurrentHashMap — fine-grained per-bucket locking
        Map<String, Integer> chm = new ConcurrentHashMap<>();
        t0 = System.nanoTime();
        runWriteBenchmark(chm);
        long elapsedChm = System.nanoTime() - t0;
        double opsPerSecChm = (NUM_THREADS * OPS_PER_THREAD * 1_000_000_000.0) / elapsedChm;

        System.out.printf("Synchronized map:     %d entries in %.2f ms (%.2f M ops/sec)%n", syncMap.size(), elapsedSync/1e6, opsPerSecSync/1e6);
        System.out.printf("ConcurrentHashMap:    %d entries in %.2f ms (%.2f M ops/sec)%n", chm.size(), elapsedChm/1e6, opsPerSecChm/1e6);
        System.out.printf("Speedup:              %.2fx%n", opsPerSecChm / opsPerSecSync);
    }

    static void runWriteBenchmark(Map<String, Integer> map) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(NUM_THREADS);
        for (int t = 0; t < NUM_THREADS; t++) {
            final int tid = t;
            new Thread(() -> {
                for (int i = 0; i < OPS_PER_THREAD; i++) {
                    map.put("key-" + (i % MAP_SIZE), tid * OPS_PER_THREAD + i);
                }
                latch.countDown();
            }).start();
        }
        latch.await();
    }
}

Key detail: the key pattern key-(i % 100) means all threads touch the same 100 buckets. Under a single lock this is pure serialization. Under ConcurrentHashMap it is the exact scenario the design targets — threads that do collide still block briefly on their bucket, while threads hitting different buckets run fully in parallel.

Running it

With Java 21 Temurin (OpenJDK) and eight writer threads:

=== ConcurrentHashMap Striping Benchmark ===
Synchronized map:     100 entries in 251.24 ms (7.96 M ops/sec)
ConcurrentHashMap:    100 entries in 99.25 ms (20.15 M ops/sec)
Speedup:              2.53x

Both maps ended up with exactly 100 entries — the put operations correctly overwrote earlier writes, and no data was lost or duplicated under either strategy.

The synchronized map took 2.5× longer because all eight threads competed for one monitor. ConcurrentHashMap’s bucket-level locks let threads that target different buckets proceed in parallel. Threads that do collide still contend — but they contend on a per-bucket basis, not for the whole map.

Takeaway

ConcurrentHashMap replaces one collection-wide monitor with fine-grained bucket locks so that threads operating on disjoint keys avoid blocking each other. It won’t eliminate contention entirely (colliding threads still serialize at the bin level), but under realistic multi-writer workloads it turns a single-lock bottleneck into parallel execution for non-colliding threads — which is often enough to turn hours of queue back-pressure into milliseconds.