W3docs

Serialización en Java

Serializa objetos Java a bytes con la interfaz Serializable, ObjectOutputStream y serialVersionUID.

Los capítulos anteriores cubrieron flujos de contenido — bytes, caracteres, primitivos, líneas. La serialización es un peldaño más arriba en la escalera: un flujo de objetos. Llamas a writeObject(someObject) y el JDK recorre todo el grafo de referencias desde ese objeto, codificando cada campo de cada objeto alcanzable como bytes, y escribe el resultado en el flujo. En el lado de lectura, readObject() reconstruye el grafo.

Eso es una afirmación importante con un asterisco importante adjunto. La serialización funciona, ha funcionado desde Java 1.1, y la verás en bases de código antiguas (RMI, EJB, replicación de sesión, algunas capas de caché). Pero el diseño tiene problemas bien conocidos — versionado frágil, agujeros de seguridad, acoplamiento estrecho entre la persistencia y la forma de la clase — y Oracle ha estado tratando de retirarla públicamente durante años. Para código nuevo, la respuesta es casi siempre JSON o Protocol Buffers. Este capítulo existe para que puedas leer y mantener el código que ya existe.

El mecanismo

Tres piezas:

  1. La interfaz marcadora Serializable. Una clase declara que puede ser serializada implementando java.io.Serializable. La interfaz no tiene métodos; es una bandera que el JDK comprueba en tiempo de ejecución.
  2. ObjectOutputStream. Un decorador que envuelve cualquier OutputStream y añade writeObject(Object). Es el motor que recorre el grafo y escribe los bytes.
  3. ObjectInputStream (siguiente capítulo). El espejo que lee los bytes y reconstruye el 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
}

Esa es la receta mínima. La clase implementa Serializable; el escritor es ObjectOutputStream; la llamada es writeObject. En la próxima lectura de ese archivo (cubierta en el siguiente capítulo) obtienes una instancia de User de vuelta.

Qué se escribe

Todo lo alcanzable desde el objeto, por defecto:

  • Cada campo que no sea transient ni static, por reflexión, en orden de declaración.
  • De forma recursiva, cada objeto al que hacen referencia esos campos.
  • Para cada clase involucrada, un descriptor (el nombre de la clase, los tipos de los campos y el serialVersionUID) para que el lector pueda validar el formato.

El formato es binario, autodescriptivo (contiene metadatos de clase), y no es legible por humanos. También es específico del sistema de tipos de Java — los bytes codifican desplazamientos de campos, nombres de tipos y jerarquías de herencia que no significan nada fuera de Java. Esta es la limitación cardinal: un archivo User.bin no puede ser leído por Python, Go, o JavaScript sin un analizador personalizado.

transient: campos que no quieres serializar

Un campo marcado como transient se omite durante la serialización. El lector lo ve con el valor predeterminado para su tipo — null, 0, false. Úsalo para:

  • Cachés que se pueden reconstruir: transient Map<String, Result> cache;
  • Campos que no tienen sentido entre JVMs: transient Thread worker;, transient Connection db;
  • Datos sensibles que no quieres en disco: transient String password;
class Session implements Serializable {
  private static final long serialVersionUID = 1L;
  String userId;
  long createdAt;
  transient byte[] sessionToken;                     // never gets written
}

La Session deserializada tendrá sessionToken == null. Tu código tiene que manejar que el campo esté ausente después de la reconstrucción.

Los campos estáticos también se omiten — static pertenece a la clase, no a la instancia, por lo que no forma parte del estado por objeto.

serialVersionUID: decláraló explícitamente

Cada clase serializable tiene un serialVersionUID — un número de versión de 64 bits escrito en el flujo y verificado contra la clase en el lado de lectura. Si no coinciden, la deserialización lanza InvalidClassException.

Siempre deberías declararlo:

private static final long serialVersionUID = 1L;

Si no lo haces, el JVM calcula uno a partir de la forma de la clase — cada campo, cada firma de método, cada interfaz. Añade un campo, cambia el tipo de retorno de un método, renombra un parámetro, y el UID calculado cambia. El código que escribió User.bin con la clase de la semana pasada no puede leerlo con la clase de esta semana. No lo detectarás en las pruebas unitarias porque ambos lados ven la misma clase. Lo detectarás en producción cuando un usuario actualice.

Declarar el UID explícitamente te pone en control. Auméntalo manualmente solo cuando hayas hecho un cambio incompatible. (Consulta el Javadoc de Serializable para ver las reglas de evolución completas — son intrincadas.)

Qué puedes cambiar entre versiones

Las reglas para cambios "compatibles" son sorprendentemente estrictas. Aproximadamente:

  • Seguro: añadir nuevos campos, eliminar campos transient/static, ampliar el acceso (privatepublic).
  • Inseguro: eliminar campos no transient, cambiar el tipo de un campo, cambiar el serialVersionUID de una clase, cambiar la cadena de herencia.

El punto: los bytes en disco están acoplados a la forma de la jerarquía de clases, no solo a los datos. Los formatos de almacenamiento a largo plazo necesitan su propio esquema. La serialización está bien para cachés de corta duración y transporte intra-JVM, pero es frágil para cualquier cosa que tenga que sobrevivir a un despliegue.

El grafo completo, incluidos los ciclos

