Diferenças entre ConcurrentHashMap, HashMap e Collections.synchronizedMap
1. Visão geral
Quando múltiplas threads leem e escrevem em um Map, você tem três opções familiares: HashMap, Collections.synchronizedMap e ConcurrentHashMap. Essas três opções diferem muito em termos de segurança ao usar múltiplas threads, no mecanismo de lock e no comportamento durante iteração.
Este artigo explica cada tipo através de exemplos executáveis. Após a leitura, você saberá qual escolher, como escrever operações compostas de forma atomic e evitar erros comuns.
2. Preparação
Os exemplos usam Java 21. A seção de testes requer JUnit 5 e AssertJ:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Se o projeto for Spring Boot 3.x, spring-boot-starter-test já inclui ambas as bibliotecas.
3. HashMap: rápido mas não thread-safe
HashMap não possui qualquer mecanismo de sincronização. Em uma única thread, é a opção mais rápida. Quando múltiplas threads escrevem simultaneamente, os dados podem ser perdidos.
3.1. Perda de atualização ao contar simultaneamente
O exemplo a seguir conta o número de pedidos de um cliente usando 8 threads:
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HashMapRaceDemo {
public static void main(String[] args) {
Map<String, Integer> orderCount = new HashMap<>();
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum));
}
} // close() aguarda todos os tasks terminarem
System.out.println("Número de pedidos: " + orderCount.get("customer-1"));
}
}
Enviamos 10.000 tasks, cada um adicionando 1 através de merge. A partir do Java 19, ExecutorService é AutoCloseable, então try-with-resources aguarda todos os tasks. O resultado varia a cada execução:
Número de pedidos: 9817
A razão é que merge do HashMap consiste em vários passos: ler o valor antigo, calcular o novo valor e escrever novamente. Duas threads podem ler 100 simultaneamente, escrever 101, e uma soma ser perdida. Pior ainda, se duas threads acionarem resize ao mesmo tempo, a estrutura interna pode ficar corrompida.
3.2. ConcurrentModificationException durante iteração
O iterator do HashMap é fail-fast. Se o map for modificado durante a iteração sem passar pelo próprio iterator, a próxima chamada de next() pode lançar ConcurrentModificationException. Esse erro ocorre mesmo em uma única thread, por exemplo, quando você chama map.remove() dentro de um loop for-each.
Observação: O mecanismo fail-fast é apenas um “melhor esforço” (best-effort). A ausência de exceção não significa que seu código está correto.
4. Collections.synchronizedMap: um lock para tudo
Collections.synchronizedMap envolve qualquer Map. Todo método do wrapper é executado dentro de synchronized (mutex), e por padrão mutex é o próprio objeto wrapper.
Map<String, Integer> orderCount = Collections.synchronizedMap(new HashMap<>());
Se substituir a linha de inicialização do exemplo na seção 3.1 pela linha acima, o resultado está sempre correto:
Número de pedidos: 10000
A partir do Java 8, esse wrapper sobrescreve os métodos padrão como merge, compute e putIfAbsent, então cada chamada individual é atomic.
4.1. Operações compostas ainda não são atomic
Cada método é sincronizado individualmente. Quando você combina duas chamadas, essa sequência não é mais atomic:
Map<String, Customer> customers = Collections.synchronizedMap(new HashMap<>());
// ERRADO: duas threads podem ver "não existe" e ambas fazer put
if (!customers.containsKey(id)) {
customers.put(id, new Customer(id));
}
// CORRETO: manter o lock durante check-then-act
synchronized (customers) {
if (!customers.containsKey(id)) {
customers.put(id, new Customer(id));
}
}
Uma forma mais simples é usar customers.putIfAbsent(id, new Customer(id)), pois esta é uma única chamada.
4.2. Deve trancar manualmente ao iterar
O iterator do wrapper não é sincronizado. O Javadoc exige que você sincronize no próprio map:
synchronized (customers) {
for (Customer c : customers.values()) {
System.out.println(c);
}
}
Se esquecer o bloco synchronized, você ainda pode ter ConcurrentModificationException. Quando locked, qualquer outra thread que queira ler ou escrever deve esperar até a iteração terminar.
4.3. Gargalo de desempenho
Há apenas um lock para o mapa inteiro. Duas threads lendo duas chaves diferentes ainda devem fazer fila. Conforme o número de threads aumenta, a contenção de lock também aumenta, e o throughput cai significativamente.
5. ConcurrentHashMap: projetado para multithreading
ConcurrentHashMap (pacote java.util.concurrent) foi projetado especificamente para acesso concorrente. A partir do Java 8, sua implementação é a seguinte:
- Leitura sem lock.
getusa leituravolatile, então quase nunca é bloqueada. - Escrita com lock por bucket. Quando um bucket está vazio, usa CAS (compare-and-swap). Quando o bucket já tem elementos,
synchronizedapenas no nó cabeçalho daquele bucket. Threads escrevendo em buckets diferentes rodam em paralelo. - Resize colaborativo. Múltiplas threads participam em transferir dados para a nova tabela, em vez de travar todo o mapa.
5.1. Operações compostas são atomic
Os métodos putIfAbsent, computeIfAbsent, computeIfPresent, compute e merge são todos atomic por chave:
import java.util.concurrent.ConcurrentHashMap;
public class InventoryService {
private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
public void addStock(String product, int quantity) {
stock.merge(product, quantity, Integer::sum);
}
public boolean reserve(String product) {
Integer result = stock.computeIfPresent(product,
(key, current) -> current > 0 ? current - 1 : current);
return result != null && result >= 0;
}
public int available(String product) {
return stock.getOrDefault(product, 0);
}
}
addStock acumula a quantidade usando merge. Se a chave não existir, merge usa quantity como valor inicial. reserve reduz o estoque em 1 usando computeIfPresent. A operação inteira de leitura, verificação e escrita acontece em um passo atomic para aquela chave, então nenhuma thread pode reivindicar a última unidade simultaneamente.
Para contar com frequência muito alta, combinar com LongAdder reduz ainda mais a contenção:
ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent("/orders", k -> new LongAdder()).increment();
5.2. Iterator weakly consistent
O iterator de ConcurrentHashMap nunca lança ConcurrentModificationException. Você pode modificar o map durante a iteração:
ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>(
Map.of("apple", 10, "banana", 0, "cherry", 3));
for (String product : stock.keySet()) {
if (stock.get(product) == 0) {
stock.remove(product);
}
}
System.out.println(stock);
{apple=10, cherry=3}
O iterator reflete o estado do mapa em algum ponto, a partir de sua criação. O iterator pode ou não ver mudanças que ocorrem depois disso. Cada elemento é retornado no máximo uma vez. Este é um trade-off deliberado: você não é bloqueado e não recebe exceção, mas também não tem um snapshot exato.
5.3. Não aceita null
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("apple", null); // NullPointerException
map.put(null, 1); // NullPointerException
Com HashMap, get(key) retornando null pode significar duas coisas: a chave não existe, ou a chave mapeia para null. Em uma única thread, você chama containsKey para diferenciar. Em um ambiente concorrente, entre essas duas chamadas o mapa pode ter mudado. Por isso ConcurrentHashMap proíbe null para que get retornando null tenha um único significado: a chave não existe.
5.4. size() é apenas uma estimativa
Quando outras threads estão escrevendo, size() e isEmpty() refletem apenas um estado aproximado. Se o mapa puder exceder Integer.MAX_VALUE elementos, use mappingCount(), pois esse método retorna long. Não escreva lógica de negócio dependendo de size() ser exato em um ponto no tempo.
6. Testando comportamento concorrente
O teste a seguir confirma que ConcurrentHashMap conta corretamente sob carga concorrente e rejeita valores null:
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ConcurrentMapTest {
@Test
void givenManyThreads_whenMerge_thenNoUpdateIsLost() {
Map<String, Integer> orderCount = new ConcurrentHashMap<>();
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum)));
}
assertThat(orderCount).containsEntry("customer-1", 10_000);
}
@Test
void givenNullValue_whenPut_thenThrowNpe() {
Map<String, Integer> stock = new ConcurrentHashMap<>();
assertThatThrownBy(() -> stock.put("apple", null))
.isInstanceOf(NullPointerException.class);
}
}
O primeiro teste sempre passa. Se você trocar para new HashMap<>(), o teste falhará na maioria das execuções, com o valor real menor que 10.000.
Observação: Um teste multithreaded passar não prova que o código está correto. Race conditions podem aparecer apenas sob carga real. Confie nas garantias da API, como a atomicidade de
merge, não apenas em testes verdes.
7. Erros comuns com ConcurrentHashMap
Combinar get depois put. Usar ConcurrentHashMap não torna seu código automaticamente seguro:
// ERRADO: race condition entre get e put
Integer current = stock.get("apple");
stock.put("apple", current - 1);
// CORRETO
stock.computeIfPresent("apple", (k, v) -> v - 1);
Função em compute* que é lenta ou tem side effects. A função de mapeamento executa enquanto o bucket está locked. Se você fazer chamadas HTTP ou consultas de banco de dados dentro de computeIfAbsent, qualquer outra thread escrevendo naquele bucket será bloqueada. Mantenha a função curta e sem side effects.
Modificar o próprio mapa dentro de compute*. Chamar map.put ou map.computeIfAbsent aninhado na função de mapeamento do mesmo mapa pode lançar IllegalStateException: Recursive update, ou causar deadlock. Um exemplo típico é cache recursivo tipo memoize Fibonacci usando computeIfAbsent.
Precisa de atomicidade em múltiplas chaves. ConcurrentHashMap garante atomicidade apenas por chave. Por exemplo, transferir estoque do depósito A para o depósito B é uma operação em duas chaves. Requer lock separado, ou redesenho dos dados para consolidar em uma chave.
Necessidade de ordem. ConcurrentHashMap não mantém ordem de inserção. Se precisar de chaves ordenadas, use ConcurrentSkipListMap.
8. Comparação abrangente
Critério | HashMap | synchronizedMap | ConcurrentHashMap
----------------------|------------------|--------------------------|--------------------------
Thread-safe | Não | Sim | Sim
Mecanismo de lock | Nenhum | Um lock para todo mapa | CAS + lock por bucket
Leitura concorrente | Insegura | Sequencial (bloqueada) | Sem lock
Operações compostas | Não atomic | Requer synchronized externo | compute/merge atomic
Iterator | Fail-fast | Fail-fast, requer lock manual | Weakly consistent
Chave/valor null | Permitido | Depende do mapa envelopado | Não permitido
Desempenho multithreaded | — | Ruim com alta contenção | Bom
Então qual escolher?
HashMap: o mapa é acessado apenas de uma thread, ou é criado e publicado com segurança para ser apenas leitura. Para read-only,Map.copyOfé ainda melhor.Collections.synchronizedMap: você precisa envolver umMapexistente comoLinkedHashMapouTreeMappara preservar ordem ou comportamento especial, carga concorrente baixa, ou você precisa trancar todo o mapa para uma sequência longa de operações.ConcurrentHashMap: a escolha padrão para caches, contadores, registros e qualquer mapa compartilhado entre múltiplas threads.
9. Conclusão
HashMap é rápido mas perde dados quando múltiplas threads escrevem. Collections.synchronizedMap é seguro graças a um único lock. Em troca, cria um gargalo, e você ainda precisa trancar manualmente ao iterar ou combinar operações. ConcurrentHashMap permite leitura sem lock e escrita paralela por bucket. Oferece operações atomic como merge e computeIfAbsent, e seu iterator não lança exceção quando o mapa muda.
Lembre-se de dois princípios. Primeiro, uma collection thread-safe não torna suas sequências de operações automaticamente atomic, então use compute* e merge em vez de get depois put. Segundo, mantenha a função de mapeamento curta e não modifique o próprio mapa dentro dela.