Todos os artigos

// Knowledge.log — 技術記事

Reentrada em computeIfAbsent: ConcurrentHashMap versus HashMap

JDK 25: computeIfAbsent reentrante na mesma chave lança IllegalStateException no ConcurrentHashMap e CME no HashMap. get + putIfAbsent evita a reentrada.

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-jupiter 6.1.3, maven-surefire-plugin 3.6.0, maven-compiler-plugin 3.14.0.
  • mvn -q test gerou o relatório Surefire com 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>

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:

CasoResultadoMensagemMapa depois
CHM, computeIfAbsent na mesma chave dentro da funçãoIllegalStateExceptionRecursive updatevazio (size 0)
HashMap, computeIfAbsent na mesma chave dentro da funçãoConcurrentModificationExceptionnull{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"ConcurrentModificationExceptionnull{other=o}, sem k
CHM, compute dentro de compute na mesma chaveIllegalStateExceptionRecursive updatevazio (size 0)
Controle k -> "v", CHM e HashMapretorna "v"—{k=v} nos dois
CHM, get + putIfAbsentretorna "v", sem exceção—{k=v}, size 1
CHM, compute cuja função não toca o maparetorna "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=o fica no mapa e k nunca é 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, um computeIfAbsent("other", …) dentro da função de "k" terminou normalmente. O javadoc continua dizendo "must not modify this map", e a IllegalStateException cobre só a atualização recursiva detectável. Passar no teste não é permissão.
  • Melhor esforço dos dois lados. A ConcurrentModificationException do HashMap e a IllegalStateException do ConcurrentHashMap sã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 no HashMap é 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.

javacollections

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

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos