1. Introdução
Em Java (e, de modo geral, na POO) cada objeto tem uma identidade — não é apenas o conjunto de valores de seus campos, mas sim “quem ele é” na memória. Dois objetos podem ser “equivalentes” (por exemplo, a.equals(b) retorna true), mas diferentes em identidade (a != b). Identidade é quando duas variáveis apontam para o mesmo objeto na memória (a == b).
Por que isso é importante para a serialização?
Quando você serializa (salva) um grafo de objetos (por exemplo, uma árvore, uma lista ou simplesmente dois objetos que referenciam o mesmo objeto aninhado), é importante preservar não só os valores dos campos, mas também a estrutura de referências entre os objetos.
Se no seu grafo houver referências repetidas (vários objetos referenciam o mesmo objeto aninhado) ou até referências cíclicas (A → B → A), então, após a desserialização, esses vínculos devem permanecer os mesmos.
Exemplo: você tem dois objetos A e B, ambos referenciam o mesmo objeto C. Após a serialização e a desserialização, deve valer: a.c == b.c (o mesmo objeto na memória). Se a serialização simplesmente “copiar” os objetos, após a restauração sairão dois objetos C diferentes — e isso já é outro grafo.
2. Como o ObjectOutputStream resolve o problema da identidade
No Java, para serialização binária, usa-se um par de classes: ObjectOutputStream (para escrita) e ObjectInputStream (para leitura).
ObjectOutputStream não é apenas “gravar um objeto em arquivo”. Ele é inteligente:
- Acompanha todos os objetos que já foram serializados dentro de um mesmo fluxo de escrita (uma sessão).
- Se aparece uma referência para um objeto que já foi serializado, ele não o grava de novo; grava uma “referência repetida” (reference handle).
- Na desserialização, o ObjectInputStream restaura a estrutura de modo que as referências repetidas apontem para o mesmo objeto na memória.
Isso funciona até para referências cíclicas! Ou seja, se você tiver A → B → A, a serialização e a desserialização não entrarão em loop nem “quebrarão”: as referências serão restauradas corretamente.
Como isso é implementado?
Por dentro, o ObjectOutputStream usa uma tabela especial (identity map) onde ficam os objetos já serializados. Ao encontrar um objeto novo, ele é adicionado à tabela e serializado por completo. Ao encontrar um objeto já presente na tabela, grava-se no fluxo apenas a “referência repetida” (handle).
3. Demonstração com exemplos
Exemplo 1: Grafo com referências cíclicas (A → B, B → A)
import java.io.*;
class Node implements Serializable {
String name;
Node next;
Node(String name) {
this.name = name;
}
}
public class CyclicSerializationDemo {
public static void main(String[] args) throws Exception {
Node a = new Node("A");
Node b = new Node("B");
a.next = b;
b.next = a; // Ciclo!
// Serialização
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic.dat"))) {
out.writeObject(a);
}
// Desserialização
Node a2;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("cyclic.dat"))) {
a2 = (Node) in.readObject();
}
System.out.println("a2.name = " + a2.name); // A
System.out.println("a2.next.name = " + a2.next.name); // B
System.out.println("a2.next.next == a2: " + (a2.next.next == a2)); // true!
}
}
Saída:
a2.name = A
a2.next.name = B
a2.next.next == a2: true
Comentário:
O ciclo foi preservado! Após a desserialização, a2.next.next volta a apontar para a2.
Exemplo 2: Referências repetidas (A → C, B → C)
import java.io.*;
class Wrapper implements Serializable {
String name;
Object ref;
Wrapper(String name) { this.name = name; }
}
public class SharedReferenceDemo {
public static void main(String[] args) throws Exception {
Wrapper a = new Wrapper("A");
Wrapper b = new Wrapper("B");
Wrapper c = new Wrapper("C");
a.ref = c;
b.ref = c;
// Serialização
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("shared.dat"))) {
out.writeObject(new Wrapper[] {a, b});
}
// Desserialização
Wrapper[] arr;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("shared.dat"))) {
arr = (Wrapper[]) in.readObject();
}
Wrapper a2 = arr[0];
Wrapper b2 = arr[1];
System.out.println("a2.ref == b2.ref: " + (a2.ref == b2.ref)); // true!
System.out.println("a2.ref.name = " + ((Wrapper)a2.ref).name); // C
}
}
Saída:
a2.ref == b2.ref: true
a2.ref.name = C
Comentário:
Após a desserialização, ambas as referências voltam a apontar para o mesmo objeto.
4. Relação com writeReplace e readResolve
No Java é possível “intervir” no processo de (de)serialização por meio de métodos especiais:
writeReplace() — é chamado antes da serialização do objeto. É possível retornar outro objeto, que será serializado no lugar do original.
readResolve() — é chamado após a desserialização do objeto. É possível retornar outro objeto, que será usado em vez daquele recém-criado.
Impacto na identidade:
- Se for usado writeReplace() ou readResolve(), o objeto retornado por esses métodos é que entrará na tabela de referências.
- Isso pode alterar a identidade: se readResolve() retornar um objeto novo/diferente, as referências repetidas podem passar a apontar para instâncias distintas.
- Tenha cuidado: você pode tanto “quebrar” a identidade quanto garanti-la de propósito (exemplo clássico — Singleton via readResolve(), em que todas as referências após a desserialização apontam para o mesmo singleton).
5. Prática: código que demonstra a preservação da identidade
import java.io.*;
class Shared implements Serializable {
String value;
Shared(String value) { this.value = value; }
}
class Holder implements Serializable {
String name;
Shared shared;
Holder(String name, Shared shared) {
this.name = name;
this.shared = shared;
}
}
public class IdentityDemo {
public static void main(String[] args) throws Exception {
Shared c = new Shared("C");
Holder a = new Holder("A", c);
Holder b = new Holder("B", c);
// Serialização
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("identity.dat"))) {
out.writeObject(new Holder[] {a, b});
}
// Desserialização
Holder[] arr;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("identity.dat"))) {
arr = (Holder[]) in.readObject();
}
Holder a2 = arr[0];
Holder b2 = arr[1];
System.out.println("a2.shared == b2.shared: " + (a2.shared == b2.shared)); // true!
System.out.println("a2.shared.value = " + a2.shared.value); // C
}
}
Saída:
a2.shared == b2.shared: true
a2.shared.value = C
Comentário:
A identidade do objeto c foi preservada: após a desserialização, ambas as referências apontam para a mesma instância.
6. Conclusões e erros comuns
Erro №1: serializar objetos com referências repetidas separadamente. Se você grava cada objeto com sua própria instância de ObjectOutputStream, as referências entre eles não serão preservadas — as instâncias desserializadas serão diferentes, mesmo que no original fosse a mesma referência.
Erro №2: uso incorreto de writeReplace() e readResolve(). Esses métodos podem substituir o objeto durante a (de)serialização, o que altera sua identidade. Sem entender a mecânica, você pode acabar com instâncias inesperadas no resultado.
Erro №3: efeitos inesperados de referências mutáveis compartilhadas. Se vários objetos referenciam um mesmo objeto aninhado mutável (por exemplo, uma lista), após a desserialização continuará assim. Alterações em um lugar afetarão todos os demais.
Erro №4: esperar “novos” objetos após a serialização. A serialização não cria uma estrutura “nova” — ela restaura as referências originais, como no original. Isso pode ser surpreendente ao trabalhar com caches ou objetos de modelo.
GO TO FULL VERSION