Java's HashMap.computeIfAbsent Recursive Trap: The Memoizer That Poisons Its Own Cache

2026-07-19

This class memoizes Fibonacci numbers using the tidy computeIfAbsent idiom. It passes unit tests for small inputs on the developer's Java 8 laptop. In staging on Java 17, it throws ConcurrentModificationException. On Java 8 in production, it silently returns wrong answers under load.

import java.util.HashMap;
import java.util.Map;

public class Fib {
    private static final Map<Integer, Long> cache = new HashMap<>();

    static long fib(int n) {
        if (n <= 1) return n;
        return cache.computeIfAbsent(n, k -> fib(k - 1) + fib(k - 2));
    }

    public static void main(String[] args) {
        System.out.println(fib(50));  // expect 12586269025
    }
}

The Bug

The lambda passed to computeIfAbsent recursively calls fib, which calls computeIfAbsent on the same map. That is explicitly forbidden. The Javadoc says: "The mapping function must not modify this map during computation." Violating that contract has different failure modes across versions, and all of them are ugly.

Why it breaks. HashMap.computeIfAbsent was designed to be an atomic "get-or-put" for a single key. It locates the bucket, invokes the lambda, and then installs the returned value in the slot it already resolved. If the lambda mutates the map in the meantime — inserting entries, triggering a resize, moving nodes to a new table — the internal state the outer call cached is stale. The installed value ends up in the wrong bucket, overwrites something else, or corrupts the modification counter.

Since Java 9, HashMap.computeIfAbsent bumps modCount around the lambda and throws ConcurrentModificationException when it detects the reentrancy. On Java 8 the check was missing: nested calls "worked" until a resize happened mid-recursion, at which point entries silently vanished or duplicated. A memoizer that occasionally forgets values is worse than one that crashes — it produces subtly wrong results and passes tests that don't exercise the resize boundary.

The trap is nasty because the idiom looks perfect: one atomic call, no double-lookup, functional-flavored. Anyone reviewing it thinks "clean code." The recursive dependency between the lambda and the map is invisible unless you know the contract.

The Fix

Split the compute step from the store step. A plain get-then-put is safe under recursion because each put completes fully before the next recursive call begins:

static long fib(int n) {
    if (n <= 1) return n;
    Long cached = cache.get(n);
    if (cached != null) return cached;
    long result = fib(n - 1) + fib(n - 2);
    cache.put(n, result);
    return result;
}

A few related notes worth internalizing:

Any idiom labelled "atomic" carries a corresponding rule about what you cannot do inside it. computeIfAbsent promises single-key atomicity in exchange for the promise that you won't touch the map during the callback. Break that trade and you get either a loud CME or, worse, a quiet cache that lies.

Key Takeaway: Never modify a map from inside its own computeIfAbsent lambda — recursion counts as modification, and the failure mode ranges from ConcurrentModificationException to silently corrupted entries.

All newsletters