image

Unlimited bootcamps and 750+ courses forever

70
%OFF
Article image
Arthur Haerdy
Arthur Haerdy20/08/2026 10:59
Share
IBM Bob: IA de Nível Empresarial para Desenvolvedores e Tech LeadersRecommended for youIBM Bob: IA de Nível Empresarial para Desenvolvedores e Tech Leaders

Radiografia de um Singleton: debugando a criação de objetos (Race Condition) - Java Intermediário

  • #Java

👋 Por que este artigo existe

A maioria dos conteúdos sobre o padrão Singleton costuma estagnar nos mesmos clichês pragmáticos: ensinam a versão naive (inocente) e disparam orientações prontas como "a versão lazy não é thread-safe, use synchronized ou Double-Checked Locking", "se quiser performance, aplique o Singleton Holder", ou a clássica fuga conceitual: "em projetos reais, o Spring Framework já resolve tudo via escopo de Beans".

Neste laboratório desci aos detalhes para explicar o problema da versão Lazy: usei o debugger do IntelliJ IDEA para acompanhar, instrução por instrução, o ciclo de vida completo de um SingletonLazy, desde o atributo estático null, passando pela alocação na Heap, até a reutilização da mesma referência na segunda chamada. Depois, simulei manualmente (com uma tabela de tempo) o que aconteceria se duas threads chamassem getInstancia() ao mesmo tempo.

O resultado é um roteiro que qualquer estudante de Design Patterns em Java pode (e DEVE) reproduzir na própria IDE, e que faz como que a race condition deixe de ser um conceito abstrato pra se tornar algo que você literalmente vê acontecer no painel Variables.

O que você vai encontrar neste artigo:

  1. Uma revisão rápida do padrão Singleton Lazy;
  2. O passo a passo de debug real, com prints do IntelliJ;
  3. Uma matriz de rastreamento de estado da execução;
  4. A anatomia de uma race condition no padrão check-then-act;
  5. As alternativas de correção — e por que a mais elegante delas dispensa synchronized.

1. Relembrando o padrão Singleton Lazy

A ideia do Singleton "Preguiçoso" (Lazy) é adiar a criação do objeto até o momento em que ele é efetivamente solicitado pela primeira vez:

package one.digitalinnovation.gof;

public class SingletonLazy {

  // O atributo 'instancia' começa como null (ainda não foi criado)
  private static SingletonLazy instancia;

  // Construtor PRIVADO: ninguém de fora pode fazer 'new SingletonLazy()'
  private SingletonLazy() {}

  // Método público para obter a instância única
  public static SingletonLazy getInstancia() {
      if (instancia == null) {
          // Só cria se ainda não existir
          instancia = new SingletonLazy();
      }
      return instancia;
  }
}

Vantagem: economia de memória e processamento na inicialização da aplicação. Problema (ainda invisível aqui, mas que existe): a checagem if (instancia == null) não é atômica ‼️. Vamos ver isso na prática: para investigar o comportamento, criei uma classe de apoio para observar as duas chamadas:

package one.digitalinnovation.gof.singletonlazy;

import one.digitalinnovation.gof.SingletonLazy;

public class SingletonLazyDebug {

  public static void main(String[] args) {
      System.out.println("=== INÍCIO DO TESTE DE DEBUG: SINGLETON LAZY ===");

      // Primeira chamada: deve instanciar o objeto na memória Heap
      SingletonLazy lazy1 = SingletonLazy.getInstancia();
      System.out.println("Endereço/Hash de lazy1: " + lazy1);

      // Segunda chamada: deve reaproveitar a instância pré-existente
      SingletonLazy lazy2 = SingletonLazy.getInstancia();
      System.out.println("Endereço/Hash de lazy2: " + lazy2);

      // Validação de identidade
      if (lazy1 == lazy2) {
          System.out.println("[SUCESSO] Ambas as variáveis apontam para a MESMA instância!");
      } else {
          System.out.println("[FALHA] Foram criadas instâncias diferentes!");
      }
  }
}

2. Colocando o debugger pra trabalhar

Setup: dois breakpoints, um em cada chamada de SingletonLazy.getInstancia(), execução em modo Debug (Shift + F9) e Step Into (F7) para entrar no método da classe base.

image

Primeira chamada — o nascimento da instância

Ao entrar no método pela primeira vez, o painel Variables confirma o que o código promete: o atributo estático instancia ainda está null.

image

O fluxo entra na linha instancia = new SingletonLazy();:

image

O construtor privado é executado — e a pilha de chamadas (Call Stack) mostra o <init> sendo disparado logo após a validação instancia == null, alocando a nova referência (@1075) na Heap:

image

Ao alcançar o return instancia;, o painel Variables confirma que o atributo estático agora referencia o objeto recém-criado, pronto para ser devolvido:

image

Avançando com Step Over (F8), a variável local lazy1 recebe o ponteiro:

image

Segunda chamada — a reutilização

Na chamada seguinte, o fluxo entra de novo em getInstancia(). Desta vez, como instancia já contém @1075, a condição do if é avaliada como false:

image

A instrução new SingletonLazy() é saltada por completo, evitando uma alocação duplicada:

image

O método retorna diretamente a referência pré-existente. Ao final da execução, os dois endereços no painel Variables são idênticos:

image

Validação final

Comparando os hashes de lazy1 e lazy2 diretamente no console de debug, fica comprovado: as duas variáveis apontam para o mesmo objeto.

image

3. Matriz de rastreamento de estado

Consolidando o que o debugger mostrou, passo a passo:

image

Com uma única thread, o comportamento é perfeitamente previsível. É aqui que a história muda de figura.

4. O "Elefante na sala": a Race Condition do check-then-act

Repare de novo na lógica do método:

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

Esse padrão é conhecido como check-then-act (verifica-então-age): o método primeiro o valor de instancia e só depois decide se cria o objeto. O problema é que, para a JVM, ler e escrever não são uma operação atômica única. Nada impede que o escalonador de threads interrompa a execução exatamente entre o if e o new, cedendo a vez para outra thread.

Simulando duas threads concorrentes

Imagine as threads A e B chamando getInstancia() ao mesmo tempo, no instante em que instancia ainda vale null:

image

Resultado: duas instâncias diferentes foram criadas (@111 e @222), e a segunda sobrescreveu a referência da primeira. Se alguma outra parte do sistema já tivesse recebido @111 entre t4 e t6, a aplicação passaria a conviver com dois "Singletons" divergentes ‼️ (o que quebra a premissa fundamental do padrão).

Isso é uma race condition: o resultado final do programa passa a depender da ordem imprevisível de execução das threads, em vez de depender só da lógica escrita no código.

5. Como corrigir isso

Existem três caminhos clássicos:

  • synchronized no método getInstancia() — força as threads a executarem o método uma de cada vez. Simples, mas adiciona overhead de lock em toda chamada, mesmo depois que a instância já existe.
  • Double-Checked Locking — sincroniza só o trecho de criação, verificando instancia == null duas vezes (uma fora e outra dentro do bloco synchronized). Reduz o overhead, mas exige que o campo seja volatile, e é uma técnica historicamente propensa a erros de implementação.
  • Initialization-on-demand holder (SingletonLazyHolder)a alternativa mais elegante: elimina o if por completo, delegando a criação a uma classe interna estática. Quem garante a criação única, mesmo com várias threads disputando ao mesmo tempo, é o próprio ClassLoader da JVM, conforme a especificação JLS §12.4.2, que garante que a inicialização de uma classe é serializada automaticamente entre threads concorrentes. Mesma segurança de um synchronized, sem escrevê-lo manualmente e sem custo de lock a cada chamada.

Essa terceira variação merece um laboratório de debug só dela: será próximo artigo desta série (onde também exploraremos os conceitos de INICIALIZAÇÃO vs. INSTANCIAÇÃO de classes).

Conclusão

Debugar não é só "achar o bug". É também a melhor ferramenta que temos para transformar teoria em evidência: vemos com os próprios olhos o null virando referência, o if sendo saltado, os endereços de heap se repetindo. Foi exatamente isso que fizemos aqui com o Singleton Lazy. E o mesmo exercício pode (e deve) ser reproduzido para qualquer padrão de projeto que estejamos estudando.

#Java #DesignPatterns #Singleton #Debug #IntelliJIDEA #Concorrência #BoasPráticas

Share
Recommended for you
Itaú - Java com Inteligência Artificial
Bootcamp NTT DATA: Backend Java com Spring AI
Globant  - Java & Spring Boot AI Developer
Comments (0)
Recommended for youIBM Bob: IA de Nível Empresarial para Desenvolvedores e Tech Leaders