O que é um objeto em Java
Em Java, um objeto é uma instância de uma classe que reúne estado e comportamento em uma única unidade no heap da JVM. Cada objeto possui seus próprios valores de campo (atributos) e acessa métodos que operam sobre esses dados. O ciclo de vida dele começa com a declaração, passa pela instanciação via new ou reflexão, e termina quando não há mais referências ativas apontando para ele — aí o coletor de lixo pode reclaimar a memória. Na linguagem de programação java o conceito de um objeto muitas vezes é apresentado de forma muito abstrata nos tutoriais, mas na prática significa que tudo que você cria com new vive no heap, tem identidade própria (endereço de memória único) e é gerenciado pelo garbage collector. Diferente de variáveis primitivas que são copiadas por valor, objetos são manipulado por referência.
Instanciação e construtores
Quando você escreve um construtor, a JVM reserva espaço no heap, invoca init() implícita para inicializar campos com valores padrão (null para objetos, 0 para inteiros, false para booleanos), e então executa o corpo do construtor na ordem certa. Se você chamar this() no construtor, ele delega para outro construtor da mesma classe. Se chamar super(), delega para o construtor da superclass. A regra estrita é que essa chamada precisa ser a primeira linha dentro do construtor — o compilador rejeita caso contrário. Um detalhe que quase ninguém menciona: o construtor padrão (sem-argumentos) só existe automaticamente se a classe não tiver nenhum construtor definido. Assim que você declara qualquer construtor, o default some. Isso quebra compiladores em projetos inteiros porque frameworks como JPA, Jackson ou Spring esperam encontrar um construtor vazio por convenção. A solução rápida é declarar explicitamente um construtor sem argumentos mesmo quando você já tem outros definidos.
Mutabilidade e imutabilidade
Objetos mutáveis são a norma em Java, mas imutáveis são muito mais seguros em contextos concorrentes. Uma classe verdadeiramente imutável precisa de campos final, construtor que encapsule referências modificáveis (cópia defensiva), e getters que retornem cópias ou objetos imutáveis, nunca a referência original do campo. Se você expõe um ArrayList como campo público, qualquer cliente pode modificar o conteúdo e quebrar a suposta imutabilidade da classe. Problema real: criei uma classe ImmutavelConfig com um campo private final Map
Serialização e o problema do serialVersionUID
Quando um objeto implementa Serializable, a JVM persiste o estado dele em bytes. O serialVersionUID é o identificador de versão que a JVM verifica no momento da desserialização. Se o número na classe não coincidir com o que estava no stream, uma InvalidClassException é lançada imediatamente. Eu tive um caso em produção onde uma atualização de domínio adicionou um campo novo em uma entidade que era serializada em sessões HTTP. O servidor novo não declarava serialVersionUID, então o compilador gerou um automaticamente. A sessão salva pelo servidor antigo tinha um serialVersionUID diferente. Todas as requisições que precisavam desserializar a sessão falhavam com InvalidClassException. A correção foi adicionar explicitamente private static final long serialVersionUID = 1L; em todas as classes relacionadas e fazer rollback para validar o fluxo de dados.
A serialização também é frágil com campos transient, herança e classes aninhadas. Campos transient não são persistidos, o que pode parecer útil mas quebra contratos de negócios se você depender deles para recuperação de estado. Herança com Serializable funciona apenas se todas as superclasses também forem serializáveis ou tiverem construtores default acessíveis, senão a desserialização falha silenciosamente ou lança exception.
Herança de objetos e polimorfismo
A herança em Java permite que subclasses reutilizem e estendam comportamento de superclasses. Um objeto filho pode ser tratado como instância da superclass através de polimorfismo. Isso é poderoso mas traz complexidade: métodos sobrescritos precisam manter o contrato da assinatura original, e o uso de @Override é praticamente obrigatório para evitar bugs sutis onde você acha que está sobrescrevendo mas na verdade está sobrecarregando. Outra armadilha comum: chamadas de métodos final não podem ser sobrescritas, mas classes anônimas ou lambdas que capturem variáveis finais efetivas podem gerar comportamento inesperado se o escopo mudar durante a execução.
👉 Clique no botão abaixo para saber mais sobre o assunto!
Collections e referências fracas
Java oferece WeakReference, SoftReference e PhantomReference para lidar com objetos cujas referências devem ser coletadas de forma mais agressiva. Cache implementations frequentemente usam WeakHashMap, onde a chave é uma referência fraca. Quando o GC coleta a chave, a entrada é removida automaticamente. Isso é útil para caches de dados temporários, mas perigoso se você esperar que os objetos permaneçam vivos por muito tempo. Eu usei WeakHashMap em um sistema de configuração de plugins. Quando um plugin era removido dinamicamente, a entrada correspondente deveria sumir do cache. Funcionou bem até o GC começar a coletar as chaves antes do tempo em cargas leves de memória, fazendo o cache parecer vazio quando na verdade só havia sido coletado. A solução final foi trocar para um mapa com tamanho fixo e invalidação explícita controlada pelo ciclo de vida do plugin.
Clonagem e cópia profunda
O método clone() da classe Object é protegido por padrão e precisa ser sobrescrito explicitamente para se tornar público. A clonagem rasa copia apenas os campos primitivos e referências de objetos — o objeto clonado e o original compartilham referências aos mesmos objetos internos. Para uma cópia profunda, você precisa clonar recursivamente todos os objetos aninhados manualmente ou usar bibliotecas como Apache Commons Lang ou Google Guava. O clone() também é problemático com interfaces e implementações porque não garante a criação de uma instância da mesma classe concreta. Em muitos casos, construtores de cópia são mais previsíveis e fáceis de manter do que tentar sobrescrever clone().
Garbage collection e memory leaks
O garbage collector em Java é marc e generacional, com coletores como G1GC, ZGC e Shenandoah sendo as opções atuais. A memória é dividida em jovens e velhas gerações, com objetos promovidos conforme sobrevivem a várias coleta de lixo. A coleção de lixo automática não significa que memory leaks sejam impossíveis — eles simplesmente assumem formas diferentes do que em C++. Um leak clássico em Java é manter referências estáticas a objetos que nunca são liberados. Listas estáticas, singletons com cache ilimitado, listeners registrados mas não removidos, e threads que mantêm referências a contexts são as causas mais frequentes. Eu investiguei um vazamento onde um Listener de evento era registrado no init() mas nunca era removido no destroy(), e cada request criava uma nova instância do listener. Após algumas horas, o heap crescia linearmente até o OOM.
Para detectar leaks em produção, ferramentas como VisualVM, JConsole, MAT (Eclipse Memory Analyzer) e o parâmetro -XX:+HeapDumpOnOutOfMemoryError são úteis. O heap dump permite analisar quais objetos estão retendo memória e encontrar as referências que impedem a coleta.
Pattern de builder e objetos imutáveis
Quando uma classe tem muitos campos opcionais, o telescoping constructor problem aparece rapidamente — múltiplos construtores com signatures diferentes ficam impossíveis de manter. O padrão Builder resolve isso criando uma classe aninhada estática que acumula campos via métodos fluentes e constrói o objeto final no build(). Ele também permite criar objetos imutáveis com muitos atributos sem necessidade de dezenas de construtores. Lombok oferece @Builder como atalho, mas gera código que pode dificultar debugging em produção se você não entender o que está sendo gerado. Em times que valorizam transparência, implementações manuais do builder são preferíveis porque o código fonte é legível e não depende de annotation processing.
Objetos e threads
Threads compartilham objetos no heap, o que exige sincronização adequada. Campos voláteis, blocos synchronized, e classes da java.util.concurrent são as ferramentas padrão. Um erro comum é confiar que um objeto é thread-safe só porque seus campos são voláteis — volatilidade garante visibilidade, não atomicidade em operações compostas. Para contadores, use AtomicInteger. Para mapas concorrentes, ConcurrentHashMap. Para sessões de dados, evite objetos mutáveis compartilhados entre threads sem proteção explícita.