Article image
Arthur Haerdy
Arthur Haerdy22/08/2026 15:24
Compartilhe

As Três Formas Clássicas de Corrigir o Race Condition no Singleton Lazy - Java Avançado

  • #Java

Motivação para este artigo

O artigo é a continuação direta de Radiografia de um Singleton: debugando a criação de objetos. Nele, usei o debugger do IntelliJ IDEA para provar, passo a passo, que a checagem if (instancia == null) do SingletonLazy não é atômica, e fechei o texto citando três caminhos clássicos para corrigir isso. Este artigo é o desenvolvimento de cada um deles: com código adaptado, teste de concorrência reproduzível e, ao final, o laboratório completo de debug da solução mais recomendada, o Lazy Holder.

Detalhamento

Sabemos que o SingletonLazy tem uma race condition. Sabemos, em teoria, que existem três formas de resolver. Mas "saber que existe" e "ver acontecer" são coisas diferentes (foi exatamente essa a lição do artigo anterior).

Então, em vez de simplesmente listar as três soluções, o objetivo aqui é:

  1. Implementar as duas primeiras soluções (synchronized no método e Double-Checked Locking) como adaptações diretas do SingletonLazy original;
  2. Escrever um teste de concorrência em duas camadas — direto contra o SingletonLazy original e, em seguida, contra uma variação instrumentada que evidencia a falha com mais contundência —, reaproveitável entre as demais variações, para provar — com números, não com fé — que cada correção resolve o problema;
  3. Fechar com o Lazy Holder, incorporando o laboratório de debug que fiz no IntelliJ (com prints reais do painel Variables e do ClassLoader), explicando por que ele é considerado o padrão-ouro para Singleton lazy em Java.

1. Relembrando o problema (resumo do artigo anterior)

O SingletonLazy original resolve a instância assim:

package one.digitalinnovation.gof;

public class SingletonLazy {

  private static SingletonLazy instancia;

  private SingletonLazy() {}

  public static SingletonLazy getInstancia() {
      if (instancia == null) {   // (1) verifica
          instancia = new SingletonLazy();  // (2) age
      }
      return instancia;
  }
}

Essa lógica é um check-then-act (verifica-então-age), e a checagem e a ação não são uma operação atômica para a JVM. Nada impede que o escalonador de threads pause a execução exatamente entre o if e o new, permitindo que duas threads leiam instancia == null ao mesmo tempo e criem duas instâncias diferentes.Duas instâncias divergentes coexistindo é exatamente o que o padrão Singleton promete que nunca vai acontecer (mas aconte ce). Vamos corrigir isso.

2. Montando o teste de concorrência (usado em toda variação deste artigo)

Antes de corrigir, precisamos de uma forma confiável de provocar a colisão. Rodar duas threads normalmente raramente é suficiente. O SO costuma escaloná-las de forma sequencial demais para expor o bug, e uma race condition só se manifesta se o escalonador interromper a execução exatamente entre o if (instancia == null) e a atribuição instancia = new SingletonLazy(). Como o construtor de SingletonLazy não faz nada (é vazio), essa janela é extremamente estreita: a colisão pode ou não acontecer numa rodada específica, dependendo só da sorte do escalonamento.

Por isso, o teste de concorrência a seguir foi montado em duas camadas contra o SingletonLazy original:

  1. Camada 1 — teste direto, contra o SingletonLazy exatamente como ele é, sem nenhuma alteração;
  2. Camada 2 — teste com janela alargada, contra uma variação instrumentada (SingletonLazyInstrumentado), que tem exatamente a mesma lógica e o mesmo bug, mas com um pequeno Thread.sleep() inserido entre o if e o new. Esse atraso não corrige nem piora nada, só torna a janela de colisão grande o bastante para o escalonador quase sempre "tropeçar" nela, evidenciando a falha de forma muito mais contundente (em vez de duas instâncias por sorte, dezenas ou centenas de instâncias distintas por garantia).

A técnica de disparo é a clássica: liberar um número alto de threads simultaneamente, usando um CountDownLatch como "linha de largada", e coletar todas as instâncias retornadas em um Set:

package one.digitalinnovation.gof.teste;

import one.digitalinnovation.gof.SingletonLazy;
import one.digitalinnovation.gof.SingletonLazyInstrumentado;
import one.digitalinnovation.gof.SingletonLazyDCL;
import one.digitalinnovation.gof.SingletonLazyHolder;
import one.digitalinnovation.gof.SingletonLazySynchronized;

import java.util.Set;
import java.util.concurrent.*;
import java.util.function.Supplier;

public class TesteConcorrencia {

  private static final int N_THREADS = 200;

  public static void main(String[] args) throws InterruptedException {
      // Camada 1: baseline direto, sem nenhuma alteração no SingletonLazy original
      rodarTeste("SingletonLazy (SEM correção)", SingletonLazy::getInstancia);

      // Camada 2: mesma lógica, com a janela de colisão alargada artificialmente,
      // para evidenciar a falha de forma mais contundente (mais instâncias, mais consistência)
      rodarTeste("SingletonLazyInstrumentado (janela alargada)", SingletonLazyInstrumentado::getInstancia);

      // As três correções propostas neste artigo
      rodarTeste("SingletonLazySynchronized", SingletonLazySynchronized::getInstancia);
      rodarTeste("SingletonLazyDCL", SingletonLazyDCL::getInstancia);
      rodarTeste("SingletonLazyHolder", SingletonLazyHolder::getInstancia);
  }

  private static void rodarTeste(String nome, Supplier<Object> fabrica) throws InterruptedException {
      ExecutorService pool = Executors.newFixedThreadPool(N_THREADS);
      CountDownLatch largada = new CountDownLatch(1);
      CountDownLatch chegada = new CountDownLatch(N_THREADS);
      Set<Object> instanciasCapturadas = ConcurrentHashMap.newKeySet();

      for (int i = 0; i < N_THREADS; i++) {
          pool.submit(() -> {
              try {
                  largada.await();                       // todas as threads liberadas juntas
                  instanciasCapturadas.add(fabrica.get());
              } catch (InterruptedException e) {
                  Thread.currentThread().interrupt();
              } finally {
                  chegada.countDown();
              }
          });
      }

      largada.countDown();   // dispara as 200 threads ao mesmo tempo
      chegada.await();
      pool.shutdown();

      String resultado = instanciasCapturadas.size() == 1 ? "OK (thread-safe)" : "FALHOU (race condition!)";
      System.out.printf("%-45s -> instâncias distintas: %d | %s%n",
              nome, instanciasCapturadas.size(), resultado);
  }
}

A variação instrumentada usada na camada 2 é esta:

package one.digitalinnovation.gof;

public class SingletonLazyInstrumentado {

  private static SingletonLazyInstrumentado instancia;

  private SingletonLazyInstrumentado() {}

  // Mesma lógica (e o mesmo bug) do SingletonLazy original — o Thread.sleep()
  // existe só para alargar a janela entre "verifica" e "age", evidenciando a
  // race condition de forma mais contundente (mais instâncias, mais consistência).
  public static SingletonLazyInstrumentado getInstancia() {
      if (instancia == null) {
          try {
              Thread.sleep(5); // simula algum trabalho acontecendo aqui no meio
          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
          }
          instancia = new SingletonLazyInstrumentado();
      }
      return instancia;
  }
}

Por que isso funciona como teste: ao usar ConcurrentHashMap.newKeySet() (um Set thread-safe) para acumular tudo que getInstancia() devolveu, a asserção fica simples e objetiva. Se o Set tiver mais de um elemento no fim, o Singleton falhou. Rodando as duas camadas contra o SingletonLazy original, este foi o resultado:

SingletonLazy (SEM correção)                 -> instâncias distintas: 2   | FALHOU (race condition!)
SingletonLazyInstrumentado (janela alargada) -> instâncias distintas: 197 | FALHOU (race condition!)

A camada 1 já comprova a falha por si só. Mas repare na diferença de contundência entre as duas: no teste direto, a race condition se manifestou de forma discreta (2 instâncias, entre 200 threads concorrentes); na versão instrumentada, praticamente todas as 200 threads conseguiram "passar" pelo if antes de qualquer uma delas concluir a atribuição, resultando em 197 instâncias distintas. É a mesma falha, na mesma linha de código. A única diferença é o quanto o teste força a janela de colisão a se abrir. Isso ilustra bem por que esse tipo de bug é traiçoeiro: a intensidade da falha observada depende inteiramente das condições de execução (hardware, carga da JVM, escalonamento do SO), não só da presença do bug em si.

🟢 Guarde as duas camadas: elas serão reutilizadas, sem nenhuma alteração de lógica, para validar as três soluções a seguir. Só o Supplier passado para rodarTeste muda.

3. Solução 1 — synchronized no método inteiro

A correção mais direta: transformar o método inteiro em uma seção crítica, garantindo que apenas uma thread por vez execute getInstancia().

package one.digitalinnovation.gof;

public class SingletonLazySynchronized {

  private static SingletonLazySynchronized instancia;

  private SingletonLazySynchronized() {}

  public static synchronized SingletonLazySynchronized getInstancia() {
      if (instancia == null) {
          instancia = new SingletonLazySynchronized();
      }
      return instancia;
  }
}

A palavra-chave synchronized no método é "ouro sintático" para um bloco synchronized (SingletonLazySynchronized.class) envolvendo o corpo inteiro. Isso significa que o if (instancia == null) e o instancia = new ... passam a ser executados como uma única operação indivisível do ponto de vista de qualquer outra thread. A janela de tempo entre "verificar" e "agir" continua existindo no bytecode, mas NENHUMA OUTRA THREAD CONSEGUE "ENTRAR" nela, porque está bloqueada no monitor do objeto até a primeira thread sair do método.

Vale reforçar essa correção contra a camada mais exigente do teste: a versão instrumentada, com o Thread.sleep(5) alargando a janela dentro da seção crítica. O resultado continua perfeito, porque nenhuma outra thread consegue nem começar a executar o método enquanto a primeira está lá dentro:

SingletonLazySynchronized        -> instâncias distintas: 1 | OK (thread-safe)

O problema não é correção, é custo. Depois que a instância já existe, getInstancia() continua adquirindo e liberando o lock a cada chamada, mesmo que não haja mais nenhuma criação a fazer. É trabalho de sincronização pago para sempre, não só durante a inicialização. Em uma aplicação que chama getInstancia() milhares de vezes por segundo, esse overhead de lock é desnecessário em 99,99% das chamadas.

image

4. Solução 2 — Double-Checked Locking (DCL)

A ideia do Double-Checked Locking é: só sincronizar o trecho que efetivamente cria o objeto. E, dentro do bloco sincronizado, checar instancia == null de novo, porque outra thread pode ter criado a instância entre a primeira checagem (sem lock) e a entrada no bloco synchronized.

package one.digitalinnovation.gof;

public class SingletonLazyDCL {

  private static volatile SingletonLazyDCL instancia;

  private SingletonLazyDCL() {}

  public static SingletonLazyDCL getInstancia() {
      if (instancia == null) {                        // 1ª checagem (sem lock — rápida)
          synchronized (SingletonLazyDCL.class) {
              if (instancia == null) {                 // 2ª checagem (com lock — decisiva)
                  instancia = new SingletonLazyDCL();
              }
          }
      }
      return instancia;
  }
}

Rodando o teste, inclusive na camada instrumentada, que estressa exatamente a janela entre a 1ª checagem (sem lock) e a entrada no bloco sincronizado:

SingletonLazyDCL                 -> instâncias distintas: 1 | OK (thread-safe)

O resultado se mantém correto porque, mesmo que várias threads passem simultaneamente pela 1ª checagem (instancia == null, sem lock), apenas uma consegue entrar no bloco synchronized por vez. E a 2ª checagem, já dentro do lock, impede que a segunda thread em diante execute new novamente.

Repare no volatile no atributo instancia: é aqui que mora a pegadinha histórica do DCL. Sem volatile, o código parece correto e até funciona na maioria dos testes manuais, mas é sutilmente quebrado: new SingletonLazyDCL() não é uma instrução atômica sob o capô. Ela envolve, essencialmente, três passos:

  1. Alocar memória para o objeto;
  2. Executar o construtor (inicializar os campos);
  3. Atribuir o endereço do objeto à variável instancia.

Sem volatile, o Java Memory Model permite que o compilador ou o processador reordenem os passos 2 e 3 como otimização, desde que, do ponto de vista de uma única thread, o resultado final pareça o mesmo. O problema é que, em um cenário concorrente, outra thread pode enxergar instancia já apontando para um endereço de memória (passo 3 "adiantado"), mas com o objeto ainda não totalmente construído (passo 2 incompleto), recebendo assim uma referência para um objeto "pela metade". O volatile cria uma barreira de memória que impede exatamente essa reordenação, garantindo que toda escrita em instancia só fique visível para outras threads depois que o construtor terminar completamente.

