W3docs

Coleções Concorrentes em Java

Coleções thread-safe em java.util.concurrent — ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue — e quando usar cada uma.

HashMap, ArrayList, ArrayDeque — essas são as coleções do dia a dia, e nenhuma delas é thread-safe. Usá-las a partir de múltiplas threads sem sincronização externa resulta em atualizações perdidas, invariantes corrompidos e a temida ConcurrentModificationException durante a iteração. A resposta legada era Collections.synchronizedMap(...), que envolve um mapa regular com um único bloqueio grande. Isso funciona, mas serializa todas as operações.

O pacote java.util.concurrent substituiu a abordagem de envolver com bloqueio por coleções projetadas para acesso concorrente desde o início: variantes com bloqueio fragmentado, cópia na escrita e sem bloqueio, ajustadas para diferentes proporções de leitura/escrita. Este capítulo é o tour — no que cada classe é melhor e os modos de falha que você deve conhecer.

ConcurrentHashMap — o cavalo de batalha

A coleção concorrente mais usada em Java. Um mapa com a forma de HashMap que você pode usar a partir de muitas threads sem sincronização externa:

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();

counts.put("hits", 1);
counts.merge("hits", 1, Integer::sum);                // atomic add-or-increment
counts.computeIfAbsent("misses", k -> 0);
counts.computeIfPresent("hits", (k, v) -> v + 1);

Três coisas o tornam rápido sob contenção:

  1. Fragmentação de bloqueio. Chaves diferentes são protegidas por bloqueios internos diferentes, então escritas em chaves não relacionadas não se bloqueiam mutuamente.
  2. Leituras sem bloqueio. As leituras não adquirem nenhum bloqueio (no estado estável). Um leitor pode competir com um escritor; o resultado é o valor antigo ou o novo, nunca um corrompido.
  3. Atualizações compostas atômicas. merge, compute, computeIfAbsent e putIfAbsent fazem sua verificação e ação atomicamente. Sem eles, o padrão não sincronizado if (!map.containsKey(k)) map.put(k, v) tem uma janela de corrida entre as duas chamadas; os métodos atômicos a eliminam.

Use ConcurrentHashMap sempre que um HashMap for acessado por mais de uma thread. É o padrão correto — mais rápido que Hashtable, mais rápido que synchronizedMap, e suporta atualizações compostas atômicas que os outros não suportam.

Uma regra: chaves null e valores null não são permitidos. containsKey(k) é confiável; map.get(k) == null é ambíguo (chave ausente vs. valor null). Proibir nulls elimina a ambiguidade.

ConcurrentSkipListMap — mapa concorrente ordenado

Quando você precisa de um mapa com a forma de TreeMap (ordenado por chave) e acesso concorrente:

ConcurrentSkipListMap<Long, Event> byTimestamp = new ConcurrentSkipListMap<>();

byTimestamp.put(1700000000000L, e1);
byTimestamp.put(1700000005000L, e2);

byTimestamp.firstEntry();                              // earliest
byTimestamp.lastEntry();                               // latest
byTimestamp.subMap(start, end);                        // range query

Apoiado por uma skip list (uma alternativa probabilística a uma árvore balanceada que é mais fácil de tornar sem bloqueio). Suporta toda a API NavigableMap. Mais lento que ConcurrentHashMap para busca simples por chave; a escolha certa quando você precisa de iteração ordenada ou consultas de intervalo.

CopyOnWriteArrayList — lista pequena com leitura intensiva

CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(myListener);
for (Listener l : listeners) l.onEvent(e);             // never throws ConcurrentModificationException

Cada escrita copia o array subjacente. As leituras são sem espera — sem bloqueio, sem sincronização, sem CME. A troca é óbvia: cada add/remove/set é O(n) porque copia o array inteiro.

Isso é uma troca terrível para cargas de trabalho com muita escrita. É uma troca perfeita para a carga de trabalho para a qual foi projetada:

  • Uma lista pequena (dezenas, talvez centenas, de itens).
  • Leituras superam amplamente as escritas.
  • Iteração é comum; nunca deve lançar CME.

O caso de uso clássico: uma lista de listeners de eventos, entradas de configuração ou assinantes registrados. Leituras acontecem em cada evento; escritas acontecem na inicialização ou quando um componente se registra.

Não use CopyOnWriteArrayList para "qualquer coisa que eu colocaria em um ArrayList." Para coleções compartilhadas mutáveis que não são pequenas e de leitura intensiva, use Collections.synchronizedList em torno de um ArrayList, ou repense a estrutura de dados.

BlockingQueue — a fila produtor/consumidor

A abstração mais útil em java.util.concurrent:

BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1024);

queue.put(task);                                       // blocks if full
queue.offer(task, 100, TimeUnit.MILLISECONDS);         // blocks up to deadline
queue.add(task);                                       // throws if full

Task t = queue.take();                                 // blocks if empty
Task t2 = queue.poll(100, TimeUnit.MILLISECONDS);      // blocks up to deadline
Task t3 = queue.poll();                                // returns null if empty

put e take são as operações de bloqueio: elas esperam até que a fila não esteja cheia / não esteja vazia. Essa é a espinha dorsal do framework executor — cada ThreadPoolExecutor internamente mantém uma BlockingQueue de tarefas pendentes; os workers fazem take dela; o execute público insere nela.

Implementações comuns:

ClasseLimitada?Quando usar
ArrayBlockingQueue(cap)Sim — capacidade fixaBuffer de tamanho fixo; contrapressão no produtor
LinkedBlockingQueue()Não (ou com limite)Fila de uso geral com alto throughput
SynchronousQueue0 — transferência diretaCada put espera por um take; transferência thread a thread
PriorityBlockingQueueNãoTarefas ordenadas por prioridade (não por inserção)
DelayQueueNãoCada elemento tem um atraso; só é retirado quando o atraso expira

