All articles

// Knowledge.log — 技術記事

Reentrant computeIfAbsent: ConcurrentHashMap versus HashMap

JDK 25: same-key computeIfAbsent throws IllegalStateException on ConcurrentHashMap and CME on HashMap. get + putIfAbsent does not reenter. Eight tests.

You have probably seen this one. A cache backed by a ConcurrentHashMap uses computeIfAbsent(key, this::load). Somewhere down one code path, load calls a method that looks up the same key in the same cache. That puts a computeIfAbsent inside another computeIfAbsent. In code review it looks like a clever loader, since it reuses the cache instead of duplicating logic. On JDK 25 the call ends in IllegalStateException: Recursive update.

People who try the same idea with a HashMap often come away saying "HashMap nests fine." On JDK 25 it doesn't. HashMap throws ConcurrentModificationException, and it also leaves a value in the map that nobody asked it to keep.

Below you'll find the javadoc contract, eight JUnit tests that run both classes against the same fixture, and an alternative with no reentry. All eight passed on JDK 25.

This is a different problem from the post on BigDecimal.equals as a Map key. That one was about key equality. This one is about a mapping function that modifies its own map.

Environment and versions

  • Temurin 25.0.4+7-LTS, maven.compiler.release=25, no --enable-preview.
  • Maven 3.9.12, junit-jupiter 6.1.3, maven-surefire-plugin 3.6.0, maven-compiler-plugin 3.14.0.
  • mvn -q test produced a Surefire report with tests=8 failures=0 errors=0 skipped=0.
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>devdojo</groupId>
  <artifactId>computeifabsent-reentry</artifactId>
  <version>1.0.0</version>
  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.release>25</maven.compiler.release>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>6.1.3</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.6.0</version>
      </plugin>
    </plugins>
  </build>
</project>

The contract: what each javadoc promises

The Java 25 javadoc for ConcurrentHashMap.computeIfAbsent) says "the entire method invocation is performed atomically," and right after that:

> The mapping function must not modify this map during computation.

The Throws section lists IllegalStateException, thrown "if the computation detectably attempts a recursive update to this map that would otherwise never complete." So modifying the map is forbidden, and the exception covers the case where the implementation can detect a recursive update that would never finish. The same page says that if the function throws a RuntimeException, no mapping is recorded. ConcurrentHashMap.compute carries the same two sentences.

The Java 25 javadoc for HashMap.computeIfAbsent) says something different:

> The mapping function should not modify this map during computation. This method will, on a best-effort basis, throw a ConcurrentModificationException if it is detected that the mapping function modifies this map during computation.

HashMap says nothing about IllegalStateException or recursive updates. It also never says the nested call works.

The default Map.computeIfAbsent does a get followed by a put and is not atomic. Its implementation requirements ask non-concurrent maps to throw ConcurrentModificationException, on a best-effort basis, when the function modifies the map. Concurrent maps are asked to throw IllegalStateException, also best-effort, when the function modifies the map and the computation would never complete. HashMap and ConcurrentHashMap each follow their own half of that rule.

The fixture and what it showed

The core case fits on one line:

map.computeIfAbsent("k", k -> map.computeIfAbsent("k", k2 -> "inner"));

Here is the class with the two same-key reentry tests:

package devdojo.chm;

import java.time.Duration;
import java.util.ConcurrentModificationException;
import java.util.HashMap;
import java.util.concurrent.ConcurrentHashMap;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;

class ComputeIfAbsentReentryTest {

    private static final String KEY = "k";
    private static final Duration TWO_SECONDS = Duration.ofSeconds(2);

    @Test
    void chmSameKeyReentryThrowsIllegalStateExceptionRecursiveUpdate() {
        ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

        IllegalStateException thrown = assertTimeoutPreemptively(TWO_SECONDS, () ->
                assertThrows(IllegalStateException.class, () ->
                        map.computeIfAbsent(KEY, k -> map.computeIfAbsent(KEY, k2 -> "inner"))));

        assertEquals("Recursive update", thrown.getMessage());
        assertEquals(0, map.size());
    }

    @Test
    void hashMapSameKeyReentryThrowsConcurrentModificationException() {
        HashMap<String, String> map = new HashMap<>();

        ConcurrentModificationException thrown = assertThrows(
                ConcurrentModificationException.class,
                () -> map.computeIfAbsent(KEY, k -> map.computeIfAbsent(KEY, k2 -> "inner")));

        assertNull(thrown.getMessage());
        assertEquals(1, map.size());
        assertEquals("inner", map.get(KEY));
    }
}

The 2-second assertTimeoutPreemptively guards the ConcurrentHashMap case against a hang. If the call ever blocked, the test would fail on the timeout instead of stalling the build. On this JDK the timeout never fired, because the exception came back immediately. When the bin is empty, ConcurrentHashMap installs a ReservationNode, runs the function, and only then replaces that node with the value. The nested call on the same key finds the ReservationNode and throws IllegalStateException("Recursive update"). The node exists so you get an exception instead of a thread dump to stare at.

The other six tests in the class change the key, the method, and the way the value is loaded. Results observed on Temurin 25.0.4:

