🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
java.lang.IllegalStateException: Recursive update
at java.base/java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1779)
at com.devinhyderabad.graph.NodeResolver.resolve(NodeResolver.java:38)
at java.base/java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1724)
at com.devinhyderabad.graph.NodeResolver.lookup(NodeResolver.java:29)⚡ Quick Fix Works 80% of the time
Never touch the same key inside its own mapping function — compute the inner value first, then insert it.
// WRONG: resolve() re-enters computeIfAbsent("node:42")
// RIGHT: flatten one level
Node n = rawLookup(id); // plain fetch, no CHM write
return nodes.computeIfAbsent(id, k -> expand(n)); // pure function of k🧠 Why this Happens
Tap to expand the deep technical explanation
computeIfAbsent reserves the destination bin with a placeholder ReservationNode, then runs your mapping function while holding the bin lock. A mapping function that calls computeIfAbsent again for the same key finds its own reservation occupying the slot — the map recognizes this self-reference (which could never terminate) and fast-fails with IllegalStateException instead of spinning or overflowing the stack. Nested updates to DIFFERENT keys are legal because they target other bins, though deep cross-key nesting has its own liveness history on old JDKs.
The HITEC City Parking Spot Analogy:
Writing an encyclopedia entry that requires reading the same unfinished entry: the editor slaps a sticky note on the blank page saying “cannot cite itself” and returns it to you rather than looping at the desk forever.
🔁 How to Reproduce Confirm this is your error
ConcurrentHashMap<String,String> m = new ConcurrentHashMap<>(); m.computeIfAbsent("k", k -> m.computeIfAbsent(k, k2 -> "v")); throws immediately with the captured trace. (Lab capture: OpenJDK Temurin 25.0.2.)
🛠️ Solutions (5 Ways to Fix)
Flatten the recursion: fetch dependencies before inserting
👉 Use this when/if the mapping function merely needs a derived value for the key.
Perform the raw lookup or computation outside the CHM call, then let the lambda be a pure function of the key. The bin lock now covers only insertion of a fully-formed value, so no self-reference exists and the reservation is satisfied on the first pass.
V load(K key) {
Raw raw = repository.fetch(key); // no map mutation here
return map.computeIfAbsent(key, k -> transform(raw));
}Two-phase build: gather everything, then insert once
👉 Use this when/if building a graph whose nodes reference siblings.
Resolve the entire dependency set into a temporary structure first; a second pass performs pure putIfAbsent/computeIfAbsent insertions leaf-first. Every mapping function stays trivially non-recursive because all values already exist by insertion time.
Map<K,V> staged = resolveAll(keys); // phase 1, plain code
keys.forEach(k -> map.putIfAbsent(k, staged.get(k))); // phase 2, flat insertsMemoize with Map<K, CompletableFuture<V>>
👉 Use this when/if loads are expensive and concurrent duplicate work must collapse.
computeIfAbsent returns a future synchronously — cheap and non-recursive — while the actual loading completes asynchronously elsewhere. Recursive graphs become lazy chains of futures; concurrent callers share one in-flight computation without anyone holding a bin lock during IO.
ConcurrentHashMap<K, CompletableFuture<V>> cache = new ConcurrentHashMap<>();
CompletableFuture<V> get(K key) {
return cache.computeIfAbsent(key, k ->
CompletableFuture.supplyAsync(() -> load(k)));
}Topologically sort recursive structures before caching
👉 Use this when/if the data is genuinely cyclic-safe DAG content like module graphs.
Sorting outside the map converts hidden recursion into explicit iteration order: leaves resolve first, parents find children already present. The cache then records finished nodes in one pass, and cycles in input data become detectable validation errors rather than runtime traps.
List<K> order = topoSort(dependencyGraph); // throws on true cycles
order.forEach(k -> map.putIfAbsent(k, buildFromDeps(k, map)));DEV ONLY: catch IllegalStateException and return a partial value
👉 Use this only to keep a demo alive while you fix the graph logic — it poisons the cache with incomplete entries.
Swallowing the fast-fail leaves whatever placeholder state existed in the bin and trains the map around broken invariants; subsequent readers may observe half-computed associations. The exception is doing its job — the recursion is the defect.
// ANTI-PATTERN — do not ship
try {
return map.computeIfAbsent(key, this::recursiveLoad);
} catch (IllegalStateException e) {
return Partial.placeholder(key); // cache now lies
}📋 Version Notes
The same self-recursion could hang the bin or produce confusing secondary errors — the explicit Recursive update check arrived with later hardening.
Fast-fail detection added: same-key recursion now throws this IllegalStateException deterministically.
Detection unchanged and battle-tested; line numbers shift with treeification tweaks.
No behavioral change — the check is orthogonal to virtual threads.
🛡️ How to Prevent This Next Time
Treat mapping functions as pure functions of their key by convention and lint, prefer the future-based memoization pattern for anything involving IO or graphs, and add a unit test that loads self-referential keys so recursion fails in CI with a clear message instead of in production.