ArrayBlockingQueue é o padrão em produção — ele limita o trabalho em andamento, o que é essencial para contrapressão. LinkedBlockingQueue sem limite é a armadilha por trás de Executors.newFixedThreadPool (fila ilimitada → memória ilimitada).

ConcurrentLinkedQueue e ConcurrentLinkedDeque — as ilimitadas sem bloqueio

ConcurrentLinkedQueue<Event> events = new ConcurrentLinkedQueue<>();
events.add(e);
Event e = events.poll();                               // null if empty; doesn't block

Sem bloqueio, sem trava, ilimitadas. poll retorna null em vez de bloquear; não há take. Melhor quando:

  • Você quer alto throughput.
  • Você pode tolerar "fila vazia" como um retorno rápido em vez de uma espera.
  • Você não precisa de contrapressão.

Estas não são BlockingQueues — escolha-as quando você genuinamente não quer a semântica de bloqueio.

Iteração: consistência fraca

Um iterador de HashMap lança ConcurrentModificationException se o mapa mudar durante a iteração. As coleções concorrentes fazem algo diferente: seus iteradores são fracamente consistentes. Isso significa:

  • Eles não lançarão ConcurrentModificationException mesmo se outras threads modificarem a coleção.
  • Eles garantem ver cada elemento que estava presente quando o iterador foi criado.
  • Eles podem ou não refletir modificações feitas após a criação do iterador.

Isso é adequado para a maioria dos usos — um iterador de snapshot é exatamente o que o código concorrente quer. A troca: size() também é "fracamente consistente" — para ConcurrentHashMap é uma contagem de melhor esforço, não um valor de snapshot garantido. Se você está tratando size() como autoritativo, está usando mal a API.

Quando usar qual

Uma árvore de decisão aproximada:

  • Armazenamento chave-valorConcurrentHashMap (padrão), ConcurrentSkipListMap (precisa de ordenado/intervalo).
  • Lista de listeners com leitura intensivaCopyOnWriteArrayList.
  • Fila de tarefas produtor–consumidorArrayBlockingQueue (limitada), LinkedBlockingQueue (sem necessidade de limite), SynchronousQueue (transferência direta).
  • Fila de prioridade entre threadsPriorityBlockingQueue.
  • Fila para agendar para depois com atrasoDelayQueue.
  • Alta vazão sem bloqueio e sem travaConcurrentLinkedQueue / ConcurrentLinkedDeque.
  • ConjuntoConcurrentHashMap.newKeySet(), CopyOnWriteArraySet, ConcurrentSkipListSet.

Sempre que uma coleção regular for acessada por mais de uma thread, escolha uma coleção concorrente ou envolva-a com Collections.synchronizedX — nunca apenas espere que funcione.

Um exemplo prático: cada coleção fazendo seu trabalho

O programa abaixo demonstra quatro coleções concorrentes sob uma carga de trabalho compartilhada — um ConcurrentHashMap contando eventos, um CopyOnWriteArrayList de listeners, um ArrayBlockingQueue para produtor/consumidor e um ConcurrentLinkedQueue para inserção sem bloqueio.

java— editable, runs on the server

O que observar na execução:

  • A seção 1 do ConcurrentHashMap.merge produziu exatamente a contagem esperada de 40.000. A função de mesclagem (Integer::sum) foi executada atomicamente por chave, então duas threads incrementando a mesma chave nunca perderam uma atualização — a atualização composta atômica é o ponto central. Com um HashMap simples e put, você obteria uma fração do valor esperado e provavelmente também um estado interno corrompido.
  • O iterador de CopyOnWriteArrayList da seção 2 viu [a, b, c] (o snapshot no momento em que o iterador foi criado). As escritas que adicionaram d e e durante a iteração não lançaram ConcurrentModificationException e não foram vistas pelo iterador em andamento. A lista final continha todos os cinco — as escritas aconteceram, mas foram simplesmente invisíveis para o iterador que já havia sido iniciado.
  • O ArrayBlockingQueue com capacidade 4 da seção 3 forçou o produtor a bloquear no put sempre que a fila estava cheia. A saída mostrou a fila enchendo até 4, depois o produtor pausando enquanto o consumidor drena, depois o produtor retomando. Isso é contrapressão feita pela estrutura de dados: o produtor não pode correr mais rápido que o consumidor, mesmo sem código de coordenação.
  • O ConcurrentLinkedQueue da seção 4 aceitou escritas de quatro threads sem bloqueio e sem contenção de trava. A contagem final drenada correspondeu exatamente à contagem inserida — cada elemento escrito foi lido com sucesso. O custo: nenhum take() para esperar em uma fila vazia; poll() retorna null e você precisa lidar com isso.
  • Durante todo o processo, as coleções concorrentes nunca lançaram ConcurrentModificationException. Essa exceção é uma característica das coleções não concorrentes — é a forma da JVM dizer "você quebrou isso." As coleções concorrentes são projetadas para ser modificadas a partir de múltiplas threads, então não precisam desse sinal.

O que vem a seguir

O próximo capítulo, Java Virtual Threads, aborda o recurso do Java 21 que muda como você pensa sobre contagens de threads — threads leves agendadas pela JVM que tornam E/S bloqueante barata novamente.

Prática

Prática
Você precisa de um `Map` thread-safe que seja modificado por muitas threads e suporte a operação atômica de 'incrementar um contador sob uma chave.' Qual é a escolha certa?
Você precisa de um `Map` thread-safe que seja modificado por muitas threads e suporte a operação atômica de 'incrementar um contador sob uma chave.' Qual é a escolha certa?
Was this page helpful?