Ferramentas de Precisão — Locks, Atomics e Coleções Concorrentes

Ferramentas de Precisão — Locks, Atomics e Coleções Concorrentes

Quando o synchronized pesa demais, o java.util.concurrent oferece peças sob medida: variáveis atômicas com CAS, LongAdder, ReentrantLock com tryLock contra o impasse, travas de leitura e escrita, ConcurrentHashMap com merge, filas bloqueantes, Semaphore, CountDownLatch e a armadilha do ThreadLocal em pools.
Java

• • 27 min de leitura

As ferramentas do pacote java.util.concurrent para quando synchronized não basta: variáveis atômicas, travas com prazo e para leitura, mapas e filas feitos para várias threads e sincronizadores que coordenam grupos de tarefas.

Em "O Cadeado e a Memória", o synchronized resolveu o contador que perdia incrementos, mas com um custo visível: as threads passavam a maior parte do tempo esperando a trava. A mesma aula deixou três pendências. O contador precisava de uma alternativa mais eficiente, o impasse da transferência bancária tinha uma segunda solução, que exige travas com prazo, e as filas e outras estruturas que garantem a visibilidade por dentro ficaram prometidas para as aulas seguintes. O pacote java.util.concurrent responde às três com ferramentas especializadas, cada uma mais rápida ou mais expressiva que o synchronized num caso específico. Conhecer essas ferramentas é saber qual peça usar em cada situação, e em muitas delas a resposta é não escrever nenhuma trava.

Variáveis atômicas

O pacote java.util.concurrent.atomic oferece classes como AtomicInteger, AtomicLong, AtomicBoolean e AtomicReference, que guardam um único valor e oferecem operações atômicas sobre ele, sem trava. O segredo é uma instrução que os processadores modernos têm, chamada compare-and-swap (CAS, "compare e troque"): "grave o valor novo neste endereço, mas só se o valor atual ainda for o que eu espero; me diga se deu certo". O processador executa essa instrução como uma unidade indivisível:

import java.util.concurrent.atomic.*;

void main() {
    AtomicInteger saldo = new AtomicInteger(100);
    // compareAndSet: só grava se o valor ainda for o esperado
    IO.println(saldo.compareAndSet(100, 70) + " -> " + saldo.get());
    IO.println(saldo.compareAndSet(100, 40) + " -> " + saldo.get());

    // O laço que as classes atômicas fazem por dentro
    int atual, novo;
    do {
        atual = saldo.get();
        novo = Math.max(0, atual - 90);        // saque que não deixa o saldo negativo
    } while (!saldo.compareAndSet(atual, novo));
    IO.println("depois do saque: " + saldo.get());

    // O mesmo, em uma linha
    saldo.set(100);
    IO.println(saldo.updateAndGet(s -> Math.max(0, s - 30)));
    IO.println(saldo.accumulateAndGet(50, Integer::sum));

    // Maior valor já visto, atualizado de forma atômica
    AtomicLong recorde = new AtomicLong();
    for (long v : new long[]{12, 40, 7, 33}) recorde.accumulateAndGet(v, Math::max);
    IO.println("recorde: " + recorde.get());
}
true -> 70
false -> 70
depois do saque: 0
70
120
recorde: 40

O segundo compareAndSet falha porque o saldo já não é 100, e nada é gravado. O laço do/while é o padrão fundamental da programação sem travas: ler o valor, calcular o novo, tentar gravar com CAS, e, se outra thread mudou o valor no meio do caminho, recomeçar com o valor atualizado. Nenhuma thread fica bloqueada: no pior caso, ela repete o cálculo. Esse estilo se chama otimista, porque aposta que a colisão é rara, em contraste com o estilo pessimista do synchronized, que tranca a porta sempre, por via das dúvidas.

Os métodos updateAndGet e accumulateAndGet fazem esse laço por você, recebendo a função que calcula o valor novo. Como o laço pode repetir, a função pode ser chamada mais de uma vez e precisa ser pura, sem efeitos colaterais: nada de imprimir, gravar em arquivo ou incrementar outro contador dentro dela. As variáveis atômicas também têm as garantias de visibilidade do volatile: uma escrita numa atômica é vista por qualquer leitura posterior, em qualquer thread.

O contador, três vezes

Com as atômicas, o contador de "O Cadeado e a Memória" ganha duas alternativas. A terceira classe, LongAdder, foi criada no Java 8 especificamente para contadores muito disputados:

import java.util.concurrent.atomic.*;
import java.util.function.*;

interface Contador { void incrementar(); long valor(); }

