Image for post Explora la serialización en Java: cómo funciona y sus aplicaciones prácticas

Serialización en Java: qué hace cada mecanismo y cuándo evitarlo


Serializar en Java es convertir el estado de un objeto en una secuencia de bytes reconstruible más tarde con ObjectInputStream; deserializar es el proceso inverso. Ese es el contrato de java.io.Serializable, el mecanismo nativo del lenguaje desde sus primeras versiones. Pero en un servicio típico de 2026 rara vez es la única pieza del problema: la mayoría de las integraciones entre sistemas usan JSON con Jackson o Gson, y la serialización nativa sobrevive sobre todo en comunicación JVM a JVM (RMI, cachés distribuidas, colas internas) y en persistencia de sesión HTTP. Esta referencia cubre el contrato mínimo, los puntos donde se rompe en la práctica, cuándo cambiar a JSON y el riesgo de seguridad que casi nadie mitiga.

El contrato mínimo: qué exige Serializable

Serializable es una interfaz marcador: no declara métodos, solo autoriza a la JVM a convertir instancias de esa clase en bytes. Cualquier campo no marcado como transient ni static se incluye en el stream sin que tengas que escribir nada adicional.

import java.io.*;

public class Empleado implements Serializable {
    private static final long serialVersionUID = 1L;

    private String nombre;
    private int edad;
    private transient String tokenSesion; // nunca se serializa

    public Empleado(String nombre, int edad, String tokenSesion) {
        this.nombre = nombre;
        this.edad = edad;
        this.tokenSesion = tokenSesion;
    }

    public String getNombre() { return nombre; }
    public int getEdad() { return edad; }
}

public class SerializarEmpleado {
    public static void main(String[] args) throws IOException {
        Empleado empleado = new Empleado("Ana", 34, "sess-9f21");
        try (ObjectOutputStream oos = new ObjectOutputStream(
                new FileOutputStream("empleado.ser"))) {
            oos.writeObject(empleado);
        }
    }
}

Para leerlo de vuelta:

try (ObjectInputStream ois = new ObjectInputStream(
        new FileInputStream("empleado.ser"))) {
    Empleado empleado = (Empleado) ois.readObject();
    System.out.println(empleado.getNombre() + ", " + empleado.getEdad());
    // tokenSesion llega como null: era transient
}

serialVersionUID: la casilla que decide si tu clase evolucionada sigue leyendo el pasado

Si no declaras serialVersionUID, la JVM calcula uno a partir de la firma completa de la clase (nombre, campos, métodos, interfaces) en tiempo de compilación. El problema aparece cuando añades un campo o cambias una firma: el UID calculado cambia, y al deserializar un objeto guardado con la versión anterior de la clase obtienes InvalidClassException en vez de un objeto parcialmente reconstruido. Declarar el UID explícitamente, como en el ejemplo anterior, desacopla la compatibilidad binaria de cambios que no alteran el significado del objeto, como añadir un campo opcional. Eso no es gratis: los campos nuevos llegan a null o al valor por defecto del tipo en los objetos antiguos, así que el código que los lee tiene que tolerar esa ausencia en vez de asumir que siempre están presentes.

Serialización personalizada: writeObject y readObject

Cuando el volcado automático de campos no basta (un campo no es serializable, o quieres transformar un valor sensible antes de escribirlo), Java permite sobrescribir el proceso con dos métodos privados que la JVM invoca por reflexión, con una firma exacta que no admite variaciones:

import java.io.*;

public class Credencial implements Serializable {
    private static final long serialVersionUID = 2L;
    private String usuario;
    private transient char[] secreto;

    public Credencial(String usuario, char[] secreto) {
        this.usuario = usuario;
        this.secreto = secreto;
    }

    private void writeObject(ObjectOutputStream out) throws IOException {
        out.defaultWriteObject(); // serializa los campos no transient
        out.writeInt(secreto.length);
        for (char c : secreto) {
            out.writeChar(c ^ 0x5A); // ofuscación mínima de ejemplo
        }
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        int longitud = in.readInt();
        secreto = new char[longitud];
        for (int i = 0; i < longitud; i++) {
            secreto[i] = (char) (in.readChar() ^ 0x5A);
        }
    }
}

Este es el patrón que el post original absorbido prometía en sus "mejores prácticas" sin llegar a mostrarlo: no basta con decir "personaliza la serialización", hay que ver la firma exacta que espera la JVM (métodos private, sin static, con esos parámetros concretos) para que el mecanismo se active en lugar de ser ignorado en silencio.

Cuándo Serializable no es la respuesta

