Definition
- ConcurrentHashMap Bin Lock – the map locks per bin (bucket) rather than globally, so operations on keys in different bins run in parallel, but the design assumes work done while holding a bin lock is tiny.
- computeIfAbsent Contract – the mapping function runs while holding the bin lock to guarantee the value is computed at most once, even under concurrency.
- Hot-Path Trap – when the mapping function is slow (DB call, HTTP request), every other thread hashing to the same bin blocks on that lock, serializing them into a thundering herd.
- Park/Unpark Churn – blocked threads are parked by the OS and later unparked; this context switching consumes CPU without doing useful work.
- AsyncLoadingCache Fix – Caffeine's AsyncLoadingCache with refreshAfterWrite returns the current value instantly and schedules at most one background reload, so no reader ever blocks.
At National Bank of India imagine a shared filing cabinet with many drawers, and a rule: "if the figure you need is not in its drawer, compute it and file it, and keep the drawer locked while you compute so nobody files a duplicate." Fine when computing takes a second. But if computing means phoning a slow external office for two minutes, every clerk needing anything from that drawer stands frozen. The fix: anyone grabs the current figure from the drawer immediately, and one designated clerk quietly refreshes it in the back.
Understanding the Concepts
How does ConcurrentHashMap achieve concurrency?
ConcurrentHashMap divides its table into bins (buckets) and locks only the bin a key hashes to, so operations on keys in different bins proceed in parallel. This fine-grained locking scales well for fast reads and writes. The key assumption is that the work done while holding a bin lock is tiny.
package com.bank.cache;
import java.util.concurrent.ConcurrentHashMap;
public class BinLockingDemo {
public static void main(String[] args) {
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
// Two keys in different bins: operations run in parallel
map.put("SAV2001", "Rajesh"); // locks bin for SAV2001
map.put("CUR3005", "Priya"); // locks different bin, no contention
System.out.println("Parallel updates to different bins: no contention");
System.out.println("SAV2001 → " + map.get("SAV2001"));
System.out.println("CUR3005 → " + map.get("CUR3005"));
}
}
Parallel updates to different bins: no contention SAV2001 → Rajesh CUR3005 → Priya
Why is computeIfAbsent a trap with slow lambdas?
computeIfAbsent holds the bin lock for the entire execution of the mapping function. If the lambda does something slow, every thread hashing to the same bin blocks on that lock.
package com.bank.cache;
import java.math.BigDecimal;
import java.util.concurrent.ConcurrentHashMap;
public class ComputeIfAbsentTrap {
static final ConcurrentHashMap<String, BigDecimal> rateCache = new ConcurrentHashMap<>();
// TRAP: this lambda holds the bin lock for 2 seconds!
static BigDecimal getExchangeRate(String pair) {
return rateCache.computeIfAbsent(pair, key -> {
System.out.println(Thread.currentThread().getName() + " computing " + key
+ " (holding bin lock!)");
try { Thread.sleep(2000); } catch (InterruptedException e) {} // slow external call
return new BigDecimal("83.50"); // USD/INR rate
});
}
public static void main(String[] args) {
// 5 threads all want the same key: they serialize on the bin lock
for (int i = 0; i < 5; i++) {
int threadNum = i;
new Thread(() -> {
long start = System.currentTimeMillis();
BigDecimal rate = getExchangeRate("USD_INR");
long elapsed = System.currentTimeMillis() - start;
System.out.println("Thread-" + threadNum + " got rate " + rate
+ " in " + elapsed + "ms");
}).start();
}
}
}
Thread-0 computing USD_INR (holding bin lock!)
Thread-0 got rate 83.50 in 2005ms
Thread-1 got rate 83.50 in 2006ms
Thread-2 got rate 83.50 in 2007ms
Thread-3 got rate 83.50 in 2008ms
Thread-4 got rate 83.50 in 2009ms
What happens under high TPS?
Under high transactions per second, dozens of threads pile up waiting on one lock. The CPU is consumed by parking and unparking threads, not by useful work. Throughput collapses precisely when load is highest.
package com.bank.cache;
public class ThroughputCollapse {
public static void main(String[] args) {
System.out.println("Low TPS: 10 threads, 1 hits the lock, 9 run freely → fine");
System.out.println("High TPS: 200 threads, 50+ hash to the same bin");
System.out.println(" → 50 threads parked waiting on one bin lock");
System.out.println(" → CPU burned on park/unpark context switching");
System.out.println(" → Throughput collapses, not from useful work, from lock contention");
System.out.println();
System.out.println("Production symptom: high CPU, low useful throughput,");
System.out.println(" thread dumps show threads PARKED on ConcurrentHashMap bin");
}
}
Low TPS: 10 threads, 1 hits the lock, 9 run freely → fine High TPS: 200 threads, 50+ hash to the same bin → 50 threads parked waiting on one bin lock → CPU burned on park/unpark context switching → Throughput collapses, not from useful work, from lock contention Production symptom: high CPU, low useful throughput, thread dumps show threads PARKED on ConcurrentHashMap bin
How do you fix it with AsyncLoadingCache?
The fix decouples the read from the compute. Caffeine's AsyncLoadingCache with refreshAfterWrite returns the current value instantly and schedules at most one background reload per key.
package com.bank.cache;
import com.github.benmanes.caffeine.cache.AsyncLoadingCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
public class AsyncCacheFix {
private final AsyncLoadingCache<String, BigDecimal> rateCache = Caffeine.newBuilder()
.maximumSize(1000)
.refreshAfterWrite(Duration.ofSeconds(30))
.expireAfterWrite(Duration.ofMinutes(5))
.buildAsync(this::fetchRate);
private CompletableFuture<BigDecimal> fetchRate(String pair) {
return CompletableFuture.supplyAsync(() -> {
System.out.println("Background thread refreshing " + pair);
return new BigDecimal("83.50");
});
}
public CompletableFuture<BigDecimal> getRate(String pair) {
return rateCache.get(pair); // returns instantly, never holds any lock
}
public static void main(String[] args) {
AsyncCacheFix svc = new AsyncCacheFix();
// All threads get the value instantly; at most ONE background thread refreshes
svc.getRate("USD_INR").thenAccept(rate ->
System.out.println("Rate: " + rate + " (returned instantly, no lock)"));
}
}
Background thread refreshing USD_INR
Rate: 83.50 (returned instantly, no lock)
How do the two approaches compare?
| Aspect | computeIfAbsent | AsyncLoadingCache |
|---|---|---|
| Lock held during compute | Yes (bin lock) | No |
| Readers during compute | Block (parked) | Get current value instantly |
| Concurrent reloads per key | One (via lock) | One (via single-flight) |
| CPU under high TPS | Wasted on park/unpark | Useful work |
| Throughput under load | Collapses | Stable |
What is the subtler deadlock hazard?
If the slow lambda inside computeIfAbsent tries to access the same ConcurrentHashMap, it can deadlock or throw in some JDK versions, because it runs while the bin lock is held.
package com.bank.cache;
import java.util.concurrent.ConcurrentHashMap;
public class ReentrantTrap {
static ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
public static void main(String[] args) {
// DANGER: lambda accesses the same map while bin lock is held
// Can cause infinite loop or deadlock in some JDK versions
try {
map.computeIfAbsent("A", key -> {
map.computeIfAbsent("B", k2 -> "value-B"); // touches same map!
return "value-A";
});
} catch (Exception e) {
System.out.println("Caught: " + e.getClass().getSimpleName());
}
System.out.println("JavaDoc warns: mapping function must not update other entries");
}
}
JavaDoc warns: mapping function must not update other entries
What are common mistakes?
Using computeIfAbsent for any I/O or non-trivial computation, even memoization, when a proper cache with single-flight semantics (Caffeine) would scale far better.
package com.bank.cache;
// WRONG: computeIfAbsent with a slow DB call
// map.computeIfAbsent(accountId, id -> fetchFromDB(id));
// Holds bin lock during DB call, serializes all threads on that bin
// CORRECT: Caffeine AsyncLoadingCache
// cache.get(accountId) → returns instantly, one background thread loads
// No lock held, no thread blocked, throughput stable
What are common interview follow-up questions?
- How would you identify this problem in a production thread dump?
- What is the difference between bin-level locking and segment-level locking (Java 7)?
- Can you use computeIfAbsent safely if the lambda is guaranteed to be fast?
- How does Caffeine's single-flight differ from a ReentrantLock around the compute?
Visual Overview
computeIfAbsent Under High TPS
──────────────────────────────
Thread-1 ──► computeIfAbsent("USD_INR", slowLambda)
│ acquires bin lock
│ runs slowLambda (2 seconds...)
│
Thread-2 ──► computeIfAbsent("USD_INR", ...)
│ BLOCKED on bin lock ← parked
Thread-3 ──► BLOCKED ← parked
Thread-4 ──► BLOCKED ← parked
... ...
Thread-50 ─► BLOCKED ← parked
CPU: HIGH (park/unpark churn)
Throughput: COLLAPSED
AsyncLoadingCache Fix
─────────────────────
Thread-1 ──► cache.get("USD_INR") → returns stale value INSTANTLY
Thread-2 ──► cache.get("USD_INR") → returns stale value INSTANTLY
Thread-50 ─► cache.get("USD_INR") → returns stale value INSTANTLY
Background ──► ONE thread refreshes "USD_INR" off the hot path
CPU: LOW (useful work only)
Throughput: STABLE
Key Takeaway
ConcurrentHashMap.computeIfAbsent becomes a hot-path trap when the mapping function is slow because the bin lock is held for the entire computation. Under high TPS, dozens of threads pile up waiting on one lock, and the CPU is consumed by parking and unparking threads rather than doing useful work. Throughput collapses precisely when load is highest.
The fix is to decouple the read from the compute using Caffeine's AsyncLoadingCache with refreshAfterWrite. Every reader gets the current value instantly with no blocking, and exactly one background thread performs the expensive reload. No reader ever holds or waits on a lock around the slow computation.
The production signature of this trap is high CPU with low useful throughput and thread dumps showing threads parked on a ConcurrentHashMap bin. Even for pure in-memory memoization, prefer a proper cache with single-flight semantics over computeIfAbsent whenever the computation is non-trivial.
Remember one sentence: computeIfAbsent holds the bin lock during the entire lambda, so a slow lambda serializes all threads on that bin; fix it with AsyncLoadingCache that returns the stale value instantly while one background thread recomputes.
---