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
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
javac OneBigLockMapDemo.java
java OneBigLockMapDemo
jps
jcmd <pid> Thread.print
jstack <pid>
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
jps
jstack <pid>
jcmd <pid> Thread.print
"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
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.