Na prática, o que isso significa passo a passo? A tabela abaixo detalha um cenário concreto dessa reordenação, com a Thread A criando a instância e a Thread B apenas lendo:

image

A Thread B recebeu uma referência válida (não-nula) para o objeto, mas pegou o objeto no meio da construção (antes do construtor rodar). Se SingletonLazyDCL tivesse campos (por exemplo, private String nome = "padrão";, ou lógica de inicialização mais complexa), a Thread B poderia enxergar esses campos com valores null/0/ default, mesmo que o construtor "devesse" já ter definido o valor certo (um bug ainda mais traiçoeiro que a criação de instâncias duplicadas, porque a instância é única, mas está corrompida).

Isso acontece porque, sem uma barreira de memória (memory barrier), a JVM só garante ordem de execução dentro de uma única thread (a chamada garantia as-if-serial). Nada impede que, do ponto de vista de outra thread, a escrita da referência (t4) apareça antes da escrita dos campos internos do construtor (t8), mesmo que no código-fonte o construtor venha primeiro. O volatile resolve isso proibindo exatamente essa reordenação: toda escrita em instancia passa a ter uma barreira que garante que tudo que aconteceu antes dela (inclusive o construtor inteiro) fique visível para qualquer outra thread que leia essa referência depois — é a diferença entre "a variável aponta para o objeto" e "a variável aponta para o objeto e o objeto está pronto para uso".

image

O DCL resolve o problema de performance do synchronized puro, mas troca simplicidade por um risco real de erro sutil (ESQUECER DE DECLARAR o campo instancia como volatile) o tipo de bug que passa despercebido em testes locais e só aparece em produção, sob carga real, em hardware com múltiplos núcleos.

5. Solução 3 — Lazy Holder (a alternativa que elimina o problema pela raiz)

As duas soluções acima têm algo em comum: ambas resolvem a race condition adicionando sincronização manual: synchronized, volatile, checagens duplicadas. Cada uma dessas ferramentas é uma oportunidade de erro humano.

O Lazy Holder (também chamado de Initialization-on-demand holder idiom) segue um caminho completamente diferente: em vez de o programador sincronizar manualmente o acesso, ele delega essa responsabilidade para um mecanismo que a própria especificação da JVM já garante como correto: o carregamento de classes.

5.1. O código

package one.digitalinnovation.gof;

public class SingletonLazyHolder {

  // Classe interna estática que "segura" a instância
  private static class InstanceHolder {
      // Só é criada quando getInstancia() for chamado pela primeira vez
      private static final SingletonLazyHolder instancia = new SingletonLazyHolder();
  }

  // Construtor PRIVADO
  private SingletonLazyHolder() {}

  // Método público para obter a instância única
  public static SingletonLazyHolder getInstancia() {
      return InstanceHolder.instancia;
  }
}

Repare: não existe nenhum if, synchronized ou volatile neste código. Toda a criação da instância acontece como efeito colateral de uma única linha (return InstanceHolder.instancia;) e é exatamente essa simplicidade que evita os riscos das duas soluções anteriores.

Rodando o mesmo TesteConcorrencia contra ela sem precisar de nenhuma versão "instrumentada" para provar nada, já que aqui não existe janela de tempo alguma para alargar:

SingletonLazyHolder              -> instâncias distintas: 1 | OK (thread-safe)

5.2. Por que funciona: a garantia da JLS §12.4.2

O truque está em delegar a instância a uma classe interna estática (InstanceHolder). Como InstanceHolder só é referenciada dentro do corpo de getInstancia(), ela permanece "invisível" para o ClassLoader até que alguém realmente chame esse método (reproduzindo o comportamento lazy).

A Java Language Specification (JLS §12.4.2) garante três coisas sobre a inicialização de uma classe:

  1. Uma classe só é inicializada (executando seu <clinit>, isto é, os inicializadores estáticos) na primeira vez em que é ativamente usada;
  2. Se várias threads tentarem inicializar a mesma classe simultaneamente, a JVM serializa esse processo: apenas uma thread executa o <clinit>, enquanto todas as outras bloqueiam automaticamente e esperam a inicialização terminar;
  3. Essa inicialização acontece no máximo uma vez por classe, para sempre (depois de concluída, todas as threads recebem a mesma referência já pronta, sem qualquer lock adicional).

Ou seja: o new SingletonLazyHolder() dentro de InstanceHolder está protegido por um lock interno da JVM, que nem aparece no código-fonte. A mesma proteção que o synchronized daria manualmente, mas implementada de forma nativa e otimizada pelo próprio ClassLoader, e sem o custo de lock permanente do primeiro caso, nem o risco de reordenação de memória do segundo.

📌 Detalhes de implementação (DEBUGGING passo a passo) podem ser verificados nesse artigo: 🧪 Laboratório de Debugging: Singleton Lazy Holder.

5.3. Matriz de Rastreamento de Estado

image

5.4. Por que existe Race Condition no SingletonLazy (e não aqui)

Antes de comprovar a thread-safety do Lazy Holder, vale entender exatamente de onde vem o problema que ele resolve. O SingletonLazy simples cria a instância assim:

public static SingletonLazy getInstancia() {
  if (instancia == null) {              // (1) verifica
      instancia = new SingletonLazy();  // (2) age
  }
  return instancia;
}

Essa lógica segue o padrão check-then-act (verifica-então-age): primeiro lê o valor de instancia, depois decide se cria o objeto. O problema é que, do ponto de vista da JVM, essas duas operações não são atômicas. Nada impede que o escalonador de threads pause a execução exatamente entre elas.

6. Comparativo final: as três soluções lado a lado

image

Conclusão

As três soluções resolvem o mesmo problema, mas com filosofias diferentes: as duas primeiras adicionam sincronização manual ao código; e, com ela, mais uma superfície onde um erro sutil pode se esconder. O Lazy Holder remove a necessidade de sincronização manual, transferindo essa responsabilidade para um contrato que a própria linguagem já garante.

Isso não significa que synchronized e Double-Checked Locking sejam soluções ruins: ambas são corretas, amplamente usadas e importantes de entender, principalmente porque os mesmos princípios (seções críticas, visibilidade de memória, reordenação de instruções) aparecem em praticamente qualquer código concorrente em Java, não só em Singletons. Mas, especificamente para o problema de "criar uma instância única, de forma preguiçosa e segura entre threads", o Lazy Holder é a solução que exige menos do programador para acertar (e com menor overhead do que synchronized) e é por isso que a comunidade Java o recomenda como abordagem padrão.

A sugestão continua de pé: reproduza esse teste de concorrência na sua própria IDE, coloque breakpoints no InstanceHolder, e veja com os próprios olhos o ClassLoader fazendo o trabalho que, nas outras duas soluções, cabe a você.

Recomendo atenção especial à diferença entre as duas camadas do teste contra o SingletonLazy original: a falha "crua" pode aparecer de forma discreta (poucas instâncias divergentes, entre centenas de chamadas), enquanto a versão instrumentada, ao alargar a janela de colisão, deixa a mesma falha praticamente garantida e muito mais numerosa. É a mesma linha de código quebrada nos dois casos: a intensidade do sintoma só depende de quanto espaço você dá para o escalonador de threads "tropeçar" nela. Essa é, talvez, a lição mais importante sobre concorrência: a ausência de sintoma visível não é prova de ausência de bug.

#Java / #DesignPatterns / #Singleton / #Concorrência / #MultiThreading / #BoasPráticas / #JVM

Compartilhe
Comentários (1)
Arthur Haerdy
Arthur Haerdy - 22/08/2026 15:33

Pessoal, boa tarde!

Eu estava devendo esse detalhamento desde o artigo anterior, quando usamos o debugger do IntelliJ para expor a race condition na checagem if (instancia == null). Promessa cumprida, e deu trabalho!

Neste texto, aproveitei para ir além da teoria e introduzir um teste real de concorrência em duas camadas, utilizando CountDownLatch para simular disparos simultâneos em massa, ConcurrentHashMap.newKeySet() para captura thread-safe dos resultados e a técnica de instrumentação com janela de colisão alargada (Thread.sleep()) para provar a falha com dados empíricos do Singleton Lazy.

Além de analisar as adaptações com synchronized e Double-Checked Locking (dessecando o papel do volatile e o comportamento do Java Memory Model), apresento a solução clássica e definitiva recomendada pela comunidade Java: o Lazy Holder (com link para o laboratório de debugging provando a eficácia do método).

Espero que o código e as análises ajudem nos estudos de concorrência de vocês. Fico à disposição nos comentários para dúvidas e trocas de ideias.

Abraços!