CaseResultMessageMap afterwards
CHM, same-key computeIfAbsent inside the functionIllegalStateExceptionRecursive updateempty (size 0)
HashMap, same-key computeIfAbsent inside the functionConcurrentModificationExceptionnull{k=inner}
CHM, computeIfAbsent("other", …) inside the function for "k"returns "v"—{k=v, other=o}
HashMap, computeIfAbsent("other", …) inside the function for "k"ConcurrentModificationExceptionnull{other=o}, no k
CHM, compute inside compute on the same keyIllegalStateExceptionRecursive updateempty (size 0)
Control k -> "v", CHM and HashMapreturns "v"—{k=v} in both
CHM, get + putIfAbsentreturns "v", no exception—{k=v}, size 1
CHM, compute whose function doesn't touch the mapreturns "v"—{k=v}

Two more variations were run on the same JDK, outside this class. A put("k", …) inside the function for "k" also produced IllegalStateException: Recursive update and left the map empty. A get("k") inside the function threw nothing, because a read is not a recursive update. What triggers the problem is modifying the same key, not looking it up.

Swapping reentrant computeIfAbsent for reentrant compute doesn't help either. The fifth row of the table shows the same IllegalStateException.

Why HashMap isn't the version that "works on the whiteboard"

On the whiteboard the story is tidy. The inner call stores inner, the outer call gets inner back, writes over it, and everyone goes to lunch. On JDK 25, HashMap.computeIfAbsent checks its modification counter (modCount) after running the function. If the map changed during the function, for any key at all, it throws ConcurrentModificationException.

That's why the second and fourth rows of the table fail the same way even though the keys differ. It's also why the leftover state looks odd:

  • With the same key, the inner call had already stored k=inner. The outer call aborts after that, and {k=inner} stays in the map.
  • With a different key, other=o stays in the map and k is never stored.

There is no rollback here. The ConcurrentModificationException stops the outer operation, but it doesn't undo what the function already did. If your code catches that exception and keeps going, it keeps going with a partly modified map.

ConcurrentHashMap behaves differently on both counts. With the same key it throws a different exception type and ends up empty. With a different key, on this JDK, it completed without error. Same fixture, different exceptions, different final state.

Fix: load outside the map

If the problem is a mapping function that touches the map, the fix is to move the loading out of it. First get, then load outside the map, then putIfAbsent:

private static String loadWithoutReentry(ConcurrentHashMap<String, String> cache, String key) {
    String existing = cache.get(key);
    if (existing != null) {
        return existing;
    }
    String computed = loadValue(key); // não chama computeIfAbsent no cache
    String raced = cache.putIfAbsent(key, computed);
    return raced == null ? computed : raced;
}

@Test
void getThenPutIfAbsentDoesNotReenterAndStoresExpectedValue() {
    ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();

    assertEquals("v", loadWithoutReentry(cache, KEY));
    assertEquals("v", cache.get(KEY));
    assertEquals("v", loadWithoutReentry(cache, KEY));
    assertEquals(1, cache.size());
}

The comment in loadValue means "does not call computeIfAbsent on the cache," which is the whole point. ConcurrentHashMap.putIfAbsent is equivalent to containsKey/put/get performed atomically, and it takes no mapping function. With no function running inside the map, there's nothing to reenter. When two threads race on the same key, only one value is stored: the loser gets the winner's value back in raced and returns it.

This pattern has a cost. It isn't the same as a single atomic computeIfAbsent. Two threads can run loadValue for the same key at the same time, and one result gets thrown away. That's what you pay for getting rid of reentry. If your loadValue must not run twice, for example because it has side effects, this pattern isn't enough without some extra protection.

The other valid option is compute with a function that doesn't touch the map:

String value = cache.compute(KEY, (k, existing) -> existing == null ? "v" : existing);

What matters here is the function, not the method. compute with a function that calls back into the map is exactly the case that already threw IllegalStateException in the table.

If computing k requires reading k itself from the cache, the cycle is in your data, and no Map method will fix that.

Limits

  • A different key on ConcurrentHashMap. On this JDK, a computeIfAbsent("other", …) inside the function for "k" completed normally. The javadoc still says "must not modify this map," and IllegalStateException only covers a detectable recursive update. A passing test is not permission.
  • Best effort on both sides. HashMap's ConcurrentModificationException and ConcurrentHashMap's IllegalStateException are both documented as best-effort detection. Don't count on an exception for every illegal side effect.
  • State after ConcurrentModificationException. The {k=inner} left in the HashMap is what this implementation happens to do. It guarantees nothing. Don't treat the exception as a rolled-back transaction.
  • Hangs. On this Temurin 25.0.4, same-key reentry threw an exception and did not hang. Don't turn that into "it always hangs" or "it never hangs" on every JVM. Keep the timeout in the test.
  • get + putIfAbsent. The value can be loaded more than once under a race. Make that explicit in any code that uses the pattern.

Next step

Run grep -rn "computeIfAbsent" src/main/java and open every lambda or method reference passed to it. Look for calls back into the same map inside the function, including indirect ones, like a service method that queries the same cache. For each one you find, write a test modeled on the fixture, with assertTimeoutPreemptively, and replace the loading with get + putIfAbsent or with a function that doesn't touch the map.

If you have production logs, search them for Recursive update. That message comes from the same-key case in ConcurrentHashMap, and every hit points to a loader that thinks it's a bit too clever.

javacollections

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles