09 / 11 Curso de Java do zero | fundamentos e linguagem · Aula escrita · atualizado em 13 out 2026

Por que o programa funciona sozinho e erra quando duas tarefas chegam juntas?

Duas reservas chegam quase ao mesmo tempo para a última unidade de um produto. Cada tarefa consulta “há uma disponível”, ambas recebem sim e as duas confirmam. A condição parecia correta quando você executava uma chamada por vez. O problema está em como essas operações se intercalam.

Fernanda Kipper
Fernanda Kipper
Próxima aula →

Duas reservas chegam quase ao mesmo tempo para a última unidade de um produto. Cada tarefa consulta “há uma disponível”, ambas recebem sim e as duas confirmam. A condição parecia correta quando você executava uma chamada por vez. O problema está em como essas operações se intercalam.

Para investigar, precisamos distinguir memória, execução e coordenação. Você já deve conhecer objetos, exceções e testes. Usaremos Java 21; os exemplos não usam recursos preview. Ao final, você conseguirá explicar uma atualização perdida, escolher uma forma de coordenação e reconhecer quando o coletor de lixo não resolve uma retenção indevida.

O programa tem memória e caminhos de execução

Uma thread é um caminho de execução dentro de um processo. Cada thread tem sua pilha de chamadas, com frames relacionados aos métodos em execução. O heap é a região lógica em que ficam objetos e arrays compartilháveis pelas threads. Essas são abstrações da JVM; otimizações podem mudar detalhes da implementação física.

Uma variável local pode guardar uma referência a um objeto. A referência local estar em uma chamada não significa que o objeto só é acessível por ela: outra thread pode possuir outra referência para o mesmo objeto. O risco depende do compartilhamento e das operações, não apenas do lugar em que a variável foi declarada.

Concorrência significa que atividades progridem em períodos sobrepostos, possivelmente alternando uso de recursos. Paralelismo significa execução simultânea. Um programa pode ter concorrência mesmo em um único núcleo; adicionar threads não garante ganho de velocidade.

O coletor procura objetos que ainda podem ser alcançados

O garbage collector, ou GC, recupera memória de objetos que deixaram de ser alcançáveis a partir das raízes consideradas pela JVM. Uma variável sair do escopo pode remover um caminho, mas outro caminho pode continuar existindo.

Imagine uma lista estática que guarda todos os relatórios já criados. Mesmo que ninguém volte a consultar os relatórios antigos, a lista ainda os referencia. Para o GC, eles continuam alcançáveis. Isso é um vazamento lógico: a aplicação reteve algo de que já não precisava.

Limpar uma referência com null não força coleta, e System.gc() não garante uma coleta imediata com determinado resultado. Também não use GC para fechar arquivo ou conexão: recursos externos precisam de fechamento explícito, geralmente por try-with-resources.

A heap crescer entre coletas não prova vazamento. Compare uso após ciclos de coleta, volume de dados legitimamente retidos, taxa de criação e pausas. Logs de GC, gravações do JFR e análise de heap ajudam a investigar hipóteses; aumentar memória sem entender retenção pode apenas adiar a falha.

Incrementar não é uma operação indivisível

Considere contador++. Conceitualmente, a thread lê o valor, soma um e grava o resultado. Se duas threads leem 7 antes de qualquer gravação, as duas podem gravar 8. Esperávamos 9, mas uma atualização foi perdida.

Uma race condition ocorre quando a correção depende de uma ordem de execução que não foi coordenada. Repetir um teste dez vezes sem falhar não prova ausência desse problema. O escalonamento pode mudar com carga, máquina e instrumentação.

volatile oferece garantias de visibilidade e ordenação para acesso ao campo, mas não torna a sequência ler-somar-gravar uma única operação atômica. Atômica significa observável como uma operação indivisível no contrato relevante. Para um contador isolado, podemos usar AtomicInteger.

Um exemplo completo de coordenação

Salve em Contagem.java:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;

public class Contagem {
    public static void main(String[] args) throws Exception {
        AtomicInteger contador = new AtomicInteger();
        List<Future<?>> tarefas = new ArrayList<>();

        try (var executor = Executors.newFixedThreadPool(2)) {
            for (int i = 0; i < 100; i++) {
                tarefas.add(executor.submit(() -> {
                    contador.incrementAndGet();
                }));
            }
            for (Future<?> tarefa : tarefas) {
                tarefa.get();
            }
        }
        System.out.println(contador.get());
    }
}

