W3docs

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:

  1. A interface marcadora Serializable. Uma classe declara que pode ser serializada implementando java.io.Serializable. A interface não tem métodos; é uma flag que o JDK verifica em tempo de execução.
  2. ObjectOutputStream. Um decorador que envolve qualquer OutputStream e adiciona writeObject(Object). É o motor que percorre o grafo e escreve os bytes.
  3. 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 transient e não static, 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 (privatepublic).
  • Inseguro: remover campos não transient, mudar o tipo de um campo, mudar o serialVersionUID de 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.

java— editable, runs on the server

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 de ObjectOutputStream.
  • O dump de bytes continha "alice" (um campo não transient) e não continha "hash-A" (um campo transient). Marcar um campo como transient é a forma suportada de excluí-lo. Campos sensíveis (senhas, tokens, chaves de sessão) devem ser transient.
  • A escrita de BadEmployee lançou NotSerializableException e a mensagem nomeou Settings — 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 como transient). A verificação acontece no campo, não no nível da classe — uma referência não serializável perdida é suficiente.
  • serialVersionUID = 1L foi 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.

Prática

Prática
Uma classe `Employee` tem um campo `transient String sessionToken`. O token é `'abc123'` no momento da serialização. Após a desserialização em uma nova JVM, qual é o valor de `sessionToken` no objeto reconstruído?
Uma classe `Employee` tem um campo `transient String sessionToken`. O token é `'abc123'` no momento da serialização. Após a desserialização em uma nova JVM, qual é o valor de `sessionToken` no objeto reconstruído?
Was this page helpful?