Blog

Threads e estado compartilhado em Java: condições de corrida, synchronized e atomics

Duas threads Java que atualizam a mesma variável podem perder escritas em silêncio. Veja por que count++ é uma condição de corrida, como synchronized, volatile, atomics e locks resolvem isso, e como forçar e detectar um deadlock.

Uma thread roda código ao lado do resto do seu programa. Threads que só mexem nos próprios dados são fáceis. O problema começa quando duas delas mudam a mesma variável: nada quebra, nada avisa você, e algumas das mudanças somem.

Este post inicia threads, mostra esse bug e força ele a acontecer em toda execução. Depois trata de synchronized, volatile, atomics, deadlock, ReentrantLock, coleções thread-safe e dados imutáveis. Todos os programas abaixo rodaram no Java 25, e a saída foi colada da execução. Para rodar um deles, salve como Main.java e rode java Main.java.

Iniciando uma thread: start, não run

Um objeto Thread não faz nada até você chamar start(), que pede à JVM uma thread nova e roda o seu código nela. Chamar run() parece parecido, mas só chama o método na thread em que você já está:

void main() throws InterruptedException {
    Thread mainThread = Thread.currentThread();
    boolean[] onMain = new boolean[2];

    Thread first = new Thread(() -> onMain[0] = Thread.currentThread() == mainThread);
    first.run();

    Thread second = new Thread(() -> onMain[1] = Thread.currentThread() == mainThread);
    second.start();
    second.join();

    IO.println("run()   ran the task on the main thread: " + onMain[0]);
    IO.println("start() ran the task on the main thread: " + onMain[1]);
    IO.println("state of the thread we called run() on: " + first.getState());
}

Ele imprime:

run()   ran the task on the main thread: true
start() ran the task on the main thread: false
state of the thread we called run() on: NEW

first.run() rodou a lambda na thread principal, e first nunca chegou a virar uma thread. O estado dela continua NEW. Chamar run() por engano compila, funciona e, sem fazer barulho, não entrega concorrência nenhuma.

second.join() faz a thread principal esperar até second terminar. Sem isso, main poderia ler onMain[1] antes de a outra thread escrever nele.

Uma thread daemon é uma thread pela qual a JVM não espera: quando só sobram threads daemon, o programa termina. O exemplo de deadlock mais abaixo usa isso.

A maior parte do código não cria threads à mão. A parte sobre java.util.concurrent trata de executors, e a parte sobre virtual threads trata das threads baratas que o Java 21 trouxe. Tudo o que este post diz sobre estado compartilhado vale para as duas.

Threads com os próprios dados não precisam de lock

O builder Thread.ofPlatform().start(...), final desde o Java 21, cria e inicia uma thread numa chamada só. Aqui, quatro threads escrevem cada uma na sua própria posição do array:

void main() throws InterruptedException {
    String[] words = {"apple", "banana", "cherry", "date"};
    int[] lengths = new int[words.length];

    var threads = new ArrayList<Thread>();
    for (int i = 0; i < words.length; i++) {
        int slot = i;
        threads.add(Thread.ofPlatform().start(() -> lengths[slot] = words[slot].length()));
    }
    for (Thread t : threads) {
        t.join();
    }

    IO.println(Arrays.toString(lengths));
}

Ele imprime:

[5, 6, 6, 4]

Nenhuma thread escreve na posição de outra, então não há nada a coordenar, e esperar com join todas as threads antes de ler deixa o resultado confiável.

Uma atualização perdida que você não consegue reproduzir

Uma atualização perdida acontece quando duas threads mudam a mesma variável e uma mudança sobrescreve a outra. Aqui, quatro threads somam 1 a um count compartilhado cem mil vezes cada:

int count = 0;

void main() throws InterruptedException {
    var threads = new ArrayList<Thread>();
    for (int i = 0; i < 4; i++) {
        threads.add(Thread.ofPlatform().start(() -> {
            for (int n = 0; n < 100_000; n++) {
                count++;
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }
    IO.println("count = " + count);
}

Você esperaria 400000. Rodamos cinco vezes seguidas, e os números mudam a cada execução, então esta saída é um exemplo, não algo que você vai reproduzir igual:

$ for i in 1 2 3 4 5; do java Main.java; done
count = 135423
count = 149972
count = 120394
count = 165135
count = 199862

Mais da metade dos incrementos sumiu. Em outra máquina, ou numa execução de sorte, talvez nenhum se perca, então um teste que passa não prova nada.

O Go tem um race detector que aponta esse tipo de código. O Java não traz um. Então, em vez de torcer para o bug aparecer, o próximo programa faz ele acontecer toda vez.

Forçando a atualização perdida

count++ não é um passo só. Ele lê count, soma 1 e grava o resultado de volta. Um CountDownLatch deixa a gente segurar as duas threads entre a leitura e a escrita:

int count = 5;

void main() throws InterruptedException {
    var bothHaveRead = new CountDownLatch(2);

    Runnable increment = () -> {
        int seen = count;            // 1. read
        bothHaveRead.countDown();
        awaitQuietly(bothHaveRead);  // wait until the other thread has read too
        count = seen + 1;            // 2. add one and write
    };

    Thread t1 = Thread.ofPlatform().start(increment);
    Thread t2 = Thread.ofPlatform().start(increment);
    t1.join();
    t2.join();

    IO.println("two increments ran, starting from 5");
    IO.println("count = " + count);
}

void awaitQuietly(CountDownLatch latch) {
    try {
        latch.await();
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

Ele imprime:

two increments ran, starting from 5
count = 6

Um CountDownLatch(2) começa em 2. countDown() diminui o valor, e await() bloqueia até ele chegar a 0. Cada thread lê 5, faz a contagem regressiva e depois espera a outra thread fazer a dela também. Então as duas threads sempre têm 5 em mãos antes de qualquer uma gravar, e as duas gravam 6. Rodamos 20 vezes e deu 6 em todas.

Para ser honesto, o latch exagera. Sem ele, o scheduler só às vezes para uma thread entre a leitura e a escrita. Em 400.000 incrementos, “às vezes” é com frequência, como as cinco execuções acima mostraram. O latch escolhe o momento ruim de propósito para você ver isso toda vez.

Vendo a atualização perdida

Duas threads rodam count++ uma vez cada sobre um count compartilhado que começa em 5:

T1 count T2 fará count++ leu 5 gravou 6 fará count++ leu 5 gravou 6 5 6 seen = 5 seen = 5 6 6 count é 6, não 7: incremento perdido count é 5, e T1 e T2 rodam count++ uma vez cada T1 lê count e guarda 5 na sua própria variável antes de T1 gravar, T2 também lê count e guarda 5 T1 soma 1 ao seu 5 e grava 6 T2 soma 1 ao seu 5 e grava 6 sobre o 6 de T1 dois incrementos rodaram, mas count só subiu para 6

Duas threads rodam count++ uma vez cada sobre um count compartilhado de 5. As duas leem 5 antes de qualquer uma gravar, então as duas gravam 6, e um incremento se perde. O latch do programa acima força essa ordem; sem ele, o scheduler escolhe essa ordem só parte das vezes.

Aqui estão esses passos em palavras, caso a animação não rode para você:

  1. count é 5. T1 e T2 rodam count++ uma vez cada.
  2. T1 lê count e guarda 5 na sua própria cópia local.
  3. Antes de T1 gravar qualquer coisa, T2 também lê count e guarda 5.
  4. T1 soma 1 ao seu 5 e grava 6.
  5. T2 soma 1 ao 5 dela e grava 6 também, por cima do 6 de T1.
  6. Dois incrementos rodaram, mas count foi de 5 para 6. Um incremento se perdeu.

Explicado como se você tivesse dez anos

Duas crianças marcam os pontos num único placar. Cada vez que o time delas marca, uma criança lê o placar, soma 1 de cabeça e escreve o número novo.

Os dois times marcam ao mesmo tempo. A criança A lê 5 e pensa “6”. A criança B também lê 5 e pensa “6”. A criança A escreve 6. A criança B apaga e escreve 6. Foram marcados dois pontos, e o placar subiu um. Um ponto sumiu, e o placar parece perfeitamente normal.

synchronized é um único pincel atômico. Só a criança que está com o pincel pode ler o placar e escrever nele. A outra criança espera o pincel, depois lê 6 e escreve 7.

A versão precisa

count++ num campo compila para três passos de bytecode: ler o campo, somar 1, gravar o campo. Outra thread pode rodar entre quaisquer dois deles. Quando duas sequências de ler-modificar-gravar se sobrepõem, a segunda gravação sobrescreve a primeira, e ela se baseia numa leitura desatualizada.

O Java não promete nada sobre como as threads se intercalam, então um programa como esse não tem resposta fixa. O compilador JIT também pode aumentar essa janela. Ele pode manter count num registrador da CPU por uma série de iterações e gravar o valor de volta só depois, e esse é um jeito de uma execução perder muito mais incrementos do que você imaginaria.

Onde a analogia falha: as crianças veem uma à outra indo até o placar. Threads não veem. count++ nunca procura outra thread, e ele só espera se você adicionar um lock. E o pincel precisa ser o mesmo pincel para as duas crianças: duas threads segurando dois locks diferentes não esperam uma pela outra.

synchronized: uma thread de cada vez

Todo objeto Java tem um lock embutido, chamado monitor. Um método synchronized pega o monitor de this antes de rodar e o libera quando retorna, mesmo que seja por uma exceção. Uma segunda thread que chama esse método no mesmo objeto espera.

Aqui está de novo o programa forçado, com increment marcado como synchronized. Agora o latch espera no máximo 300 milissegundos, porque a outra thread não consegue entrar para fazer a contagem regressiva:

int count = 5;
int timedOut = 0;

synchronized void increment(CountDownLatch bothHaveRead) {
    int seen = count;
    bothHaveRead.countDown();
    if (!awaitBriefly(bothHaveRead)) {
        timedOut++;  // the other thread never got in to read
    }
    count = seen + 1;
}

void main() throws InterruptedException {
    var bothHaveRead = new CountDownLatch(2);

    Thread t1 = Thread.ofPlatform().start(() -> increment(bothHaveRead));
    Thread t2 = Thread.ofPlatform().start(() -> increment(bothHaveRead));
    t1.join();
    t2.join();

    IO.println("count = " + count);
    IO.println("waits that gave up: " + timedOut);
}

boolean awaitBriefly(CountDownLatch latch) {
    try {
        return latch.await(300, TimeUnit.MILLISECONDS);
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

Ele imprime:

count = 7
waits that gave up: 1

A thread que pegar o monitor primeiro lê 5 e faz a contagem regressiva. Depois espera uma segunda leitura que não pode acontecer, porque a outra thread está bloqueada do lado de fora de increment. Depois de 300 ms ela desiste e grava 6. Só então a segunda thread pode entrar. Ela lê 6, encontra o latch já em 0 e grava 7. A intercalação ruim agora é impossível, não só improvável.

Com um latch.await() simples, sem timeout, esse programa travaria para sempre. A primeira thread seguraria o lock enquanto esperava a segunda. Isso é um deadlock, e ele tem uma seção só dele mais abaixo.

Um método static synchronized trava o objeto Class da classe em vez de this. O monitor também é reentrante: uma thread que o segura pode chamar outro método synchronized no mesmo objeto sem bloquear a si mesma.

Escolhendo o objeto de lock

Um bloco synchronized diz de qual objeto ele pega o monitor. A escolha comum é um campo private final que existe só para ser travado:

class Counter {
    private final Object lock = new Object();
    private int count;

    void increment() {
        synchronized (lock) {
            count++;
        }
    }

    int get() {
        synchronized (lock) {
            return count;
        }
    }
}

void main() throws InterruptedException {
    var counter = new Counter();
    var threads = new ArrayList<Thread>();
    for (int i = 0; i < 4; i++) {
        threads.add(Thread.ofPlatform().start(() -> {
            for (int n = 0; n < 100_000; n++) {
                counter.increment();
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }
    IO.println("count = " + counter.get());
}

Ele imprime:

count = 400000

Esse é o programa instável de antes, corrigido. Rodamos 20 vezes e deu 400000 em todas.

Três motivos para preferir um lock privado a métodos synchronized:

  • Ninguém mais consegue pegá-lo. Qualquer código que tenha um Counter pode escrever synchronized (counter) e bloquear os seus métodos. Nenhum código de fora alcança lock.
  • O bloco pode ser pequeno. Trave só as linhas que mexem no estado compartilhado, e faça o trabalho lento fora.
  • Leituras também precisam do lock. get() também trava, então nunca vê uma atualização pela metade e sempre vê a escrita mais recente.

Não trave num objeto que muda. synchronized (count) sobre um campo Integer trava um objeto diferente depois de cada count++, porque o boxing cria um Integer novo. O javac pega isso: warning: [identity] attempt to synchronize on an instance of a value-based class. Esse é o nome da categoria no Java 25, e -Werror transforma o aviso num build que falha.

volatile: vendo as escritas de outra thread

Visibilidade é o segundo problema do estado compartilhado. Uma thread escreve num campo, e outra thread pode continuar vendo o valor antigo. Um campo volatile resolve isso: toda leitura vê a escrita mais recente. O uso clássico é uma flag de parada:

volatile boolean running = true;

void main() throws InterruptedException {
    var started = new CountDownLatch(1);
    long[] loops = new long[1];

    Thread worker = Thread.ofPlatform().start(() -> {
        started.countDown();
        while (running) {
            loops[0]++;
        }
    });

    started.await();
    running = false;
    worker.join();
    IO.println("worker stopped: " + !worker.isAlive());
}

Ele imprime:

worker stopped: true

A thread worker gira até running ficar false, e join retorna assim que ela percebe.

Aqui vem o que nos surpreendeu. Tiramos o volatile, fizemos main dormir meio segundo antes de limpar a flag e rodamos cinco vezes com um timeout de cinco segundos. A thread worker nunca parou, nem uma vez. O motivo provável é o JIT: nada dentro do laço grava running, então o laço compilado pode parar de ler o campo. Sem volatile ou um lock, isso é permitido.

Mas volatile não deixa count++ seguro. Um volatile int count faz cada leitura ver o valor mais recente, mas ler, somar e gravar continuam sendo três passos. Duas threads podem ler 5 e gravar 6, exatamente como na animação. volatile resolve a visibilidade, não a atomicidade. Use para uma flag que uma thread define e outras leem.

Atomics: compare-and-set

java.util.concurrent.atomic tem classes cujas atualizações são passos únicos e indivisíveis. Elas se apoiam em compare-and-set: “defina o valor como 6, mas só se ele ainda for 5”. Se outra thread mudou o valor no meio do caminho, a chamada falha e retorna false, e você tenta de novo. Aqui está a intercalação forçada mais uma vez, com um AtomicInteger:

void main() throws InterruptedException {
    var count = new AtomicInteger(5);
    var retries = new AtomicInteger();
    var bothHaveRead = new CountDownLatch(2);

    Runnable increment = () -> {
        int seen = count.get();
        bothHaveRead.countDown();
        awaitQuietly(bothHaveRead);
        while (!count.compareAndSet(seen, seen + 1)) {
            retries.incrementAndGet();  // someone changed it: read again
            seen = count.get();
        }
    };

    Thread t1 = Thread.ofPlatform().start(increment);
    Thread t2 = Thread.ofPlatform().start(increment);
    t1.join();
    t2.join();

    IO.println("count = " + count.get());
    IO.println("failed compare-and-sets: " + retries.get());
}

void awaitQuietly(CountDownLatch latch) {
    try {
        latch.await();
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

Ele imprime:

count = 7
failed compare-and-sets: 1

As duas threads ainda leem 5. Uma ganha o compareAndSet(5, 6). O compareAndSet(5, 6) da outra falha, porque agora o valor é 6. Ela lê 6 de novo e define 7. Nada se perde, e ninguém esperou por um lock.

Você raramente escreve esse laço. incrementAndGet() faz exatamente isso por dentro, e updateAndGet(x -> x * 2) faz o mesmo para qualquer função:

void main() throws InterruptedException {
    var count = new AtomicInteger();
    var total = new LongAdder();

    var threads = new ArrayList<Thread>();
    for (int i = 0; i < 4; i++) {
        threads.add(Thread.ofPlatform().start(() -> {
            for (int n = 0; n < 100_000; n++) {
                count.incrementAndGet();
                total.increment();
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }

    IO.println("AtomicInteger: " + count.get());
    IO.println("LongAdder:     " + total.sum());
}

Ele imprime:

AtomicInteger: 400000
LongAdder:     400000

LongAdder serve para contadores que muitas threads incrementam o tempo todo. Quando muitas threads disputam um único AtomicInteger, os compare-and-sets vivem falhando e tentando de novo. Um LongAdder dá às threads células separadas para somar e junta as células quando você chama sum(). Os incrementos ficam mais baratos e as leituras ficam um pouco mais caras. E sum() não é um retrato fiel enquanto as threads ainda estão somando, então leia depois que elas terminarem, ou quando um valor aproximado bastar.

Atomics protegem um valor. Quando dois campos precisam mudar juntos, como um saldo e um contador de transações, use um lock.

Deadlock: dois locks em ordem inversa

Um deadlock acontece quando duas threads seguram, cada uma, um lock de que a outra precisa, e as duas esperam para sempre. A causa comum são dois locks pegos em ordens inversas. Este programa força isso com um latch, detecta o deadlock e termina:

import java.lang.management.ManagementFactory;

final Object accountA = new Object();
final Object accountB = new Object();

void main() throws InterruptedException {
    var bothHoldOne = new CountDownLatch(2);

    Thread t1 = Thread.ofPlatform().daemon().start(() -> {
        synchronized (accountA) {
            bothHoldOne.countDown();
            awaitQuietly(bothHoldOne);
            synchronized (accountB) {
                IO.println("t1 moved money from A to B");
            }
        }
    });
    Thread t2 = Thread.ofPlatform().daemon().start(() -> {
        synchronized (accountB) {
            bothHoldOne.countDown();
            awaitQuietly(bothHoldOne);
            synchronized (accountA) {
                IO.println("t2 moved money from B to A");
            }
        }
    });

    var threadBean = ManagementFactory.getThreadMXBean();
    long[] stuck = threadBean.findDeadlockedThreads();
    while (stuck == null) {
        Thread.sleep(10);
        stuck = threadBean.findDeadlockedThreads();
    }
    IO.println("deadlock detected: " + stuck.length + " threads");
    IO.println("t1 state: " + t1.getState() + ", t2 state: " + t2.getState());
}

void awaitQuietly(CountDownLatch latch) {
    try {
        latch.await();
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

Ele imprime:

deadlock detected: 2 threads
t1 state: BLOCKED, t2 state: BLOCKED

t1 segura A e espera B. t2 segura B e espera A. Nenhuma das mensagens de transferência chega a ser impressa. findDeadlockedThreads() pede à JVM as threads presas num ciclo assim, e retorna null enquanto não há nenhuma. O laço consulta até o ciclo se formar.

Uma thread bloqueada em synchronized não pode ser interrompida, então nada destrava essas duas. Elas são threads daemon, então a JVM termina mesmo assim quando main retorna. Rodamos 20 vezes: ele imprimiu as mesmas duas linhas em todas e terminou em cerca de um segundo e meio.

Num servidor de verdade, você veria isso de fora. jcmd <pid> Thread.print despeja todas as threads, e para este programa a saída incluiu Found one Java-level deadlock:, seguido de qual thread segura qual monitor.

A correção é uma regra: toda thread pega os locks na mesma ordem.

final Object accountA = new Object();
final Object accountB = new Object();
int transfers = 0;

void transfer() {
    synchronized (accountA) {      // always A first
        synchronized (accountB) {  // then B
            transfers++;
        }
    }
}

void main() throws InterruptedException {
    Runnable work = () -> {
        for (int n = 0; n < 50_000; n++) {
            transfer();
        }
    };
    Thread t1 = Thread.ofPlatform().start(work);
    Thread t2 = Thread.ofPlatform().start(work);
    t1.join();
    t2.join();
    IO.println("transfers = " + transfers);
}

Ele imprime:

transfers = 100000

Não pode existir uma thread que segura B enquanto espera A, então o ciclo não se forma. Com contas de verdade, escolha a ordem a partir de algo estável, como o id da conta: trave primeiro o id menor.

ReentrantLock: um lock do qual você pode desistir

java.util.concurrent.locks.ReentrantLock faz o que synchronized faz, com chamadas explícitas a lock() e unlock(). O unlock sempre vai num finally, para que uma exceção não deixe o lock preso:

class Counter {
    private final ReentrantLock lock = new ReentrantLock();
    private int count;

    void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    int get() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

void main() throws InterruptedException {
    var counter = new Counter();
    Runnable work = () -> {
        for (int n = 0; n < 100_000; n++) {
            counter.increment();
        }
    };
    Thread t1 = Thread.ofPlatform().start(work);
    Thread t2 = Thread.ofPlatform().start(work);
    t1.join();
    t2.join();
    IO.println("count = " + counter.get());
}

Ele imprime:

count = 200000

É mais código que synchronized para o mesmo resultado. ReentrantLock se justifica quando você precisa de algo que synchronized não faz, e o principal é desistir. tryLock com timeout espera um tempo limitado e retorna false se o lock nunca ficou livre:

void main() throws InterruptedException {
    var lock = new ReentrantLock();
    boolean[] gotIt = new boolean[1];

    lock.lock();  // main holds the lock for the whole test
    try {
        Thread other = Thread.ofPlatform().start(() -> {
            try {
                gotIt[0] = lock.tryLock(100, TimeUnit.MILLISECONDS);
                if (gotIt[0]) {
                    lock.unlock();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        other.join();
    } finally {
        lock.unlock();
    }
    IO.println("other thread got the lock: " + gotIt[0]);
}

Ele imprime:

other thread got the lock: false

No programa de deadlock, tryLock no segundo lock deixaria uma thread recuar, liberar o primeiro lock e tentar de novo, em vez de esperar para sempre. lockInterruptibly() dá a você uma espera que interrupt() consegue encerrar, algo que synchronized nunca permite.

Um ReadWriteLock, geralmente ReentrantReadWriteLock, tem dois lados. Qualquer número de threads pode segurar o lock de leitura ao mesmo tempo, mas o lock de escrita é exclusivo e espera todos os leitores saírem. Ele ajuda quando as leituras são muito mais numerosas que as escritas e cada leitura demora um pouco. Para seções críticas curtas, muitas vezes não é mais rápido, então comece com um lock simples.

Coleções thread-safe e check-then-act

Uma coleção thread-safe deixa cada chamada isolada segura, mas não uma sequência de chamadas. Collections.synchronizedList envolve uma lista para que todo método pegue um único lock:

void main() throws InterruptedException {
    List<Integer> numbers = Collections.synchronizedList(new ArrayList<>());

    var threads = new ArrayList<Thread>();
    for (int i = 0; i < 4; i++) {
        threads.add(Thread.ofPlatform().start(() -> {
            for (int n = 0; n < 1_000; n++) {
                numbers.add(n);
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }

    long sum = 0;
    synchronized (numbers) {  // iterating needs the list's own lock
        for (int n : numbers) {
            sum += n;
        }
    }
    IO.println("size = " + numbers.size() + ", sum = " + sum);
}

Ele imprime:

size = 4000, sum = 1998000

Cada add é seguro. Mas o laço não é uma chamada só: são muitas chamadas a next(), então você mesmo precisa segurar o lock da lista, como a documentação diz. Trocamos por um ArrayList comum e rodamos 30 vezes. Em 29 execuções o tamanho ficou abaixo de 4000, e duas delas também lançaram ArrayIndexOutOfBoundsException dentro de uma thread.

ConcurrentHashMap vai além. Leituras não bloqueiam, quem escreve trava só uma parte pequena do map, e percorrer o map nunca lança ConcurrentModificationException. Mesmo assim, ele não protege código que verifica e depois age em duas chamadas:

void main() throws InterruptedException {
    var sessions = new ConcurrentHashMap<String, String>();
    var created = new AtomicInteger();
    var bothHaveChecked = new CountDownLatch(2);

    Runnable login = () -> {
        if (!sessions.containsKey("ana")) {          // check
            bothHaveChecked.countDown();
            awaitQuietly(bothHaveChecked);
            int n = created.incrementAndGet();
            sessions.put("ana", "session-" + n);     // then act
        }
    };

    Thread t1 = Thread.ofPlatform().start(login);
    Thread t2 = Thread.ofPlatform().start(login);
    t1.join();
    t2.join();

    IO.println("sessions created for ana: " + created.get());
    IO.println("entries in the map: " + sessions.size());
}

void awaitQuietly(CountDownLatch latch) {
    try {
        latch.await();
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

Ele imprime:

sessions created for ana: 2
entries in the map: 1

O latch segura as duas threads entre a verificação e o put, então as duas não veem sessão nenhuma e as duas criam uma. O map tem uma entrada, mas duas sessões foram criadas, e o segundo put substituiu o primeiro em silêncio. É a mesma atualização perdida de count++, um nível acima.

A correção é entregar a decisão inteira ao map, numa chamada só:

void main() throws InterruptedException {
    var sessions = new ConcurrentHashMap<String, String>();
    var created = new AtomicInteger();
    var wordCounts = new ConcurrentHashMap<String, Integer>();
    var words = List.of("red", "blue", "red", "green", "red", "blue");

    var threads = new ArrayList<Thread>();
    for (int i = 0; i < 8; i++) {
        threads.add(Thread.ofPlatform().start(() -> {
            sessions.computeIfAbsent("ana", user -> "session-" + created.incrementAndGet());
            for (String w : words) {
                wordCounts.merge(w, 1, Integer::sum);
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }

    IO.println("sessions created for ana: " + created.get());
    IO.println("word counts: " + new TreeMap<>(wordCounts));
}

Ele imprime:

sessions created for ana: 1
word counts: {blue=16, green=8, red=24}

Oito threads pediram a sessão da Ana, e exatamente uma foi criada. ConcurrentHashMap.computeIfAbsent roda a função no máximo uma vez por chave, e outra thread que atualiza essa chave pode ter que esperar enquanto ela roda. merge(w, 1, Integer::sum) é o “some um, ou comece em um” atômico. O programa copia o map para um TreeMap antes de imprimir, para as chaves saírem ordenadas.

Tentamos forçar o momento ruim aqui também, com um latch dentro da função e um timeout. Em cinco execuções, a segunda thread nunca conseguiu entrar. Ela esperou e depois encontrou a sessão da Ana já lá. Como outras threads podem esperar por ela, mantenha essa função curta e não atualize o mesmo map de dentro dela.

Dados imutáveis não precisam de lock

A thread safety mais fácil é a de dados que não podem mudar. Se nada grava, não há corrida para perder. Um record com um List.copyOf no construtor compacto pode ser entregue com segurança a qualquer número de threads:

record Order(String customer, List<String> items) {
    Order {
        items = List.copyOf(items);
    }
}

void main() throws InterruptedException {
    var items = new ArrayList<>(List.of("tea", "cake"));
    var order = new Order("ana", items);
    items.add("soup");  // changes our list, not the order's copy

    int[] sizes = new int[4];
    var threads = new ArrayList<Thread>();
    for (int i = 0; i < sizes.length; i++) {
        int slot = i;
        threads.add(Thread.ofPlatform().start(() -> sizes[slot] = order.items().size()));
    }
    for (Thread t : threads) {
        t.join();
    }
    IO.println("every thread saw: " + Arrays.toString(sizes));
    IO.println(order);
}

Ele imprime:

every thread saw: [2, 2, 2, 2]
Order[customer=ana, items=[tea, cake]]

Os campos do record são final, e List.copyOf deu a ele uma lista não modificável só dele. O add que quem chamou fez depois não chega até ela. A parte sobre records explica por que a cópia importa. Para “mudar” um valor imutável, construa um novo e publique-o por um único AtomicReference ou campo volatile. Aí o único estado compartilhado que sobra é uma referência.

O que lembrar

  • start() roda código numa thread nova, e run() só chama o código na sua. join() espera uma thread terminar antes de você ler os resultados dela.
  • count++ é ler, somar, gravar. Duas threads podem intercalar esses passos e perder uma atualização, e o Java não tem race detector para pegar isso.
  • synchronized deixa uma thread de cada vez segurar o monitor de um objeto. Trave num objeto private final, e trave as leituras além das escritas.
  • volatile torna uma escrita visível para outras threads. Ele não torna count++ atômico.
  • AtomicInteger atualiza um valor com compare-and-set. Use LongAdder para contadores muito disputados, e um lock quando vários valores precisam mudar juntos.
  • Deadlock vem de pegar locks em ordens diferentes. Pegue sempre numa única ordem, e use tryLock com timeout quando precisar de uma saída.
  • Uma coleção thread-safe deixa chamadas isoladas seguras. Check-then-act ainda gera corrida, então use computeIfAbsent e merge, e prefira dados imutáveis quando puder.

Estado mutável compartilhado precisa de uma regra sobre quem pode mexer nele, e sem essa regra quem decide é o scheduler.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.