writeObject sigue cada referencia. Si User contiene un Team y el Team contiene una List<User> que incluye al primer User, el ciclo se maneja: el JDK rastrea la identidad de cada objeto que escribe y, cuando encuentra uno por segunda vez, escribe una referencia inversa en lugar de volver a recurrir. El grafo reconstruido en el otro lado tiene las mismas relaciones de identidad.

Eso es potente y también un riesgo. Un objeto serializable arrastra todo lo que puede alcanzar — y si alguno de esos objetos alcanzables no es Serializable, la escritura falla con NotSerializableException nombrando el tipo infractor. La solución es una de las siguientes: implementar Serializable en el infractor, marcar el campo como transient, o reestructurar la clase para no mantener la referencia.

Seguridad: nunca deserialices bytes no confiables

Este es principalmente un tema del siguiente capítulo, pero la consecuencia también condiciona el lado de escritura. El formato de serialización de Java ejecuta código en el lector — constructores de clases y ganchos readObject — durante la deserialización. Flujos de bytes manipulados se han utilizado para ejecución remota de código contra todos los servidores de aplicaciones Java más importantes. La regla que ha surgido de años de CVEs:

No deserialices bytes de ninguna fuente que no controles completamente.

En el lado de escritura esto significa: no diseñes protocolos donde una parte serializa datos con ObjectOutputStream y otra los deserializa con ObjectInputStream. Usa JSON o Protocol Buffers a través de límites de confianza; reserva la serialización para casos de uso del "mismo JVM, mismo cargador de clases, mismo dominio de confianza".

Cuándo usar serialización (y cuándo no)

Úsala cuando:

  • Necesitas crear un punto de control de un grafo de objetos en el mismo JVM para recuperación tras reinicio.
  • Estás trabajando con un framework existente (RMI, JMX, EJB, cierta replicación de sesión) que lo requiere.
  • Quieres una implementación de 10 líneas para un archivo "guardar partida" con el que puedas romper la compatibilidad en cualquier momento.

No la uses cuando:

  • El formato tiene que sobrevivir a un despliegue. Usa en su lugar un formato con versión de esquema (JSON + un campo de versión, Protobuf, Avro).
  • Los datos cruzan un límite de confianza. Usa JSON o Protobuf.
  • Otro lenguaje tiene que leer o escribir los datos. El formato de serialización de Java es exclusivo de Java.

Para la mayoría del código nuevo, Jackson.writeValueAsString(obj) a un archivo JSON es la mejor opción. Es flexible aunque sin esquema, legible por humanos, y analizable desde cualquier lenguaje.

Un ejemplo completo: escribir un grafo de registros

El programa a continuación define dos tipos serializables simples, Department y Employee, con una referencia inversa (cada Employee conoce su Department, y cada Department mantiene una lista de sus Employees — un ciclo). Escribe el grafo con ObjectOutputStream, muestra el conteo de bytes y muestra la NotSerializableException que obtienes cuando se cuela un campo no serializable. La lectura de los bytes de vuelta es el siguiente capítulo; aquí nos centramos en el lado de escritura.

java— editable, runs on the server

Qué extraer de la ejecución:

  • Una sola llamada writeObject(eng) serializó el Department, los tres Employees, las referencias inversas de Employee a Department y la lista dentro de Department. Esa es la característica principal de la serialización: grafos, no registros. Ciclos manejados, identidad preservada, sin recorrido manual.
  • Los primeros cuatro bytes fueron AC ED 00 05 — el "número mágico" de serialización de Java y la versión del flujo. Cada archivo serializado comienza con estos. Si ves este encabezado en un archivo que encontraste en producción, estás mirando la salida de ObjectOutputStream.
  • El volcado de bytes contenía "alice" (un campo no transient) y no contenía "hash-A" (un campo transient). Marcar un campo como transient es la forma admitida de excluirlo. Los campos sensibles (contraseñas, tokens, claves de sesión) pertenecen a transient.
  • La escritura de BadEmployee lanzó NotSerializableException y el mensaje nombró Settings — el tipo exacto no serializable. Así es como encuentras a los infractores: intenta escribir, lee la excepción, corrige la clase nombrada (o marca el campo como transient). La comprobación ocurre en el campo, no en el nivel de clase — una sola referencia no serializable es suficiente.
  • serialVersionUID = 1L fue declarado en cada clase serializable. La ejecución actual no lo notaría si faltara, pero un futuro tú que refactoriza la clase e intenta cargar un archivo antiguo con el nuevo código sí lo notaría de inmediato. Decláraolo; auméntalo deliberadamente cuando hagas un cambio incompatible.

Qué viene después

Este capítulo cubrió la escritura — Serializable, ObjectOutputStream, el recorrido del grafo, el formato. La lectura y reconstrucción del grafo es la operación espejo con su propio conjunto de problemas (el de seguridad siendo el mayor). Ese es el siguiente capítulo, Deserialización en Java.

Práctica

Práctica
Una clase `Employee` tiene un campo `transient String sessionToken`. El token es `'abc123'` en el momento de la serialización. Después de la deserialización en un nuevo JVM, ¿cuál es el valor de `sessionToken` en el objeto reconstruido?
Una clase `Employee` tiene un campo `transient String sessionToken`. El token es `'abc123'` en el momento de la serialización. Después de la deserialización en un nuevo JVM, ¿cuál es el valor de `sessionToken` en el objeto reconstruido?
Was this page helpful?