La pregunta que los dos artículos originales dejaban sin resolver, a pesar de que uno prometía explícitamente una comparativa de rendimiento, es cuándo conviene usar JSON en vez del stream binario nativo. Estos son los criterios que de verdad deciden:

Criteriojava.io.SerializableJackson (JSON)Gson (JSON)
Legible por humanos / depurableNo, formato binario propietario
Interoperable con otros lenguajesNo, solo JVM
Tolerancia a evolución del esquemaFrágil sin UID explícitoAlta con anotaciones como @JsonIgnorePropertiesAlta, ignora campos desconocidos por defecto
Rendimiento en payloads grandesCompacto pero acoplado a la JVMBueno, algo más de overhead que el binario nativoBueno, similar a Jackson
Superficie de ataque al deserializarAlta si el origen no es de confianzaMenor, pero vulnerable si el polimorfismo está mal configuradoMenor

Con Jackson (jackson-databind 2.19.4 según su registro de cambios oficial) el mismo objeto se convierte así:

import com.fasterxml.jackson.databind.ObjectMapper;

ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(empleado);
Empleado copia = mapper.readValue(json, Empleado.class);

Con Gson (2.14.0, según sus notas de versión en GitHub) el equivalente:

import com.google.gson.Gson;

Gson gson = new Gson();
String json = gson.toJson(empleado);
Empleado copia = gson.fromJson(json, Empleado.class);

Ninguno de los dos exige que la clase implemente una interfaz especial, lo que ya es una ventaja frente a Serializable cuando no controlas el código fuente de la clase que quieres convertir, por ejemplo porque viene de una librería externa.

El riesgo real: deserializar bytes que no controlas

ObjectInputStream.readObject() reconstruye instancias ejecutando el código de la clase indicada en el propio stream, y ese nombre de clase lo elige quien escribió los bytes, no quien los lee. Si el stream viene de una fuente no confiable (un cliente externo, un mensaje en una cola compartida con otro equipo), alguien puede intentar instanciar clases del classpath con efectos colaterales en sus constructores o en sus métodos readObject, las llamadas cadenas de gadgets. La mitigación estándar es un filtro de deserialización con ObjectInputFilter, que define qué clases están permitidas antes de que readObject las instancie:

import java.io.*;

ObjectInputFilter filtro = ObjectInputFilter.Config.createFilter(
    "com.example.model.*;java.base/*;!*" // permite tu paquete y lo esencial del JDK, rechaza el resto
);

try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("empleado.ser"))) {
    ois.setObjectInputFilter(filtro);
    Empleado empleado = (Empleado) ois.readObject();
}

Este mecanismo (JEP 290, con filtros específicos por contexto añadidos después vía JEP 415) forma parte del núcleo de seguridad de la plataforma, con soporte para listas de permitidos, listas de bloqueados y filtros globales por JVM vía propiedades del sistema, según la documentación oficial de filtrado de serialización de Oracle. Si tu aplicación deserializa datos que no genera ella misma, no configurar un filtro es dejar la puerta abierta sin cerradura.

Casos borde que rompen la teoría de manual

  • Records: un record puede implementar Serializable, disponibles como característica estable en las versiones LTS actuales, pero su forma serializada se construye a partir de los componentes del constructor canónico, no de campos arbitrarios; los métodos writeObject/readObject personalizados se ignoran salvo que el record los declare explícitamente con la misma firma exacta.
  • Jerarquías con polimorfismo en JSON: Jackson y Gson no saben por sí solos qué subclase concreta instanciar a partir de una interfaz; hace falta @JsonTypeInfo en Jackson o un adaptador de tipo propio en Gson para no perder el tipo real al deserializar una jerarquía.
  • Migración de datos legados: si tienes años de objetos serializados en binario y quieres pasar a JSON, la migración no es un cambio de formato trivial: hay que deserializar primero con las clases originales, y su UID original, antes de poder reserializar en el nuevo formato, lo que obliga a mantener esas clases vivas durante toda la ventana de migración.
  • Compatibilidad entre versiones de la JVM: el formato de stream de Serializable es estable entre versiones de Java desde hace mucho tiempo, pero eso no protege contra cambios de firma en tus propias clases; el UID explícito sigue siendo tu única defensa real contra ese problema concreto.
  • Rendimiento con grafos de objetos grandes: ObjectOutputStream cachea referencias para no serializar el mismo objeto dos veces, lo que evita ciclos infinitos, pero también significa que reutilizar el mismo stream para escribir versiones sucesivas del mismo objeto puede devolver la versión antigua cacheada si no llamas a reset() entre escrituras.

Fuentes citadas

Compartir X LinkedIn