Serialização em Java
Serialize objetos Java em bytes com a interface Serializable, ObjectOutputStream e serialVersionUID.
Os capítulos anteriores abordaram fluxos de conteúdo — bytes, caracteres, primitivos, linhas. A serialização é um degrau acima: um fluxo de objetos. Você chama writeObject(someObject) e o JDK percorre todo o grafo de referências a partir desse objeto, codificando cada campo de cada objeto alcançável como bytes e escrevendo o resultado no fluxo. No lado da leitura, readObject() reconstrói o grafo.
Isso é uma afirmação importante com um asterisco importante. A serialização funciona, funcionou desde o Java 1.1, e você a encontrará em bases de código antigas (RMI, EJB, replicação de sessão, algumas camadas de cache). Mas o design tem problemas bem conhecidos — versionamento frágil, falhas de segurança, acoplamento rígido entre persistência e forma da classe — e a Oracle tem tentado publicamente aposentá-la há anos. Para código novo, a resposta é quase sempre JSON ou Protocol Buffers. Este capítulo existe para que você possa ler e manter o código que já existe.
O mecanismo
Três partes:
- A interface marcadora
Serializable. Uma classe declara que pode ser serializada implementandojava.io.Serializable. A interface não tem métodos; é uma flag que o JDK verifica em tempo de execução. ObjectOutputStream. Um decorador que envolve qualquerOutputStreame adicionawriteObject(Object). É o motor que percorre o grafo e escreve os bytes.ObjectInputStream(próximo capítulo). O espelho que lê os bytes e reconstrói o grafo.
class User implements Serializable { // the marker
private static final long serialVersionUID = 1L;
String name;
int age;
User(String name, int age) { this.name = name; this.age = age; }
}
try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(path))) {
out.writeObject(new User("alice", 30)); // the user is now on disk
}Essa é a receita mínima. A classe implementa Serializable; o escritor é ObjectOutputStream; a chamada é writeObject. Na próxima leitura desse arquivo (abordada no próximo capítulo) você obtém de volta uma instância de User.
O que é gravado
Tudo o que é alcançável a partir do objeto, por padrão:
- Cada campo não
transiente nãostatic, por reflexão, na ordem de declaração. - Recursivamente, cada objeto referenciado por esses campos.
- Para cada classe envolvida, um descritor (o nome da classe, tipos dos campos e
serialVersionUID) para que o leitor possa validar o formato.
O formato é binário, autodescritivo (carrega metadados da classe) e não legível por humanos. Ele também é específico ao sistema de tipos Java — os bytes codificam offsets de campos, nomes de tipos e hierarquias de herança que não significam nada fora do Java. Esta é a limitação fundamental: um arquivo User.bin não pode ser lido por Python, Go ou JavaScript sem um parser personalizado.
transient: campos que você não quer serializar
Um campo marcado como transient é ignorado durante a serialização. O leitor o vê como o valor padrão para seu tipo — null, 0, false. Use-o para:
- Caches que podem ser reconstruídos:
transient Map<String, Result> cache; - Campos que não fazem sentido entre JVMs:
transient Thread worker;,transient Connection db; - Dados sensíveis que você não quer em disco:
transient String password;
class Session implements Serializable {
private static final long serialVersionUID = 1L;
String userId;
long createdAt;
transient byte[] sessionToken; // never gets written
}A Session desserializada terá sessionToken == null. Seu código precisa lidar com o campo ausente após a reconstrução.
Campos estáticos também são ignorados — static pertence à classe, não à instância, portanto não faz parte do estado por objeto.
serialVersionUID: declare explicitamente
Toda classe serializável tem um serialVersionUID — um número de versão de 64 bits gravado no fluxo e verificado em relação à classe no lado da leitura. Se eles não coincidirem, a desserialização lança InvalidClassException.
Você deve sempre declará-lo:
private static final long serialVersionUID = 1L;Se não o fizer, a JVM calcula um a partir da forma da classe — cada campo, cada assinatura de método, cada interface. Adicione um campo, mude o tipo de retorno de um método, renomeie um parâmetro, e o UID calculado muda. O código que escreveu User.bin com a classe da semana passada não consegue lê-lo com a classe desta semana. Você não vai perceber isso em testes unitários porque ambos os lados veem a mesma classe. Mas vai perceber em produção quando um usuário fizer atualização.
Declarar o UID explicitamente coloca você no controle. Incremente-o manualmente apenas quando tiver feito uma mudança incompatível. (Veja o Javadoc de Serializable para as regras completas de evolução — elas são intrincadas.)
O que você pode mudar entre versões
As regras para mudanças "compatíveis" são surpreendentemente rígidas. Resumidamente:
- Seguro: adicionar novos campos, remover campos transient/static, expandir acesso (
private→public). - Inseguro: remover campos não transient, mudar o tipo de um campo, mudar o
serialVersionUIDde uma classe, mudar a cadeia de herança.
O ponto é: os bytes em disco estão acoplados à forma da hierarquia de classes, não apenas aos dados. Formatos de armazenamento de longo prazo precisam de seu próprio esquema. A serialização é adequada para caches de curta duração e transporte intra-JVM, mas é frágil para qualquer coisa que precise sobreviver a um deploy.
O grafo completo, incluindo ciclos
writeObject segue cada referência. Se User contém um Team e o Team contém uma List<User> que inclui o primeiro User, o ciclo é tratado: o JDK rastreia a identidade de cada objeto que escreve e, quando encontra um pela segunda vez, escreve uma referência retroativa em vez de recursão novamente. O grafo reconstruído do outro lado tem as mesmas relações de identidade.
Isso é poderoso e ao mesmo tempo um risco. Um objeto serializável puxa tudo o que pode alcançar — e se algum desses objetos alcançáveis não for Serializable, a escrita falha com NotSerializableException nomeando o tipo infrator. A correção é uma das seguintes: implementar Serializable no infrator, marcar o campo como transient, ou reestruturar a classe para não manter a referência.
Segurança: nunca desserialize bytes não confiáveis
Este é principalmente um tópico do próximo capítulo, mas a consequência molda também o lado da escrita. O formato de serialização do Java executa código no leitor — construtores de classe e hooks readObject — durante a desserialização. Fluxos de bytes criados maliciosamente foram usados para execução remota de código contra todos os principais servidores de aplicativos Java. A regra que surgiu de anos de CVEs:
Não desserialize bytes de nenhuma fonte que você não controle completamente.
No lado da escrita, isso significa: não projete protocolos onde uma parte serializa dados com ObjectOutputStream e outra os desserializa com ObjectInputStream. Use JSON ou Protocol Buffers em limites de confiança; reserve a serialização para casos de uso "mesma JVM, mesmo class loader, mesmo domínio de confiança".
Quando usar serialização (e quando não usar)
Use quando:
- Você precisa fazer checkpoint de um grafo de objetos na mesma JVM para recuperação após reinicialização.
- Você está trabalhando com um framework existente (RMI, JMX, EJB, alguma replicação de sessão) que exige isso.
- Você quer uma implementação de 10 linhas para um arquivo "save game" em que pode quebrar a compatibilidade a qualquer momento.
Não use quando:
- O formato precisar sobreviver a um deploy. Use um formato com versionamento de esquema (JSON + um campo de versão, Protobuf, Avro).
- Os dados cruzarem um limite de confiança. Use JSON ou Protobuf.
- Outra linguagem precisar ler ou escrever os dados. O formato de serialização Java é exclusivo para Java.
Para a maioria do código novo, Jackson.writeValueAsString(obj) para um arquivo JSON é a escolha melhor. É sem esquema mas flexível, legível por humanos e analisável em qualquer linguagem.
Um exemplo completo: escrevendo um grafo de registros
O programa abaixo define dois tipos serializáveis simples, Department e Employee, com uma referência retroativa (cada Employee conhece seu Department, e cada Department mantém uma lista de seus Employees — um ciclo). Ele escreve o grafo com ObjectOutputStream, exibe a contagem de bytes e mostra o NotSerializableException que você obtém quando um campo não serializável aparece. A leitura dos bytes de volta é o próximo capítulo; aqui focamos no lado da escrita.
O que observar na execução:
- Uma chamada
writeObject(eng)serializou o Department, todos os três Employees, as referências retroativas de Employee para Department e a lista dentro de Department. Esse é o recurso principal da serialização: grafos, não registros. Ciclos tratados, identidade preservada, sem percurso manual. - Os primeiros quatro bytes foram
AC ED 00 05— o "número mágico" de serialização Java e a versão do fluxo. Todo arquivo serializado começa com esses bytes. Se você vir esse cabeçalho em um arquivo encontrado em produção, está vendo a saída deObjectOutputStream. - O dump de bytes continha
"alice"(um campo não transient) e não continha"hash-A"(um campotransient). Marcar um campo comotransienté a forma suportada de excluí-lo. Campos sensíveis (senhas, tokens, chaves de sessão) devem sertransient. - A escrita de
BadEmployeelançouNotSerializableExceptione a mensagem nomeouSettings— o tipo exato não serializável. É assim que você encontra os infratores: tente escrever, leia a exceção, corrija a classe nomeada (ou marque o campo comotransient). A verificação acontece no campo, não no nível da classe — uma referência não serializável perdida é suficiente. serialVersionUID = 1Lfoi declarado em toda classe serializável. A execução atual não notaria se estivesse ausente, mas um futuro você que refatorar a classe e tentar carregar um arquivo antigo com o novo código notaria imediatamente. Declare-o; incremente-o deliberadamente quando fizer uma mudança incompatível.
O que vem a seguir
Este capítulo cobriu a escrita — Serializable, ObjectOutputStream, o percurso do grafo, o formato. Ler e reconstruir o grafo é a operação espelho com seu próprio conjunto de armadilhas (a de segurança sendo a maior). Esse é o próximo capítulo, Java Deserialization.