static class ComTrava implements Contador {
    private long valor;
    public synchronized void incrementar() { valor++; }
    public synchronized long valor() { return valor; }
}

static class Atomico implements Contador {
    private final AtomicLong valor = new AtomicLong();
    public void incrementar() { valor.incrementAndGet(); }
    public long valor() { return valor.get(); }
}

static class Somador implements Contador {
    private final LongAdder valor = new LongAdder();
    public void incrementar() { valor.increment(); }
    public long valor() { return valor.sum(); }
}

static void medir(String nome, Supplier<Contador> fabrica) throws InterruptedException {
    Contador c = fabrica.get();
    Runnable trabalho = () -> { for (int i = 0; i < 10_000_000; i++) c.incrementar(); };
    long t0 = System.nanoTime();
    Thread a = Thread.ofPlatform().start(trabalho);
    Thread b = Thread.ofPlatform().start(trabalho);
    a.join(); b.join();
    IO.println(String.format("%-12s %d em %d ms", nome, c.valor(), (System.nanoTime() - t0) / 1_000_000));
}

void main() throws InterruptedException {
    for (int r = 0; r < 3; r++) {
        medir("synchronized", ComTrava::new);
        medir("AtomicLong", Atomico::new);
        medir("LongAdder", Somador::new);
    }
}
synchronized 20000000 em 1672 ms
AtomicLong   20000000 em 246 ms
LongAdder    20000000 em 132 ms
synchronized 20000000 em 995 ms
AtomicLong   20000000 em 385 ms
LongAdder    20000000 em 118 ms
synchronized 20000000 em 2902 ms
AtomicLong   20000000 em 393 ms
LongAdder    20000000 em 116 ms

As três versões chegam ao valor correto, 20 milhões, mas em tempos muito diferentes. O synchronized é o mais lento e o mais instável, variando de 1 a quase 3 segundos, porque a disputa pela trava coloca threads para dormir e acordá-las custa caro. O AtomicLong é várias vezes mais rápido: nenhuma thread dorme, só repete o CAS quando perde a corrida. E o LongAdder é o mais rápido e o mais estável.

A diferença do LongAdder está no desenho. Um AtomicLong tem um único valor, e com duas threads incrementando sem parar, uma delas perde a corrida do CAS o tempo todo. O LongAdder percebe a disputa e passa a manter várias células (no máximo cerca de uma por núcleo), e cada thread soma seus incrementos na célula que lhe coube. O valor total só é calculado quando alguém chama sum(). A disputa quase desaparece porque as threads passam a escrever em lugares diferentes. O preço é que sum() não é uma foto instantânea: se outras threads estiverem incrementando durante a soma, o resultado pode não incluir os incrementos mais recentes. Para estatísticas, como contar requisições ou erros, isso não importa. Para gerar números de pedido únicos, importa, e aí o AtomicLong.incrementAndGet() é a escolha certa, porque devolve exatamente o valor que aquela thread criou.

Estes números são de uma máquina de dois núcleos, com o mesmo cuidado de "Medir Antes de Otimizar": em mais núcleos, a vantagem do LongAdder cresce, e sem disputa nenhuma as três versões ficam parecidas, porque a JVM otimiza travas que ninguém disputa.

ReentrantLock: a trava que sabe desistir

As travas do synchronized têm limitações: uma thread que tenta obter uma trava ocupada espera para sempre, sem prazo e sem poder ser interrompida, e a trava precisa ser liberada no mesmo bloco onde foi obtida. O pacote java.util.concurrent.locks oferece travas explícitas, cuja implementação principal é a ReentrantLock. Ela é reentrante como o synchronized, mas obtida e liberada com chamadas de método:

trava.lock();
try {
    // seção crítica
} finally {
    trava.unlock();   // sempre no finally: uma exceção não pode deixar a trava presa
}

Esse formato é obrigatório. Com synchronized, a JVM libera a trava sozinha quando o bloco termina, até por exceção. Com uma trava explícita, esquecer o unlock() num caminho de exceção deixa a trava presa para sempre, e todas as outras threads que a pedirem ficam bloqueadas.

Em troca desse cuidado, a ReentrantLock oferece o tryLock, que tenta obter a trava e desiste se não conseguir, na hora ou depois de um prazo. É a segunda solução para o impasse da transferência bancária de "O Cadeado e a Memória": em vez de impor uma ordem às travas, cada thread tenta obter as duas e, se não conseguir a segunda, solta a primeira e tenta de novo depois:

import java.util.concurrent.*;
import java.util.concurrent.locks.*;

static class Conta {
    final String nome;
    final ReentrantLock trava = new ReentrantLock();
    private long saldo;
    Conta(String nome, long saldo) { this.nome = nome; this.saldo = saldo; }
    long saldo() {
        trava.lock();
        try { return saldo; } finally { trava.unlock(); }
    }
}

// Tenta obter as duas travas; se não conseguir, solta tudo e tenta de novo
static boolean transferir(Conta de, Conta para, long valor, long prazoMs) throws InterruptedException {
    long limite = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(prazoMs);
    while (System.nanoTime() < limite) {
        if (de.trava.tryLock(1, TimeUnit.MILLISECONDS)) {
            try {
                if (para.trava.tryLock(1, TimeUnit.MILLISECONDS)) {
                    try {
                        if (de.saldo < valor) return false;
                        de.saldo -= valor;
                        para.saldo += valor;
                        return true;
                    } finally {
                        para.trava.unlock();
                    }
                }
            } finally {
                de.trava.unlock();
            }
        }
        Thread.sleep(ThreadLocalRandom.current().nextInt(1, 3)); // espera aleatória, evita repetir a colisão
    }
    throw new IllegalStateException("não consegui transferir dentro do prazo");
}

void main() throws Exception {
    Conta ana = new Conta("Ana", 1_000_000), bruno = new Conta("Bruno", 1_000_000);
    long t0 = System.nanoTime();
    try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
        pool.submit(() -> { for (int i = 0; i < 100_000; i++) transferir(ana, bruno, 1, 1000); return null; });
        pool.submit(() -> { for (int i = 0; i < 100_000; i++) transferir(bruno, ana, 1, 1000); return null; });
    }
    IO.println("Ana = " + ana.saldo() + ", Bruno = " + bruno.saldo()
            + ", total = " + (ana.saldo() + bruno.saldo())
            + " em " + (System.nanoTime() - t0) / 1_000_000 + " ms");
}
Ana = 1000000, Bruno = 1000000, total = 2000000 em 62 ms

As duas threads transferem em sentidos opostos, a situação exata que causava o impasse, e o programa termina com o dinheiro preservado. Nas quatro condições de Coffman, a condição quebrada agora é a posse e espera: nenhuma thread fica segurando uma trava enquanto espera indefinidamente pela outra.

O Thread.sleep aleatório no fim do laço não é enfeite. Sem ele, as duas threads podem entrar num ritmo simétrico: cada uma pega a sua primeira trava, não consegue a segunda, solta, tenta de novo no mesmo instante, e repete. Nenhuma está bloqueada, ambas trabalham sem parar, e nenhuma avança. Isso se chama livelock, um impasse em movimento, e a pausa de duração aleatória quebra a simetria, como duas pessoas num corredor que param de desviar para o mesmo lado ao mesmo tempo. O prazo total garante que, no pior caso, a transferência falha com uma exceção em vez de travar o programa.

A ReentrantLock tem ainda lockInterruptibly(), que espera pela trava mas responde a interrupções, e pode ser criada como justa (new ReentrantLock(true)), atendendo as threads na ordem de chegada, ao preço de bem menos desempenho. A regra de escolha é simples: use synchronized por padrão, porque é mais simples e impossível de esquecer aberto, e use ReentrantLock quando precisar de prazo, interrupção ou justiça.

Muitos leitores, um escritor

Muitos dados são lidos o tempo todo e alterados raramente, como uma tabela de preços ou uma configuração. Com synchronized, duas leituras simultâneas se excluem, mesmo que ler ao mesmo tempo seja perfeitamente seguro. A ReentrantReadWriteLock tem duas travas ligadas: a de leitura, que várias threads podem segurar ao mesmo tempo, e a de escrita, exclusiva, que espera todos os leitores saírem:

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;

static class Precos {
    private final Map<String, Integer> tabela = new HashMap<>();
    private final ReadWriteLock trava = new ReentrantReadWriteLock();

    Integer preco(String item) {
        trava.readLock().lock();             // vários leitores ao mesmo tempo
        try {
            dormir(100);                     // simula uma leitura demorada
            return tabela.get(item);
        } finally {
            trava.readLock().unlock();
        }
    }

    void reajustar(String item, int valor) {
        trava.writeLock().lock();            // escritor sozinho: espera os leitores saírem
        try {
            tabela.put(item, valor);
        } finally {
            trava.writeLock().unlock();
        }
    }
}

static void dormir(int ms) {
    try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}

void main() {
    Precos precos = new Precos();
    precos.reajustar("café", 12);
    long t0 = System.nanoTime();
    try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
        for (int i = 0; i < 4; i++) pool.submit(() -> precos.preco("café"));
    }
    IO.println("4 leituras de 100 ms em " + (System.nanoTime() - t0) / 1_000_000 + " ms");
}
4 leituras de 100 ms em 111 ms

As quatro leituras aconteceram ao mesmo tempo. Com synchronized, levariam 400 ms. A trava de leitura e escrita só compensa quando as leituras são muito mais frequentes que as escritas e demoram o suficiente para a disputa importar, porque ela é mais cara que uma trava simples. Existe ainda a StampedLock, do Java 8, com um modo de leitura otimista ainda mais rápido, mas de uso bem mais delicado. Para o caso típico de "muitas leituras, escritas raras", a alternativa mais simples costuma ser a imutabilidade de "O Cadeado e a Memória": um mapa imutável num campo volatile, substituído inteiro a cada reajuste.

ConcurrentHashMap e o perigo do verificar-e-agir

Um HashMap comum, alterado por duas threads ao mesmo tempo, pode perder dados e até corromper sua estrutura interna. A resposta antiga era Collections.synchronizedMap, que põe um synchronized em cada método e faz todas as threads disputarem uma única trava. A resposta moderna é o ConcurrentHashMap, que permite leituras sem trava e escritas simultâneas em partes diferentes do mapa. Mas trocar o mapa não basta, e o exemplo mostra por quê:

import java.util.*;
import java.util.concurrent.*;

static void contar(Map<String, Integer> mapa, boolean atomico) throws InterruptedException {
    String[] palavras = {"java", "jvm", "thread", "lock"};
    Runnable trabalho = () -> {
        for (int i = 0; i < 1_000_000; i++) {
            String p = palavras[i % palavras.length];
            if (atomico) {
                mapa.merge(p, 1, Integer::sum);           // ler, somar e gravar como uma operação
            } else {
                mapa.put(p, mapa.getOrDefault(p, 0) + 1); // verificar-e-agir: duas operações
            }
        }
    };
    Thread a = Thread.ofPlatform().start(trabalho), b = Thread.ofPlatform().start(trabalho);
    a.join(); b.join();
    int soma = mapa.values().stream().mapToInt(Integer::intValue).sum();
    IO.println(String.format("%-40s soma = %d  %s", mapa.getClass().getSimpleName()
            + (atomico ? " + merge" : " + get/put"), soma, new TreeMap<>(mapa)));
}

void main() throws InterruptedException {
    contar(new HashMap<>(), false);
    contar(new ConcurrentHashMap<>(), false);
    contar(new ConcurrentHashMap<>(), true);
}
HashMap + get/put                        soma = 1854055  {java=411138, jvm=475323, lock=471039, thread=496555}
ConcurrentHashMap + get/put              soma = 1834994  {java=451190, jvm=452667, lock=476274, thread=454863}
ConcurrentHashMap + merge                soma = 2000000  {java=500000, jvm=500000, lock=500000, thread=500000}

Dois milhões de contagens eram esperadas. O HashMap perdeu cerca de 150 mil, o que já era previsível. O detalhe importante é a segunda linha: o ConcurrentHashMap com getOrDefault seguido de put também perdeu contagens. Cada operação isolada do mapa é segura, mas a combinação não é: entre a leitura e a gravação, a outra thread pode gravar, e uma das duas gravações apaga a outra. É o mesmo problema do valor++, agora num nível acima. Esse padrão, verificar um estado e agir com base nele em duas operações separadas, se chama verificar-e-agir (check-then-act), e é a fonte mais comum de erros com coleções concorrentes.

A solução é pedir ao mapa que faça a combinação inteira como uma operação atômica. O merge, visto em "Comportamento como Valor", faz exatamente isso: se a chave não existe, grava o valor inicial; se existe, combina o valor atual com o novo usando a função. No ConcurrentHashMap, o merge é atômico, e a contagem fica perfeita. O mapa oferece uma família dessas operações: putIfAbsent, computeIfAbsent, computeIfPresent, compute e merge. Como no updateAndGet, as funções passadas a elas devem ser curtas e não devem alterar o próprio mapa, porque rodam enquanto o mapa segura uma trava interna daquela chave.

Para contagens muito disputadas, uma combinação comum é ConcurrentHashMap<String, LongAdder>, com mapa.computeIfAbsent(p, k -> new LongAdder()).increment(): o mapa cria o contador uma única vez por chave, e os incrementos acontecem no LongAdder, sem disputar o mapa.

O ConcurrentHashMap também muda o comportamento da iteração. Percorrer um HashMap enquanto outra thread o altera pode lançar ConcurrentModificationException. Os iteradores do ConcurrentHashMap são fracamente consistentes: nunca lançam essa exceção, refletem o estado do mapa em algum momento do percurso e podem ou não incluir alterações feitas durante ele. E ele não aceita null como chave nem como valor, porque, entre várias threads, não haveria como distinguir "a chave não existe" de "a chave existe com valor null".

Listas que copiam e filas que bloqueiam

Para listas, o pacote oferece a CopyOnWriteArrayList, que resolve um problema clássico:

import java.util.*;
import java.util.concurrent.*;

void main() {
    List<String> comum = new ArrayList<>(List.of("a", "b", "c"));
    try {
        for (String s : comum) if (s.equals("a")) comum.add("d");
    } catch (ConcurrentModificationException e) {
        IO.println("ArrayList: " + e.getClass().getSimpleName());
    }
    List<String> copia = new CopyOnWriteArrayList<>(List.of("a", "b", "c"));
    for (String s : copia) if (s.equals("a")) copia.add("d");  // itera sobre uma "foto" da lista
    IO.println("CopyOnWriteArrayList: " + copia);
}
ArrayList: ConcurrentModificationException
CopyOnWriteArrayList: [a, b, c, d]

Como o nome diz, cada alteração cria uma cópia inteira do array interno. Um iterador percorre a versão que existia quando foi criado, sem trava e sem exceção. Isso torna a escrita cara, proporcional ao tamanho da lista, e a leitura, gratuita. O caso de uso ideal é uma lista de ouvintes de eventos: registrada no início do programa, raramente alterada, percorrida a cada evento. Para listas que mudam com frequência, é uma péssima escolha.

A estrutura mais importante do pacote para coordenar threads, porém, é a BlockingQueue, uma fila em que put espera se ela estiver cheia e take espera se ela estiver vazia. Com ela, o padrão produtor-consumidor, em que umas threads produzem trabalho e outras o consomem, fica quase trivial:

import java.util.concurrent.*;

record Pedido(int numero, String item) {
    static final Pedido FIM = new Pedido(-1, "");   // "pílula de veneno": avisa que acabou
}

void main() throws Exception {
    BlockingQueue<Pedido> fila = new ArrayBlockingQueue<>(2);   // capacidade pequena de propósito
    int cozinheiros = 2;

    try (ExecutorService pool = Executors.newFixedThreadPool(cozinheiros + 1)) {
        // Produtor: o garçom anota pedidos
        pool.submit(() -> {
            String[] itens = {"pizza", "salada", "sopa", "lasanha", "risoto", "torta"};
            for (int i = 0; i < itens.length; i++) {
                fila.put(new Pedido(i + 1, itens[i]));    // bloqueia se a fila estiver cheia
                IO.println("garçom anotou #" + (i + 1) + " (fila: " + fila.size() + ")");
            }
            for (int c = 0; c < cozinheiros; c++) fila.put(Pedido.FIM);
            return null;
        });
        // Consumidores: os cozinheiros preparam
        for (int c = 1; c <= cozinheiros; c++) {
            String nome = "cozinheiro " + c;
            pool.submit(() -> {
                while (true) {
                    Pedido p = fila.take();               // bloqueia se a fila estiver vazia
                    if (p == Pedido.FIM) break;
                    Thread.sleep(150);
                    IO.println("  " + nome + " entregou #" + p.numero() + " " + p.item());
                }
                return null;
            });
        }
    }
    IO.println("cozinha fechada");
}
garçom anotou #1 (fila: 1)
garçom anotou #2 (fila: 1)
garçom anotou #3 (fila: 2)
garçom anotou #4 (fila: 2)
  cozinheiro 2 entregou #1 pizza
garçom anotou #5 (fila: 2)
  cozinheiro 1 entregou #2 salada
garçom anotou #6 (fila: 2)
  cozinheiro 2 entregou #3 sopa
  cozinheiro 1 entregou #4 lasanha
  cozinheiro 2 entregou #5 risoto
  cozinheiro 1 entregou #6 torta
cozinha fechada

O garçom anota os quatro primeiros pedidos rapidamente e então para: a fila está cheia, e o put só volta quando um cozinheiro retira um pedido. A fila limitada é a contrapressão de "Não Crie Threads, Entregue Tarefas" em sua forma mais pura, e ninguém escreveu uma única trava, um wait ou um notify. As lambdas terminam com return null para serem Callable, o que permite chamar put, take e sleep, que lançam InterruptedException, sem try.

Para avisar os consumidores de que o trabalho acabou, o produtor coloca na fila um objeto especial, a pílula de veneno (poison pill), uma para cada consumidor. Quando um cozinheiro a retira, sai do laço. A comparação é por identidade, com ==, porque interessa saber se é aquele objeto, e não um pedido qualquer que por acaso tenha os mesmos campos, a distinção de "Iguais ou o Mesmo?".

As implementações mais usadas são ArrayBlockingQueue, com capacidade fixa num array, LinkedBlockingQueue, opcionalmente limitada (e sem limite se você não passar a capacidade, o mesmo risco do newFixedThreadPool), PriorityBlockingQueue, que entrega pela ordem de um Comparator como em "Ordem e Critério", e DelayQueue, que só entrega cada elemento depois de um atraso. Um pool de threads, aliás, é exatamente isto: uma BlockingQueue de tarefas e um grupo de threads consumidoras.

Sincronizadores: esperar por eventos e limitar acessos

Os sincronizadores coordenam grupos de threads sem proteger nenhum dado específico. Os dois mais usados são o CountDownLatch e o Semaphore:

import java.util.concurrent.*;

void main() throws InterruptedException {
    // CountDownLatch: esperar N eventos
    CountDownLatch prontos = new CountDownLatch(3);
    // Semaphore: no máximo 2 ao mesmo tempo
    Semaphore conexoes = new Semaphore(2);
    try (ExecutorService pool = Executors.newFixedThreadPool(5)) {
        for (int i = 1; i <= 3; i++) {
            int n = i;
            pool.submit(() -> {
                IO.println("serviço " + n + " iniciado");
                prontos.countDown();
            });
        }
        prontos.await();
        IO.println("todos os serviços prontos; abrindo as portas");

        long t0 = System.nanoTime();
        for (int i = 1; i <= 5; i++) {
            int n = i;
            pool.submit(() -> {
                conexoes.acquire();
                try {
                    IO.println("consulta " + n + " usando uma conexão aos " + (System.nanoTime() - t0) / 1_000_000 + " ms");
                    Thread.sleep(200);
                } finally {
                    conexoes.release();
                }
                return null;
            });
        }
    }
}
serviço 1 iniciado
serviço 2 iniciado
serviço 3 iniciado
todos os serviços prontos; abrindo as portas
consulta 1 usando uma conexão aos 1 ms
consulta 2 usando uma conexão aos 3 ms
consulta 3 usando uma conexão aos 204 ms
consulta 4 usando uma conexão aos 204 ms
consulta 5 usando uma conexão aos 404 ms

O CountDownLatch é um contador que só desce: cada countDown() o diminui, e await() espera até ele chegar a zero. Serve para "espere até que estes N eventos tenham acontecido", como a inicialização de vários serviços. Ele não pode ser reiniciado; para um ponto de encontro que se repete a cada rodada, existe o CyclicBarrier.

O Semaphore guarda um número de licenças. O acquire() pega uma, esperando se nenhuma estiver livre, e o release() a devolve, sempre num finally. Com duas licenças, as cinco consultas passam de duas em duas, e os tempos mostram as três ondas. É a ferramenta para limitar o acesso a um recurso escasso, como conexões com um banco de dados ou chamadas a uma API com limite de uso, e ela volta em "Um Milhão de Threads", onde as threads são praticamente ilimitadas, mas os recursos que elas usam não.

ThreadLocal e o vazamento entre tarefas

Uma última ferramenta resolve o problema do compartilhamento pela raiz: não compartilhar. Um ThreadLocal<T> guarda um valor separado para cada thread: cada uma que chama set e get vê só a sua cópia. Frameworks web o usam há décadas para guardar o usuário da requisição atual, a transação do banco ou um objeto caro e não seguro entre threads, como o antigo SimpleDateFormat. Mas, com pools de threads, ele tem uma armadilha:

import java.util.concurrent.*;

static final ThreadLocal<String> usuarioAtual = new ThreadLocal<>();

static void atender(String usuario, boolean limpar) {
    if (usuario != null) usuarioAtual.set(usuario);
    try {
        IO.println(Thread.currentThread().getName() + " atende como: " + usuarioAtual.get());
    } finally {
        if (limpar) usuarioAtual.remove();
    }
}

void main() {
    try (ExecutorService pool = Executors.newSingleThreadExecutor()) {
        pool.submit(() -> atender("ana", false));   // esquece de limpar
        pool.submit(() -> atender(null, false));    // requisição anônima herda "ana"!
        pool.submit(() -> atender("bruno", true));
        pool.submit(() -> atender(null, true));
    }
}
pool-1-thread-1 atende como: ana
pool-1-thread-1 atende como: ana
pool-1-thread-1 atende como: bruno
pool-1-thread-1 atende como: null

A segunda requisição, anônima, foi atendida como Ana. A thread do pool sobrevive às tarefas, e o valor guardado nela também. Num servidor, isso pode significar um usuário vendo os dados de outro. A regra é a do exemplo: todo set precisa de um remove() num finally. Há ainda um vazamento de memória: o valor fica preso à thread enquanto ela viver, e num pool ela pode viver tanto quanto o programa. Esses dois problemas motivaram os valores com escopo (scoped values), finalizados no Java 25, que "Tarefas em Família" apresenta: valores que existem só durante uma chamada e desaparecem sozinhos ao fim dela.

Com esta aula, o pacote java.util.concurrent fica praticamente completo, com uma exceção importante. Os Futures do ExecutorService entregam resultados, mas só a quem espera por eles, bloqueando uma thread, e combinar vários resultados que dependem uns dos outros exige uma sequência de get()s.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um cache é implementado assim, com um ConcurrentHashMap: if (!cache.containsKey(chave)) cache.put(chave, calcularCaro(chave));. Oito threads pedem a mesma chave ao mesmo tempo. Quantas vezes calcularCaro pode ser executado? Corrija o código.

Ver resposta

✓ Resposta: Até oito vezes. É o padrão verificar-e-agir: todas as oito threads podem executar o containsKey antes que qualquer uma chegue ao put, todas veem que a chave não existe, e todas calculam. O mapa não se corrompe, porque cada operação isolada é segura, mas o cálculo caro é repetido, e se calcularCaro tivesse efeitos, como abrir uma conexão, eles aconteceriam oito vezes. Num teste com oito threads e um cálculo de 50 ms, a versão com containsKey/put executou o cálculo 8 vezes. A correção é cache.computeIfAbsent(chave, k -> calcularCaro(k));, que no ConcurrentHashMap é atômico: a primeira thread calcula, e as outras que pedem a mesma chave esperam o cálculo terminar e recebem o mesmo valor. No mesmo teste, o cálculo aconteceu 1 vez. O cuidado é que a função não deve ser muito demorada nem alterar o próprio mapa, porque enquanto ela roda a trava interna daquela parte do mapa fica ocupada.

Exercício 2

Explique por que o LongAdder foi mais rápido que o AtomicLong no teste do contador, e por que, mesmo assim, ele seria a escolha errada para gerar números de pedido únicos.

Ver resposta

✓ Resposta: O AtomicLong guarda um único valor. Com duas threads incrementando sem parar, as duas fazem CAS no mesmo endereço de memória, e a cada rodada uma delas perde e precisa repetir. Além disso, os núcleos disputam a mesma linha de cache, que precisa ser transferida de um para o outro a cada escrita. O LongAdder, ao detectar disputa, espalha os incrementos em várias células, e cada thread passa a escrever na sua, sem colisões. O custo é transferido para a leitura: sum() percorre todas as células e soma. Para gerar números únicos, porém, cada thread precisa receber um valor que nenhuma outra recebeu. O LongAdder não tem um método que incremente e devolva o novo valor numa só operação atômica; increment() seguido de sum() seria um verificar-e-agir, e duas threads poderiam ler a mesma soma. Além disso, sum() não é uma foto instantânea sob escrita concorrente. O AtomicLong.incrementAndGet() devolve exatamente o valor produzido por aquele incremento, único por construção.

Exercício 3

Implemente uma classe Sala com capacidade fixa e um método boolean reservar() que ocupa um assento e devolve true, ou devolve false se a sala estiver lotada, usando AtomicInteger e compareAndSet, sem nenhuma trava. Por que if (ocupados.get() < capacidade) ocupados.incrementAndGet(); não funciona?

Ver resposta

✓ Resposta:

static class Sala {
    private final int capacidade;
    private final AtomicInteger ocupados = new AtomicInteger();
    Sala(int capacidade) { this.capacidade = capacidade; }

    boolean reservar() {
        while (true) {
            int atual = ocupados.get();
            if (atual >= capacidade) return false;              // lotada: desiste
            if (ocupados.compareAndSet(atual, atual + 1)) return true;
            // outra thread mudou o valor entre o get e o CAS: tenta de novo
        }
    }
    int ocupados() { return ocupados.get(); }
}

Com capacidade 100 e mil tentativas de reserva vindas de oito threads, o resultado é aceitas: 100, ocupados: 100. A versão com get e incrementAndGet separados é outro verificar-e-agir: com 99 assentos ocupados, várias threads podem ler 99 ao mesmo tempo, todas passam no teste, e todas incrementam, deixando a sala com 101, 102 ou mais. No laço com CAS, a verificação e a gravação estão ligadas: o compareAndSet(atual, atual + 1) só tem sucesso se ninguém mudou o valor desde a leitura que passou no teste. Se alguém mudou, a thread relê o valor e refaz a verificação, e se a sala lotou no meio tempo, desiste. Não seria possível usar updateAndGet aqui de forma simples, porque o método precisa informar se a reserva aconteceu, e a função passada a ele não tem como devolver essa informação.

Exercício 4

Na transferência com tryLock, o que aconteceria se o Thread.sleep aleatório fosse removido do fim do laço? E se o prazo total fosse removido, deixando while (true)?

Ver resposta

✓ Resposta: Sem a pausa aleatória, o programa fica sujeito a livelock. As duas threads, transferindo em sentidos opostos, podem entrar num ciclo simétrico: cada uma obtém a primeira trava, espera 1 ms pela segunda, desiste, solta a primeira e tenta de novo imediatamente, no mesmo ritmo da outra. Nenhuma está bloqueada, e as duas consomem CPU, mas nenhuma completa a transferência. Na prática, as pequenas diferenças de tempo entre as threads costumam quebrar o ciclo sozinhas, mas depender disso é depender da sorte. A pausa de duração aleatória garante que, depois de uma colisão, uma delas tente antes da outra e consiga as duas travas. Sem o prazo total, o livelock ou uma trava presa por outra parte do programa fariam a transferência tentar para sempre, e o problema se manifestaria como um sistema lento, sem nenhum erro. Com o prazo, a falha vira uma exceção clara, que pode ser registrada e investigada. É o mesmo princípio dos prazos do Future.get: toda espera em código de produção deve ter um limite.

Exercício 5

Escolha a estrutura mais adequada para cada caso e justifique: (a) a lista de ouvintes de um sistema de eventos, registrados na inicialização e notificados milhares de vezes por segundo; (b) a passagem de tarefas de três threads que recebem pedidos para oito que os processam, sem deixar a memória crescer sem limite; (c) a contagem de acessos por página de um site, atualizada por centenas de threads; (d) um mapa de sessões, acessado por várias threads, que precisa ser percorrido em ordem de identificador.

Ver resposta

✓ Resposta: (a) CopyOnWriteArrayList: as escritas são raras e podem ser caras, e a notificação, que percorre a lista milhares de vezes por segundo, não usa trava nem arrisca ConcurrentModificationException, mesmo que um ouvinte se registre durante uma notificação. (b) Uma ArrayBlockingQueue com capacidade definida, ou uma LinkedBlockingQueue com capacidade passada no construtor: os três produtores usam put, que espera quando a fila está cheia, e os oito consumidores usam take. A capacidade limitada é o que impede a memória de crescer e freia os produtores. (c) ConcurrentHashMap<String, LongAdder>, com mapa.computeIfAbsent(pagina, k -> new LongAdder()).increment(): o mapa cria um contador por página uma única vez, de forma atômica, e os incrementos acontecem no LongAdder, que suporta bem a disputa de centenas de threads. Um ConcurrentHashMap<String, Integer> com merge também é correto, só menos eficiente sob disputa intensa. (d) ConcurrentSkipListMap, a versão concorrente e ordenada do TreeMap de "Ordem e Critério": mantém as chaves em ordem, permite acesso concorrente sem uma trava global e tem iteradores fracamente consistentes. Um Collections.synchronizedSortedMap também funcionaria, mas com uma única trava para tudo, e percorrê-lo exigiria segurar essa trava manualmente durante a iteração inteira.

Comentários

Mais em Java

Hierarquias Fechadas — Sealed Classes e Pattern Matching
Hierarquias Fechadas — Sealed Classes e Pattern Matching

Sealed classes e pattern matching em Java: permits, final, sealed e…

Agrupar, Achatar, Reduzir — Collectors e Gatherers
Agrupar, Achatar, Reduzir — Collectors e Gatherers

Como reunir os elementos de uma stream Java em mapas agrupados, partições e…

Quando o Contrato se Quebra — Exceções Verificadas e Não Verificadas
Quando o Contrato se Quebra — Exceções Verificadas e Não Verificadas

Como ler um stack trace, capturar exceções com try e catch, garantir a limpeza…