Back to Scenarios & Design Questions
Q63Question 63 of 67

Why is ConcurrentHashMap.computeIfAbsent a hot-path trap under high TPS?

Key Concepts
  • ConcurrentHashMap locks per bin, not globally, which is fast for tiny operations but breaks when the mapping function is slow.
  • computeIfAbsent holds the bin lock for the entire duration of the mapping function to guarantee at-most-once computation.
  • A slow lambda (DB call, HTTP request) serializes every thread hashing to that bin into a thundering herd of parked threads.
  • CPU is consumed by park/unpark context switching, not useful computation, so throughput collapses under high TPS.
  • The fix is Caffeine AsyncLoadingCache with refreshAfterWrite: returns the current value instantly while one background thread reloads.
  • The production symptom is high CPU with low useful throughput and threads stacked in a parked state on a ConcurrentHashMap bin.
  • Even for pure in-memory memoization, prefer a proper cache with single-flight semantics when the computation is non-trivial.

Definition

computeIfAbsent holds the bin lock for the whole mapping function, so a slow lambda serializes every thread hashing to that bin into a thundering herd that burns CPU on park/unpark, and the fix is an AsyncLoadingCache that returns the current value instantly while one background thread recomputes.
  • 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.

JAVA
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"));
    }
}
DIAGRAM
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.

JAVA
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();
        }
    }
}
TEXT
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.

JAVA
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");
    }
}
DIAGRAM
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.

JAVA
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)"));
    }
}
TEXT
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.

JAVA
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");
    }
}
TEXT
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.

JAVA
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

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate