Introdução
Embora o Java conte com o Garbage Collector (GC) para o gerenciamento automático de memória, confiar cegamente nesse mecanismo pode levar a sérios problemas de desempenho e estabilidade. O GC não é infalível e, em cenários mal projetados, a aplicação pode sofrer com o uso ineficiente de recursos.
Um dos problemas mais comuns é o vazamento de memória (memory leak). Isso ocorre porque o GC não remove objetos que ainda possuem referências ativas, mesmo que eles não sejam mais utilizados pela lógica do negócio. Além disso, o processo de limpeza não é "gratuito": para organizar a memória, o coletor frequentemente precisa interromper a execução da aplicação em eventos conhecidos como Stop-the-World, o que pode causar latências perceptíveis.
Importância do Ciclo de Vida
Compreender o ciclo de vida de um objeto é fundamental para a economia de recursos. Objetos de curta duração devem ser mantidos, idealmente, dentro do escopo de métodos, para que sejam coletados rapidamente na geração jovem (Young Generation) do heap.
Caso esses objetos sobrevivam por muito tempo, eles são promovidos para a geração antiga (Old Generation). Nessa área, a limpeza é significativamente mais custosa e demorada, impactando diretamente a escalabilidade do sistema. Este guia explora as melhores práticas para dominar esses processos.
Métodos de Fábrica Estáticos em vez de Construtores
Criar objetos via construtores é intuitivo, especialmente para quem está começando no ecossistema Java. Não se trata de uma prática errada, afinal, é o mecanismo padrão da linguagem. No entanto, em diversos cenários, o uso de métodos de fábrica estáticos é preferível.
Joshua Bloch, em sua obra Effective Java, orienta: “Considere métodos de fábrica estáticos em vez de construtores”.
Por que utilizar métodos de fábrica estáticos?
Vantagens Principais
Nomes Significativos: Diferente dos construtores, que devem obrigatoriamente ter o nome da classe, os métodos estáticos podem ter nomes que expressam claramente sua intenção e a do objeto retornado.
Flexibilidade de Retorno: Eles podem retornar o próprio tipo, um subtipo (polimorfismo) ou até mesmo tipos primitivos, oferecendo um controle muito maior sobre a instância devolvida.
Encapsulamento de Lógica: Podem centralizar toda a lógica necessária para entregar instâncias totalmente inicializadas e válidas.
Controle de Instâncias: Permitem implementar padrões como singleton ou cache de instâncias, garantindo que a classe tenha controle total sobre quantos objetos existem (classes com controle de instância).
Exemplos na API do Java
A própria biblioteca padrão do Java utiliza extensivamente esse padrão. Um exemplo clássico é a classe String. Embora você possa instanciá-la da forma tradicional (o que raramente é recomendado):
// Forma padrão (cria um novo objeto desnecessariamente se for um literal)
String coach = new String("Ancelotti convoca o Neymar");O Java oferece o método estático valueOf(), que possui diversas sobrecargas e retorna uma representação em String dependendo do argumento:
String yearOfTheSixthTitle = String.valueOf(2026);
String quantityBrazilWorldCups = String.valueOf(5L);
String isBrazilChampion = String.valueOf(true);
String goalOfTitle = String.valueOf('Neymar');Outro exemplo onipresente no dia a dia do desenvolvedor moderno é a classe Optional. Ela utiliza métodos de fábrica estáticos com nomes altamente descritivos:
// Nomes que expressam intenção clara
Optional<String> palmeirasWorldCup = Optional.empty();
Optional<String> brazilAttacker = Optional.of("Endrick");
Optional<String> neymarStatus = Optional.ofNullable(null);Desvantagens
Embora os métodos de fábrica estáticos ofereçam várias vantagens, também apresentam desvantagens importantes a serem consideradas:
Quando uma classe expõe apenas fábricas estáticas e não fornece construtores públicos ou protegidos, a extensibilidade por herança fica limitada. Em alguns casos isso é desejável, por incentivar composição; em outros, pode ser uma restrição indesejada.
Para desenvolvedores iniciantes, chamadas como Type.of(...) tendem a ser menos óbvias que o uso do operador new, o que pode aumentar a curva de aprendizagem. Há também implicações práticas: frameworks e bibliotecas de serialização que dependem de construtores públicos ou do padrão JavaBean podem ter dificuldades para instanciar classes que só oferecem fábricas estáticas.
Outro ponto importante é a expectativa sobre o comportamento das instâncias. Consumidores podem supor que cada chamada cria um novo objeto, e fábricas que retornam instâncias em cache ou singletons podem surpreender e introduzir bugs quando os objetos não são imutáveis. Por fim, multiplicar métodos de fábrica com nomes diferentes pode poluir a API da classe e dificultar a descoberta das diversas formas de criação disponíveis.
Resumo
A existência de dezenas de exemplos na JDK reforça que esse padrão não é apenas uma "preferência estética", mas uma ferramenta poderosa para criar APIs mais legíveis e fáceis de manter.
Convenções de nomenclatura
Ao nomear métodos de fábrica estáticos, dê prioridade à clareza e à consistência: nomes curtos e significativos ajudam muito na descoberta da API. Em geral, of e from funcionam bem para conversões simples, valueOf é adequado para transformar valores primitivos ou representações, parse e decode deixam explícito que há interpretação de texto ou bytes, e getInstance/instance são indicados quando a fábrica devolve uma instância compartilhada.
Prefira verbos como parse ou create quando a fábrica executa uma ação perceptível, e nomes nominais como of ou from quando ela apenas converte ou retorna um valor já prontamente representável.
Sempre documente o comportamento em relação à criação de instâncias — se cada chamada produz um novo objeto, se há cache de instâncias ou se retorna um singleton — porque expectativas incorretas podem gerar bugs, sobretudo quando os objetos não são imutáveis. Por fim, mantenha a consistência com as convenções da JDK e do próprio projeto para reduzir a curva de aprendizado dos consumidores da API.
Padrão Builder para Muitos Parâmetros
Quando uma classe cresce em complexidade e passa a aceitar muitos parâmetros, especialmente quando alguns são obrigatórios e outros opcionais, é comum ver a proliferação de construtores sobrecarregados para cobrir combinações diferentes de argumentos. Esse fenômeno é conhecido como construtores telescópicos (telescoping constructors).
Inicialmente parece uma solução prática, mas rapidamente traz problemas: a API fica difícil de entender, a documentação cresce demais (cada sobrecarga precisa ser explicada) e a manutenção se torna custosa. Além disso, chamadas ao construtor tornam-se frágeis e propensas a erros sutis, já que a ordem dos argumentos importa; por exemplo, new Foo(a, b, null, 0, true) não deixa claro quais valores foram omitidos ou qual parâmetro corresponde a cada posição.
O Builder resolve esses pontos diretamente. Em vez de oferecer várias sobrecargas posicionalmente ambíguas, o Builder expõe métodos nomeados para cada parâmetro opcional, permitindo uma construção passo a passo e autocontida. Cada chamada deixa explícito o que está sendo definido. Isso elimina a ambiguidade da ordem de argumentos, reduz a necessidade de criar múltiplos construtores e concentra validações e valores padrão em um único local. Como resultado, a API fica muito mais legível (new Foo.Builder(required).timeout(30).secure(true).build()), a classe pode ser imutável e as invariantes são validadas no momento do build().
Implementação típica (Builder como classe interna estática):
public class NutritionFacts {
private final int servingSize; // obrigatório
private final int servings; // obrigatório
private final int calories; // opcional
private final int fat; // opcional
private final int sodium; // opcional
private final int carbohydrate; // opcional
private NutritionFacts(Builder builder) {
this.servingSize = builder.servingSize;
this.servings = builder.servings;
this.calories = builder.calories;
this.fat = builder.fat;
this.sodium = builder.sodium;
this.carbohydrate = builder.carbohydrate;
}
public static class Builder {
private final int servingSize;
private final int servings;
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;
public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}
public Builder calories(int val) { this.calories = val; return this; }
public Builder fat(int val) { this.fat = val; return this; }
public Builder sodium(int val) { this.sodium = val; return this; }
public Builder carbohydrate(int val) { this.carbohydrate = val; return this; }
public NutritionFacts build() { return new NutritionFacts(this); }
}
}Uso:
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100)
.sodium(35)
.carbohydrate(27)
.build();Quando usar e quando não usar
O Builder é indicado quando há muitos parâmetros opcionais, quando você quer garantir imutabilidade e quando a legibilidade da API importa. Ele evita construtores confusos e facilita validações complexas na etapa de construção.
Não use Builder para classes triviais com poucos parâmetros (2–3), onde a verbosidade pode não valer a pena. Em situações extremamente críticas de performance ou quando frameworks exigem construtores públicos/JavaBean, considere fornecer alternativas compatíveis além do Builder.
Resumo
O Builder melhora legibilidade e segurança em APIs com muitos parâmetros; prefira-o para modelos ricos em opções.
Singletons com Construtores ou Enum
Singletons garantem uma única instância de uma classe ao longo da aplicação. Existem várias formas de implementar esse padrão em Java, cada uma com trade-offs importantes.
Uma implementação imediata (eager) instancia o singleton no carregamento da classe. É simples e thread-safe por construção:
public class EagerSingleton {
private static final EagerSingleton INSTANCE = new EagerSingleton();
private EagerSingleton() {}
public static EagerSingleton getInstance() { return INSTANCE; }
}A inicialização preguiçosa (lazy) adia a criação até o primeiro acesso, normalmente sincronizando o método de acesso para garantir segurança de thread, o que pode penalizar performance:
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static synchronized LazySingleton getInstance() {
if (instance == null) instance = new LazySingleton();
return instance;
}
}Para reduzir a contenção, a técnica de double-checked locking utiliza volatile e verifica a instância duas vezes, evitando sincronização após a inicialização:
public class DCLSingleton {
private static volatile DCLSingleton instance;
private DCLSingleton() {}
public static DCLSingleton getInstance() {
if (instance == null) {
synchronized(DCLSingleton.class) {
if (instance == null) instance = new DCLSingleton();
}
}
return instance;
}
}Uma alternativa elegante é a abordagem de inicialização segura por classe interna (Bill Pugh), que tira vantagem do carregamento de classes para inicializar o singleton de forma lazy e thread-safe sem sincronização explícita:
public class HolderSingleton {
private HolderSingleton() {}
private static class Holder { static final HolderSingleton INSTANCE = new HolderSingleton(); }
public static HolderSingleton getInstance() { return Holder.INSTANCE; }
}Apesar dessas opções, a solução mais robusta em Java é frequentemente usar um enum com um único elemento. Um enum singleton é thread-safe por definição, trata corretamente serialização e fornece proteção contra criação via reflection:
public enum EnumSingleton {
INSTANCE;
public void doSomething() { /* ... */ }
}As diferenças práticas entre as abordagens podem ser resumidas assim: o eager é simples e seguro, mas sempre aloca a instância; o lazy (síncrono) evita alocação precoce à custa de overhead de sincronização; o double-checked locking reduz overhead pós-inicialização, mas exige cuidado (uso de volatile) para ser correto; o Holder (Bill Pugh) oferece lazy + thread-safety sem sincronização explícita e é geralmente preferível a DCL por ser mais claro. O enum, por sua vez, oferece a melhor proteção em muitos cenários: a JVM garante thread-safety na inicialização de enums, a serialização é tratada automaticamente (evitando problemas de criar cópias na desserialização) e a construção via reflection que tentaria invocar um construtor privado falha em criar uma nova instância confiável.
Em termos de desvantagens, enums não permitem estender classes (assim como singletons convencionais com construtores privados) e podem parecer menos naturais quando o singleton precisa implementar interfaces complexas ou quando se espera instanciabilidade externa para testes; nesses casos, pode ser conveniente combinar o singleton com abstrações (interfaces) e fornecer implementações alternativas para testes. DCL adiciona complexidade e é sujeito a erros sutis se volatile for omitido; sincronização no acesso lazy impõe custo de performance em cenários de alto throughput; o eager pode desperdiçar memória se a criação for pesada e nunca utilizada.
Na prática, para a maioria das necessidades de singleton em Java, enum ou o padrão Holder (Bill Pugh) são as escolhas mais simples e robustas: prefira enum quando não precisar de herança e quiser máxima segurança contra serialização e reflexão, e prefira Holder quando precisar de uma implementação de classe mais tradicional com inicialização preguiçosa sem sincronização aparente.
Classes Utilitárias Não Instanciáveis
As classes utilitárias agrupam funções estáticas reutilizáveis e não mantêm estado de instância. Por isso não fazem sentido como objetos. Em Java isso normalmente se traduz em uma classe final com métodos static que expõem comportamentos utilitários (por exemplo, validações ou formatações), deixando claro que não há estado e que não é preciso instanciar nada.
Para reforçar essa intenção e evitar instâncias acidentais, é prática comum declarar um construtor private e lançar uma exceção caso ele seja invocado. Lançar um AssertionError ou UnsupportedOperationException no construtor sinaliza um erro de programação (não de execução normal) e impede que alguém crie acidentalmente um objeto dessa classe. Inclusive em muitos cenários de reflexão, a tentativa de instanciar resultará na exceção sendo lançada:
public final class StringUtils {
private StringUtils() { throw new AssertionError("No instances"); }
public static boolean isEmpty(String source) {
return source == null || source.length() == 0;
}
public static String wrap(String source, String wrapWith) {
return isEmpty(source) ? source : wrapWith + source + wrapWith;
}
}Por que lançar AssertionError no construtor? Por ser uma Error não verificada, ela deixa explícito que a criação de instâncias é um bug do programador. UnsupportedOperationException também é usado em alguns projetos; a escolha depende da semântica desejada, mas o objetivo é o mesmo: falhar cedo e de forma óbvia se alguém tentar instanciar a classe.
Alternativas modernas: desde o Java 8 é possível usar métodos static em interfaces para agrupar operações relacionadas, o que evita a necessidade de uma classe utilitária separada. Interfaces com métodos estáticos funcionam bem quando a intenção é apenas agrupar funções nomeadas por contrato:
public interface StringOps {
static boolean isEmpty(String s) { return s == null || s.isEmpty(); }
}Outra alternativa é usar um enum com zero valores (uma enum sem constantes) ou com um único elemento quando fizer sentido, mas esse uso é menos comum para utilitários genéricos. Enums oferecem garantias fortes para singletons e inicialização, porém não são a escolha típica apenas para agrupar funções estáticas.
Em resumo: prefira uma final class com um construtor private que lance uma exceção quando o objetivo for agrupar utilitários; utilize interfaces com métodos static quando fizer sentido expor operações como parte de um contrato; reserve enum para casos onde as garantias de enum (por ex. singletons) forem realmente necessárias.
Injeção de Dependência
O padrão de injeção de dependência é extremamente útil para manter o baixo acoplamento entre módulos. Nesse padrão, as dependências deixam de ser criadas internamente pelo objeto consumidor e passam a ser fornecidas externamente. Em aplicações com frameworks, esse papel costuma ser exercido por um contêiner de IoC.
A injeção de dependência está relacionada ao princípio de inversão de dependência (DIP), mas não é exatamente a mesma coisa: DIP é um princípio de design; DI é uma técnica prática para aplicá-lo.
Princípio de Inversão de Controle (IoC)
Inversão de Controle significa inverter quem controla o fluxo de criação e composição dos objetos. Em vez de cada classe decidir "como" obter suas dependências, esse controle é deslocado para fora dela.
Em termos práticos:
- Sem IoC: a classe instancia suas dependências com
new. - Com IoC: a classe apenas declara o que precisa, e alguém de fora fornece isso.
Esse "alguém" pode ser:
- código manual (factory/composição no
main), ou - um container (como o Spring).
Tipos de injeção
1) Constructor Injection
As dependências são exigidas no construtor. É o estilo mais recomendado para dependências obrigatórias, pois o objeto já nasce válido.
public class PedidoService {
private final PedidoRepository repository;
public PedidoService(PedidoRepository repository) {
this.repository = repository;
}
}2) Setter Injection
As dependências são fornecidas por métodos set. Útil para dependências opcionais ou que podem ser trocadas em tempo de execução.
public class RelatorioService {
private Notificador notificador;
public void setNotificador(Notificador notificador) {
this.notificador = notificador;
}
}3) Field Injection
A dependência é atribuída diretamente no campo (geralmente por anotação em frameworks).
public class UsuarioController {
@Autowired
private UsuarioService usuarioService;
}Embora seja comum em exemplos, Field Injection tende a dificultar testes e ocultar dependências. Em código de produção, prefira Constructor Injection.
Vantagens
- Testabilidade: facilita mocks/stubs em testes unitários.
- Desacoplamento: o consumidor depende de abstrações, não de implementações concretas.
- Manutenibilidade: trocar implementação (ex.: banco, gateway externo) exige menos mudanças no código cliente.
- Coesão de responsabilidades: classes focam no comportamento de negócio, não no ciclo de vida das dependências.
Exemplo prático sem framework
public interface Mensageiro {
void enviar(String msg);
}
public class EmailMensageiro implements Mensageiro {
public void enviar(String msg) {
System.out.println("Enviando e-mail: " + msg);
}
}
public class NotificacaoService {
private final Mensageiro mensageiro;
public NotificacaoService(Mensageiro mensageiro) {
this.mensageiro = mensageiro;
}
public void notificar(String msg) {
mensageiro.enviar(msg);
}
}
public class App {
public static void main(String[] args) {
Mensageiro mensageiro = new EmailMensageiro();
NotificacaoService service = new NotificacaoService(mensageiro);
service.notificar("Pedido confirmado");
}
}Perceba que a inversão de controle já acontece mesmo sem Spring: a classe NotificacaoService não cria EmailMensageiro; ela apenas recebe a abstração Mensageiro.
Exemplo prático com Spring
public interface Mensageiro {
void enviar(String msg);
}
@Service
public class EmailMensageiro implements Mensageiro {
@Override
public void enviar(String msg) {
System.out.println("Enviando e-mail: " + msg);
}
}
@Service
public class NotificacaoService {
private final Mensageiro mensageiro;
public NotificacaoService(Mensageiro mensageiro) {
this.mensageiro = mensageiro;
}
public void notificar(String msg) {
mensageiro.enviar(msg);
}
}Nesse caso, o Spring faz o papel de container IoC:
- identifica componentes (
@Service), - resolve dependências,
- injeta automaticamente no construtor.
Resumo
IoC define "quem controla" a criação e composição dos objetos; DI define "como" as dependências são fornecidas. Em aplicações Java modernas, a combinação de abstrações + injeção por construtor + contêiner (quando necessário) costuma oferecer o melhor equilíbrio entre clareza, testabilidade e manutenção.
Evite a Construção Desnecessária de Objetos
Criar objetos em Java é relativamente barato na maioria dos casos, mas ainda assim não é gratuito. Em caminhos críticos (loops quentes, processamento de alto volume e APIs de baixa latência), pequenas alocações evitáveis podem aumentar a pressão no Garbage Collector e gerar custo acumulado.
O objetivo aqui não é micro-otimizar tudo, e sim evitar padrões de criação redundante quando já existem alternativas mais simples e eficientes.
Interning e pool de strings
Strings literais em Java já são armazenadas no pool de strings. Isso significa que duas literais iguais apontam para o mesmo objeto:
String a = "java";
String b = "java";
System.out.println(a == b); // trueQuando você usa new String("java"), força a criação de uma nova instância desnecessária na maioria dos cenários:
String c = new String("java");
System.out.println(a == c); // falseUse intern() apenas quando houver uma justificativa clara (por exemplo, reduzir duplicação de muitas strings repetidas em memória após medição). Aplicar interning indiscriminadamente pode piorar desempenho e consumo de memória.
Autoboxing (Long vs long)
Autoboxing converte automaticamente tipos primitivos em classes wrapper (long -> Long) e vice-versa. Essa conveniência pode introduzir criação de objetos e custo extra sem necessidade.
Exemplo clássico de armadilha:
Long soma = 0L;
for (long i = 0; i < 1_000_000; i++) {
soma += i; // unboxing + soma + boxing a cada iteração
}Melhor abordagem para processamento numérico:
long soma = 0L;
for (long i = 0; i < 1_000_000; i++) {
soma += i;
}Regra prática: prefira primitivos (int, long, double) em cálculos e estruturas de alto volume; use classes wrapper (Integer, Long, Double) quando precisar de anulabilidade, tipos genéricos ou APIs que exigem objetos.
Reutilização de objetos imutáveis
Objetos imutáveis podem ser compartilhados com segurança porque seu estado não muda. Reutilizá-los evita alocações repetidas e reduz ruído para o GC.
Exemplos comuns:
- usar
Boolean.TRUE/Boolean.FALSEem vez de instanciar; - reutilizar constantes como
BigDecimal.ZERO,BigDecimal.ONE,BigInteger.ZERO; - preferir fábricas estáticas que já podem retornar instâncias compartilhadas.
Evite este padrão:
BigDecimal taxa = new BigDecimal("0");Prefira:
BigDecimal taxa = BigDecimal.ZERO;Quando não criar pools de objetos
Na JVM moderna, criar e coletar objetos leves costuma ser muito eficiente. Por isso, manter pools de objetos manuais para objetos baratos (DTOs, pequenas estruturas, classes wrapper simples) geralmente aumenta complexidade sem ganho real.
Problemas típicos de pool desnecessário:
- código mais complexo e difícil de manter;
- risco de vazamento de estado entre reutilizações;
- contenção/sincronização adicional em cenários concorrentes;
- possível piora de latência por gerenciamento manual.
Exceção importante: recursos realmente pesados e externos, como conexões de banco (DataSource com pool), conexões HTTP e alguns clientes de mensageria. Nesses casos, o uso de pool é recomendado porque o custo de criação/destruição é alto e envolve E/S externa.
Resumo
Evite criar objetos desnecessários, mas não antecipe otimizações sem evidência. Meça primeiro (profilers, métricas de alocação e latência), identifique os hotspots e só então aplique ajustes direcionados.
Elimine Referências Obsoletas
Referências obsoletas são referências para objetos que já não são mais úteis para a lógica da aplicação, mas que continuam acessíveis por alguma estrutura. Enquanto a referência existir, o GC não pode coletar o objeto.
Esse tipo de problema é uma fonte comum de vazamento de memória em Java: não há "vazamento" no sentido nativo (como em C), e sim retenção indevida de memória por objetos ainda referenciados.
O que são referências obsoletas
Uma referência se torna obsoleta quando o objeto não é mais necessário, porém permanece armazenado em campos, coleções, caches ou arrays internos.
Sinais típicos:
- estruturas crescem e nunca diminuem;
- objetos antigos continuam vivos em heap dump;
- uso de memória sobe gradualmente sem cair após picos.
Vazamento de memória em Java (exemplo com Stack)
Um exemplo clássico é uma pilha implementada com array interno. Se, no pop, o elemento removido não for limpo, a pilha mantém uma referência obsoleta:
public class Stack {
private Object[] elements;
private int size = 0;
public Stack(int capacity) {
this.elements = new Object[capacity];
}
public void push(Object e) {
elements[size++] = e;
}
public Object pop() {
if (size == 0) throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // elimina referência obsoleta
return result;
}
}Sem a linha elements[size] = null, os objetos removidos da pilha podem ficar retidos por tempo indefinido.
Quando fazer null-out
Em geral, você não precisa atribuir null manualmente em variáveis locais: ao sair de escopo, elas deixam de ser alcançáveis.
Faça null-out quando:
- você gerencia memória manualmente em estruturas próprias (arrays/buffers internos);
- há ciclos de vida longos e risco de retenção acidental em campos/coleções;
- a remoção lógica de um item não remove automaticamente a referência física.
Evite null-out por padrão em todo lugar; use de forma cirúrgica onde há retenção real.
WeakHashMap para caches
Para certos caches, WeakHashMap pode ajudar a evitar retenção eterna das chaves: quando não houver referência forte para a chave fora do mapa, a entrada pode ser removida pelo GC.
Map<Chave, Valor> cache = new WeakHashMap<>();Importante: isso funciona bem quando as chaves realmente podem desaparecer sem impacto funcional. Para políticas mais previsíveis (TTL, tamanho máximo, estatísticas), bibliotecas de cache dedicadas costumam ser melhores.
Listeners e callbacks
Outro foco comum de vazamento é registrar listeners/callbacks e nunca removê-los. O objeto observador permanece referenciado pelo publicador e não é coletado.
Boas práticas:
- sempre parear registro com remoção (
add/remove); - remover listeners ao encerrar ciclo de vida (ex.:
close,dispose,onStop); - quando aplicável, usar referências fracas para observadores.
publisher.addListener(listener);
try {
// uso
} finally {
publisher.removeListener(listener);
}Resumo
Em Java, vazamento de memória geralmente significa retenção indevida de objetos alcançáveis. Elimine referências obsoletas em estruturas próprias, escolha estratégias de cache adequadas e trate listeners/callbacks com disciplina de ciclo de vida.
Evite finalizadores e cleaners
Finalizadores (finalize) foram uma tentativa de automatizar limpeza de recursos, mas trazem mais problemas do que benefícios. Em Java moderno, a recomendação é evitar finalizadores e usar mecanismos explícitos de liberação.
Problemas dos finalizadores
Finalizadores são problemáticos por três motivos principais:
- tempo indeterminado: não há garantia de quando (ou se)
finalizeserá executado; - performance: objetos com finalizador custam mais para o GC e podem aumentar latência;
- segurança/correção: finalização pode ressuscitar objetos e abrir espaço para comportamentos inesperados.
Além disso, finalize é considerado obsoleto e foi descontinuado para remoção nas versões modernas da plataforma.
Cleaners como alternativa (Java 9+)
Cleaner (Java 9+) substitui finalizadores como mecanismo de contingência para limpeza, com semântica mais previsível e sem os principais problemas de finalize.
Ponto importante: Cleaner não substitui fechamento explícito. Ele deve funcionar como contingência (rede de segurança), e não como caminho principal de liberação.
Implementar AutoCloseable
A forma recomendada de gerenciar recursos é expor close() e usar try-with-resources:
public final class NativeBuffer implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private static final class State implements Runnable {
private long address;
State(long address) {
this.address = address;
}
@Override
public void run() {
if (address != 0) {
freeNative(address); // libera recurso nativo
address = 0;
}
}
}
private final State state;
private final Cleaner.Cleanable cleanable;
public NativeBuffer(int size) {
long address = allocateNative(size);
this.state = new State(address);
this.cleanable = CLEANER.register(this, state);
}
@Override
public void close() {
cleanable.clean(); // liberação explícita e idempotente
}
private static long allocateNative(int size) { return 1L; }
private static void freeNative(long address) { }
}Uso:
try (NativeBuffer buffer = new NativeBuffer(1024)) {
// uso do recurso
}Com isso, o caminho principal de limpeza é determinístico (close), e o cleaner fica como rede de segurança.
Quando usar Cleaners
Use Cleaner em situações específicas:
- como rede de segurança para casos em que
close()não foi chamado por erro de uso; - para recursos nativos/externos de alto custo (memória fora da heap, handles de SO, descritores nativos);
- quando você precisa reduzir risco de vazamento em APIs consumidas por terceiros.
Evite usar Cleaner para objetos Java comuns e leves. Para esses casos, o GC já resolve o ciclo de vida naturalmente.
Resumo
Prefira sempre liberação explícita com AutoCloseable + try-with-resources. Considere Cleaner apenas como proteção adicional para recursos críticos, nunca como substituto de fechamento determinístico.
Try-with-resources em vez de try-finally
Antes do Java 7, o padrão comum para liberar recursos era usar try-finally. Funciona, mas tende a gerar código mais verboso e sujeito a erros sutis.
Problemas do try-finally
Dois problemas aparecem com frequência:
- verbosidade: o código de fechamento se repete e reduz legibilidade;
- supressão de exceções: uma exceção no
close()pode esconder a exceção original lançada no bloco principal.
Exemplo com try-finally:
InputStream in = new FileInputStream("dados.txt");
try {
// leitura
} finally {
in.close();
}Com múltiplos recursos, a complexidade cresce rapidamente (aninhamentos e múltiplos finally).
Como try-with-resources resolve isso
try-with-resources fecha automaticamente os recursos no fim do bloco, em ordem reversa de abertura, deixando o código mais curto e mais seguro.
try (InputStream in = new FileInputStream("dados.txt")) {
// leitura
}Sobre exceções: se ocorrer erro no corpo do try e também no close(), a exceção principal é preservada e as demais ficam registradas como suprimidas (getSuppressed()), evitando perda de diagnóstico.
Interface AutoCloseable
Para usar try-with-resources, o tipo precisa implementar AutoCloseable (ou Closeable, que estende AutoCloseable).
public final class RelatorioWriter implements AutoCloseable {
public void write(String linha) {
// escreve no recurso
}
@Override
public void close() {
// libera recurso
}
}Uso:
try (RelatorioWriter writer = new RelatorioWriter()) {
writer.write("linha 1");
}Múltiplos recursos
Você pode declarar mais de um recurso no mesmo try:
try (
BufferedReader reader = new BufferedReader(new FileReader("in.txt"));
BufferedWriter writer = new BufferedWriter(new FileWriter("out.txt"))
) {
writer.write(reader.readLine());
}O fechamento ocorre de baixo para cima (primeiro writer, depois reader).
Java 9+ e variáveis effectively final
Desde Java 9, é possível usar no try-with-resources uma variável já declarada, desde que seja final ou effectively final (efetivamente final):
BufferedReader reader = new BufferedReader(new FileReader("in.txt"));
try (reader) {
System.out.println(reader.readLine());
}Isso facilita refatorações e reduz duplicação quando o recurso precisa ser preparado antes do try.
Resumo
Prefira try-with-resources para qualquer recurso que exija fechamento. Você ganha código mais limpo, menor chance de erro e tratamento de exceções mais correto do que no padrão try-finally.
Conclusao
Ao longo deste guia, vimos que criar objetos de forma consciente é uma decisão de design, não apenas de sintaxe. Escolhas como preferir métodos de fábrica estáticos, aplicar Builder quando necessário, usar injeção de dependência, evitar alocações desnecessárias e eliminar referências obsoletas têm impacto direto em desempenho, legibilidade e confiabilidade.
Também ficou claro que o gerenciamento correto de recursos é parte essencial de um código Java saudável: evitar finalizadores, implementar AutoCloseable e adotar try-with-resources reduz riscos de vazamento, melhora a previsibilidade e simplifica o tratamento de falhas.
No conjunto, essas práticas fortalecem os três pilares de uma base de código profissional:
- robustez, ao reduzir comportamentos inesperados e erros sutis;
- eficiência, ao diminuir custo de alocação e pressão no coletor de lixo;
- manutenibilidade, ao favorecer APIs claras, baixo acoplamento e evolução segura.
Em projetos reais, a diferença entre código que "funciona hoje" e código que permanece estável no longo prazo costuma estar justamente nesses detalhes. Seguir essas boas práticas não é excesso de zelo: é engenharia de software aplicada para construir sistemas Java mais sólidos, escaláveis e fáceis de manter.