O cenário é comum. Um cache em ConcurrentHashMap usa computeIfAbsent(key, this::load). Em algum caminho, o load chama um método que consulta o mesmo cache pela mesma chave, ou seja, um computeIfAbsent dentro de outro computeIfAbsent. No code review isso parece um loader esperto, porque reaproveita o cache em vez de duplicar lógica. No JDK 25, a chamada termina em IllegalStateException: Recursive update.
Quem testa a mesma ideia com HashMap costuma sair dizendo que "no HashMap aninha e funciona". No JDK 25 não funciona: o HashMap lança ConcurrentModificationException e ainda deixa no mapa um valor que ninguém pediu para guardar.
A seguir estão o contrato do javadoc, oito testes JUnit que mostram o comportamento das duas classes com a mesma fixture e uma alternativa sem reentrada. Os oito passaram no JDK 25.
Não confunda com o post sobre BigDecimal.equals como chave de Map. Lá o problema era a igualdade da chave. Aqui é a função de mapeamento mexendo no próprio mapa.
Ambiente e versões
- Temurin 25.0.4+7-LTS,
maven.compiler.release=25, sem--enable-preview. - Maven 3.9.12,
junit-jupiter6.1.3,maven-surefire-plugin3.6.0,maven-compiler-plugin3.14.0. mvn -q testgerou o relatório Surefire comtests=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>
O contrato: o que cada javadoc promete
O javadoc de ConcurrentHashMap.computeIfAbsent no Java 25) diz que "the entire method invocation is performed atomically" e logo depois:
> The mapping function must not modify this map during computation.
Na seção Throws aparece IllegalStateException, lançada "if the computation detectably attempts a recursive update to this map that would otherwise never complete". Ou seja: modificar o mapa é proibido, e a exceção cobre o caso em que a implementação consegue detectar uma atualização recursiva que nunca terminaria. Na mesma documentação, se a função lança uma RuntimeException, o mapeamento não é criado. O compute do ConcurrentHashMap traz as mesmas duas frases.
O javadoc de HashMap.computeIfAbsent no Java 25) diz outra coisa:
> 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.
O HashMap não fala em IllegalStateException nem em atualização recursiva, e também não diz em lugar nenhum que a chamada aninhada funciona.
A implementação default de Map.computeIfAbsent faz um get seguido de um put e não é atômica. A seção de requisitos de implementação pede que mapas não concorrentes lancem ConcurrentModificationException, na base do melhor esforço, quando a função modifica o mapa. Para mapas concorrentes, pede IllegalStateException, também como melhor esforço, quando a função modifica o mapa e a computação nunca terminaria. HashMap e ConcurrentHashMap seguem cada um a sua metade da regra.
A fixture e o que ela mostrou
O caso central cabe numa linha:
map.computeIfAbsent("k", k -> map.computeIfAbsent("k", k2 -> "inner"));
Esta é a classe, com os dois testes de reentrada na mesma chave:
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));
}
}
O assertTimeoutPreemptively de 2 segundos protege contra travamento no caso do ConcurrentHashMap. Se a chamada travasse, o teste falharia por timeout em vez de segurar o build. Neste JDK o timeout não disparou: a exceção saiu na hora. Quando o bin está vazio, o ConcurrentHashMap instala um ReservationNode, executa a função e só depois troca esse nó pelo valor. A chamada aninhada na mesma chave encontra o ReservationNode e lança IllegalStateException("Recursive update"). É um nó que existe para você receber uma exceção em vez de ficar olhando um thread dump.
Os outros seis testes da classe variam a chave, o método e a forma de carregar o valor. Resultados observados no Temurin 25.0.4:
| Caso | Resultado | Mensagem | Mapa depois |
|---|---|---|---|
CHM, computeIfAbsent na mesma chave dentro da função | IllegalStateException | Recursive update | vazio (size 0) |
HashMap, computeIfAbsent na mesma chave dentro da função | ConcurrentModificationException | null | {k=inner} |
CHM, computeIfAbsent("other", …) dentro da função de "k" | retorna "v" | — | {k=v, other=o} |
HashMap, computeIfAbsent("other", …) dentro da função de "k" | ConcurrentModificationException | null | {other=o}, sem k |
CHM, compute dentro de compute na mesma chave | IllegalStateException | Recursive update | vazio (size 0) |
Controle k -> "v", CHM e HashMap | retorna "v" | — | {k=v} nos dois |
CHM, get + putIfAbsent | retorna "v", sem exceção | — | {k=v}, size 1 |
CHM, compute cuja função não toca o mapa | retorna "v" | — | {k=v} |
Rodei duas variações à parte, no mesmo JDK e fora dessa classe. Um put("k", …) dentro da função de "k" também deu IllegalStateException: Recursive update e deixou o mapa vazio. Um get("k") dentro da função não lançou nada, porque leitura não é atualização recursiva. Quem dispara o problema é a modificação da mesma chave, não a consulta.
Também não adianta trocar computeIfAbsent reentrante por compute reentrante. A quinta linha da tabela mostra o mesmo IllegalStateException.
Por que o HashMap não é a versão que "funciona no quadro branco"
No quadro branco a história é bonita: a chamada interna grava inner, a externa recebe inner de volta, grava por cima e todo mundo vai almoçar. No JDK 25, o HashMap.computeIfAbsent olha o contador de modificações (modCount) depois de executar a função. Se o mapa mudou durante a função, por qualquer chave, ele lança ConcurrentModificationException.
Por isso a segunda e a quarta linhas da tabela falham do mesmo jeito, mesmo com chaves diferentes. E por isso o estado que sobra é estranho:
- Na mesma chave, a chamada interna já tinha gravado
k=inner. A externa aborta depois disso, e{k=inner}fica no mapa. - Com outra chave,
other=ofica no mapa eknunca é gravado.
Isso não é rollback. A ConcurrentModificationException interrompe a operação externa, mas não desfaz o que a função fez antes. Se o seu código captura essa exceção e continua, ele continua com um mapa parcialmente alterado.
O ConcurrentHashMap se comporta de outro jeito nos dois pontos. Na mesma chave, lança outro tipo de exceção e termina vazio. Com outra chave, neste JDK, completou sem erro. Mesma fixture, exceções diferentes, estados finais diferentes.
Correção: carregar fora do mapa
Se o problema é a função de mapeamento mexer no mapa, a correção é tirar o carregamento de dentro dela. Primeiro get, depois carregar fora do mapa, depois 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());
}
O putIfAbsent do ConcurrentHashMap equivale a containsKey/put/get feito de forma atômica, e não recebe função de mapeamento. Não existe função rodando dentro do mapa, então não existe reentrada. Quando dois threads correm pela mesma chave, só um valor é gravado: quem perde recebe o valor do vencedor em raced e o devolve.
Esse padrão tem um custo. Ele não equivale a um único computeIfAbsent atômico: dois threads podem executar loadValue para a mesma chave ao mesmo tempo, e um dos resultados é descartado. É o preço de não ter reentrada. Se o seu loadValue não pode rodar duas vezes, por exemplo porque tem efeito colateral, esse padrão não serve sem uma proteção extra.
A outra saída válida é compute com uma função que não toca o mapa:
String value = cache.compute(KEY, (k, existing) -> existing == null ? "v" : existing);
O que vale aqui é a função, não o método. compute com uma função que chama o mapa de novo é exatamente o caso que já deu IllegalStateException na tabela.
Se calcular k exige ler o próprio k do cache, o ciclo está nos dados, e nenhum método de Map resolve isso.
Limites
- Outra chave no
ConcurrentHashMap. Neste JDK, umcomputeIfAbsent("other", …)dentro da função de"k"terminou normalmente. O javadoc continua dizendo "must not modify this map", e aIllegalStateExceptioncobre só a atualização recursiva detectável. Passar no teste não é permissão. - Melhor esforço dos dois lados. A
ConcurrentModificationExceptiondoHashMape aIllegalStateExceptiondoConcurrentHashMapsão ambas documentadas como detecção na base do melhor esforço. Não conte com uma exceção para todo efeito colateral ilegal. - Estado após a
ConcurrentModificationException. O{k=inner}que sobrou noHashMapé o que a implementação faz, não uma garantia de nada. Não trate a exceção como transação desfeita. - Travamento. Neste Temurin 25.0.4 a reentrada na mesma chave lançou exceção e não travou. Não generalize isso para "sempre trava" nem para "nunca trava" em qualquer JVM. Mantenha o timeout no teste.
get+putIfAbsent. O valor pode ser carregado mais de uma vez em caso de corrida. Deixe isso explícito no código que usar o padrão.
Próximo passo
Faça um grep -rn "computeIfAbsent" src/main/java e abra cada lambda ou method reference passada para ele. Procure chamadas ao mesmo mapa dentro da função, inclusive as indiretas, como um método de serviço que consulta o mesmo cache. Para cada caso encontrado, escreva um teste parecido com o da fixture, com assertTimeoutPreemptively, e troque o carregamento por get + putIfAbsent ou por uma função que não toque o mapa.
Se você já tem logs de produção, procure Recursive update. Essa mensagem vem do caso de mesma chave no ConcurrentHashMap, e cada ocorrência aponta para um loader que se acha esperto demais.