Contention & Performance

One Big Lock Around a Shared Map

One Big Lock Around a Shared Map: practice a Java concurrency bug with symptoms like Poor scalability, Throughput plateaus early, Threads block on one...

  • Coarse-grained locking
  • Contention
  • Collections
  • Java
  • Intermediate

Production symptoms

  • Poor scalability
  • Throughput plateaus early
  • Threads block on one hotspot

Failure scenario

Code

Java example
class CacheStats {
    private final Object lock = new Object();
    private final Map<String, Integer> counts = new HashMap<>();

    void record(String key) {
        synchronized (lock) {
            counts.merge(key, 1, Integer::sum);
        }
    }
}

Prod Symptoms

A shared cache, stats map, or per-tenant registry is protected by one global lock. Independent keys still wait behind the same hotspot.

Key signal: A single global lock turns many independent map operations into one lane.

  • The code is correct but does not scale with more threads
  • Throughput plateaus early as concurrency rises
  • Thread dumps show many callers blocked on the same map lock
  • Requests for different keys or tenants wait behind each other
  • The bottleneck appears as contention, not wrong data

Run Locally

  • All keys share the same lock
  • Adding workers does not let independent keys update independently
  • During the run, several threads may be BLOCKED on the global lock
  • The result is correct, but scalability is poor

What to look for

  • Many threads waiting to enter the same synchronized block
  • Throughput flattening as thread count increases
  • A single lock protecting operations that could be independent by key
Run
javac OneBigLockMapDemo.java
java OneBigLockMapDemo
Inspect while running
jps
jcmd <pid> Thread.print
jstack <pid>
OneBigLockMapDemo.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class OneBigLockMapDemo {
    private static final int THREADS = 8;
    private static final int OPERATIONS_PER_THREAD = 10_000;

    private static final Object lock = new Object();
    private static final Map<Integer, Integer> counts = new HashMap<>();
    private static long blackhole;

    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(THREADS);
        List<Future<?>> futures = new ArrayList<>();
        long start = System.nanoTime();

        for (int t = 0; t < THREADS; t++) {
            final int worker = t;
            futures.add(pool.submit(() -> {
                for (int i = 0; i < OPERATIONS_PER_THREAD; i++) {
                    int key = (worker * OPERATIONS_PER_THREAD + i) % 1_024;
                    synchronized (lock) {
                        counts.merge(key, 1, Integer::sum);
                        blackhole ^= busyWork(key);
                    }
                }
            }));
        }

        Thread.sleep(300);
        System.out.println("Run jcmd now to catch blocked map updates.");

        for (Future<?> future : futures) {
            future.get();
        }

        long elapsedMillis = (System.nanoTime() - start) / 1_000_000;
        System.out.println("keys = " + counts.size());
        System.out.println("elapsed ms = " + elapsedMillis);
        System.out.println("blackhole = " + blackhole);
        pool.shutdown();
    }

    private static long busyWork(int seed) {
        long value = seed;
        for (int i = 0; i < 200; i++) {
            value = value * 31 + i;
        }
        return value;
    }
}

Note: The busy work makes the global lock visible. Real systems often hide similar cost inside cache/update logic.

Diagnosis and fix

Explanation

The global lock protects the map, but it also forces unrelated keys to wait behind each other.

Key signal: Correct locking can still be too coarse for the workload.

  • Workers touching different keys still wait on one monitor
  • The lock becomes a scalability boundary
  • The map may be correct while the application is slow
  • A synchronized wrapper has the same coarse-grained shape for compound operations
  • Concurrent collections reduce unnecessary waiting for common independent-key access patterns

How to Diagnose

Use contention evidence and throughput comparisons rather than looking for data corruption.

  • Capture dumps during high latency and look for many threads BLOCKED on the same monitor
  • Measure throughput as thread count increases
  • Inspect whether different keys or tenants still share one lock
  • Search for synchronizedMap, synchronized blocks around HashMap, or one lock guarding a broad cache
  • Check whether compound operations need atomicity per key or across the whole map
  • Profile the critical section; expensive work inside the map lock can dominate the update itself
Commands
jps
jstack <pid>
jcmd <pid> Thread.print
Expected dump shape
"pool-1-thread-4" #... BLOCKED (on object monitor)
  at OneBigLockMapDemo.lambda$main$0(OneBigLockMapDemo.java:...)
  - waiting to lock <...> (a java.lang.Object)

How to Fix

  • Use ConcurrentHashMap for independent key-level updates
  • Use atomic map methods such as merge or compute when they match the operation
  • Avoid placing unrelated work under one global map lock
  • Keep compute and merge functions small because they still coordinate updates for affected keys
  • Use finer-grained locks only when ConcurrentHashMap does not fit the invariant
  • Keep a global lock only for invariants that truly span the whole map
ConcurrentHashMapFixed.java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ConcurrentHashMapFixed {
    private static final int THREADS = 8;
    private static final int OPERATIONS_PER_THREAD = 10_000;

    private static final ConcurrentHashMap<Integer, Integer> counts =
            new ConcurrentHashMap<>();

    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(THREADS);
        List<Future<Long>> futures = new ArrayList<>();
        long start = System.nanoTime();

        for (int t = 0; t < THREADS; t++) {
            final int worker = t;
            futures.add(pool.submit(() -> {
                long checksum = 0;
                for (int i = 0; i < OPERATIONS_PER_THREAD; i++) {
                    int key = (worker * OPERATIONS_PER_THREAD + i) % 1_024;
                    counts.merge(key, 1, Integer::sum);
                    checksum ^= busyWork(key);
                }
                return checksum;
            }));
        }

        long checksum = 0;
        for (Future<Long> future : futures) {
            checksum ^= future.get();
        }

        long elapsedMillis = (System.nanoTime() - start) / 1_000_000;
        System.out.println("keys = " + counts.size());
        System.out.println("elapsed ms = " + elapsedMillis);
        System.out.println("checksum = " + checksum);
        pool.shutdown();
    }

    private static long busyWork(int seed) {
        long value = seed;
        for (int i = 0; i < 200; i++) {
            value = value * 31 + i;
        }
        return value;
    }
}

Note: ConcurrentHashMap lets independent keys proceed without one application-level monitor around the whole map.