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:
- 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.
- 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.
- Atualizações compostas atômicas.
merge,compute,computeIfAbsenteputIfAbsentfazem sua verificação e ação atomicamente. Sem eles, o padrão não sincronizadoif (!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 queryApoiado 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 ConcurrentModificationExceptionCada 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 emptyput 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:
| Classe | Limitada? | Quando usar |
|---|---|---|
ArrayBlockingQueue(cap) | Sim — capacidade fixa | Buffer de tamanho fixo; contrapressão no produtor |
LinkedBlockingQueue() | Não (ou com limite) | Fila de uso geral com alto throughput |
SynchronousQueue | 0 — transferência direta | Cada put espera por um take; transferência thread a thread |
PriorityBlockingQueue | Não | Tarefas ordenadas por prioridade (não por inserção) |
DelayQueue | Não | Cada 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 blockSem 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
ConcurrentModificationExceptionmesmo 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-valor →
ConcurrentHashMap(padrão),ConcurrentSkipListMap(precisa de ordenado/intervalo). - Lista de listeners com leitura intensiva →
CopyOnWriteArrayList. - Fila de tarefas produtor–consumidor →
ArrayBlockingQueue(limitada),LinkedBlockingQueue(sem necessidade de limite),SynchronousQueue(transferência direta). - Fila de prioridade entre threads →
PriorityBlockingQueue. - Fila para agendar para depois com atraso →
DelayQueue. - Alta vazão sem bloqueio e sem trava →
ConcurrentLinkedQueue/ConcurrentLinkedDeque. - Conjunto →
ConcurrentHashMap.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.
O que observar na execução:
- A seção 1 do
ConcurrentHashMap.mergeproduziu exatamente a contagem esperada de40.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 umHashMapsimples eput, você obteria uma fração do valor esperado e provavelmente também um estado interno corrompido. - O iterador de
CopyOnWriteArrayListda seção 2 viu[a, b, c](o snapshot no momento em que o iterador foi criado). As escritas que adicionaramdeedurante a iteração não lançaramConcurrentModificationExceptione 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
ArrayBlockingQueuecom capacidade 4 da seção 3 forçou o produtor a bloquear noputsempre 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
ConcurrentLinkedQueueda 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: nenhumtake()para esperar em uma fila vazia;poll()retornanulle 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.