O resultado esperado é 100. O executor recebe tarefas e administra threads. Usamos duas threads para processar cem tarefas; tarefa e thread não precisam corresponder uma a uma.

submit devolve um Future, que representa o resultado futuro da tarefa. get() aguarda e permite perceber uma falha ocorrida na execução. Sem observar resultados, uma tarefa enviada pode falhar e a lógica principal continuar sem notar. O try-with-resources fecha o executor ao sair; esse uso de ExecutorService como recurso está disponível no Java 21.

A lista de Futures é manipulada pela thread principal. As tarefas compartilham o contador, cuja operação de incremento é atômica. Não transformamos tudo em estrutura concorrente sem necessidade.

O throws Exception simplifica a demonstração no main. Em uma aplicação, trate falhas de execução e interrupção de acordo com a responsabilidade do chamador; não esconda cancelamentos como resultados válidos.

Um contador atômico não resolve toda regra composta

Reservar estoque exige verificar disponibilidade e reduzir quantidade como uma decisão. Ter um campo atômico não torna automaticamente atômicos dois comandos separados.

Para um único objeto dentro de uma JVM, uma solução simples é proteger a operação com o mesmo monitor:

synchronized boolean reservar() {
    if (disponivel == 0) {
        return false;
    }
    disponivel--;
    return true;
}

Esse é um trecho de uma classe com campo inteiro disponivel, inicialmente não negativo. synchronized permite que uma thread por vez execute a região protegida pelo monitor da instância. Toda operação concorrente que participa da mesma regra precisa respeitar a mesma coordenação, inclusive leituras que exigem visibilidade consistente.

Se a aplicação roda em duas JVMs, cada uma tem seu objeto e monitor. O bloco não protege um estoque compartilhado no banco entre as duas instâncias. Nesse caso, precisamos de garantias no armazenamento, como atualização condicional, transação e controle de concorrência apropriado. É por isso que a discussão reaparece em JPA.

Threads virtuais mudam custo, não a regra

Java 21 oferece threads virtuais para executar tarefas com um modelo de thread por tarefa a custo menor em cenários adequados, especialmente muita espera por I/O. Um executor correspondente é Executors.newVirtualThreadPerTaskExecutor().

Elas não tornam cálculo pesado mais rápido por si só, não corrigem races e não aumentam a capacidade do banco. Dez mil tarefas tentando usar vinte conexões ainda precisam disputar vinte conexões. Limite concorrência no recurso escasso e observe o sistema.

Esperar indefinidamente também é uma decisão. Future.get tem variante com timeout; timeout significa que o chamador parou de esperar, não que a operação remota foi desfeita. Interrupção é um mecanismo cooperativo: uma tarefa precisa responder a ele. Thread.sleep não serve como garantia de que outra tarefa terminou; use coordenação explícita.

Confira seu raciocínio

Explique por que duas threads podem transformar 7 em 8 em vez de 9. A resposta precisa mostrar duas leituras de 7 e duas gravações de 8. Dizer apenas “thread é imprevisível” não identifica o mecanismo.

Depois explique por que um cache estático sem limite pode continuar crescendo apesar do GC. Os objetos permanecem alcançáveis; a solução envolve política de retenção, limite ou expiração, não obrigar o coletor a ignorar referências válidas.

Em uma revisão posterior, compare contador isolado, reserva composta e reserva em duas instâncias. Escolha a garantia em cada caso. Você está pronto para aprofundar quando consegue explicar o limite de sua solução, inclusive o que ela não coordena.

Referência usada: guia de threads, concorrência e memória da JVM.

Material complementar da Fernanda

  • Guia avançado: concorrência e paralelismo — Reveja sincronização, pools e condições de corrida. Concorrência também existe com vários núcleos; paralelismo descreve execução simultânea. ThreadLocal mantém estado por thread, não substitui um contador compartilhado seguro.

Pratique com /aprender

Copie o comando e cole no chat para iniciar uma prática guiada:

/aprender Pratique concorrência em Java 21 com duas tarefas que atualizam um contador compartilhado. Peça que eu desenhe uma intercalação que perde uma atualização e compare uma correção com AtomicInteger. Faça uma pergunta por vez e não mostre a solução antes da tentativa.
A SEGUIR · AULA 10

Entendendo o coletor de lixo do Java: heap, stack e garbage collector

32 min de vídeo · Resumo curto de heap, referências e alcançabilidade para acompanhar a aula sobre garbage collector.

Ir para a aula 10 →