RU | EN | DE

Что это и зачем

Сериализация — преобразование объекта в поток байтов (для записи в файл/передачи по сети/кеширования).
Десериализация — обратное восстановление объекта из байтов.

Где встречается:

  • распределённые системы (RPC, REST, Kafka), сессии (HttpSession), кэши (Redis, Hazelcast), очереди (JMS), снапшоты состояния.

Базовая сериализация: Serializable

Минимальный пример

import java.io.*;
 
public class User implements Serializable {
    private static final long serialVersionUID = 1L; // фиксируем версию класса
 
    private String username;
    private transient String password; // не сериализуем
 
    public User(String username, String password) {
        this.username = username;
        this.password = password;
    }
}
 
// запись
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.bin"))) {
    out.writeObject(new User("alice", "p@ss"));
}
 
// чтение
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.bin"))) {
    User u = (User) in.readObject();
}

Ключевые моменты

  • Маркерный интерфейс Serializable (без методов) разрешает стандартный механизм.
  • serialVersionUID — версия класса. Если не указать, JVM сгенерирует автоматически → при малейших изменениях структуры возможны InvalidClassException.
    Ручное фиксирование serialVersionUID стабилизирует совместимость.
  • transient помечает поля, которые не должны попадать в поток (пароли, кеши, зависимости, lazy-прокси и т.п.).

Кастомизация процесса

1. writeObject/readObject

Позволяет контролировать сериализацию/десериализацию.

private void writeObject(ObjectOutputStream out) throws IOException {
    out.defaultWriteObject(); // стандартная логика
    // дополнительно шифруем пароль и пишем
    String enc = password == null ? null : encrypt(password);
    out.writeObject(enc);
}
 
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject(); // стандартная логика
    String enc = (String) in.readObject();
    this.password = enc == null ? null : decrypt(enc);
}

2. readResolve / writeReplace

  • readResolve() — возвращает замещающий объект после десериализации (полезно для синглтонов/кэшей).
  • writeReplace() — подменяет объект перед сериализацией (паттерн Serialization Proxy).
// Синглтон
public class AppConfig implements Serializable {
    private static final AppConfig INSTANCE = new AppConfig();
    private AppConfig() {}
    public static AppConfig getInstance() { return INSTANCE; }
    private Object readResolve() { return INSTANCE; } // сохраняем синглтон-гарантию
}

3. serialPersistentFields

Определяет «логическое» множество сериализуемых полей, независимо от реальных.

private static final ObjectStreamField[] serialPersistentFields = {
    new ObjectStreamField("username", String.class)
};

Полный контроль: Externalizable

Если нужна полная ручная сериализация:

import java.io.*;
 
public class Point implements Externalizable {
    private int x, y;
    public Point() {} // обязателен публичный конструктор без аргументов
 
    public Point(int x, int y) { this.x = x; this.y = y; }
 
    @Override public void writeExternal(ObjectOutput out) throws IOException {
        out.writeInt(x); out.writeInt(y);
    }
    @Override public void readExternal(ObjectInput in) throws IOException {
        this.x = in.readInt(); this.y = in.readInt();
    }
}

Плюсы: контроль формата, скорость, совместимость. Минусы: больше кода, выше риск ошибок.

Безопасность: фильтрация и ограничения (JEP 290)

Проблема: Java-сериализация может привести к уязвимостям (gadget-chains, RCE) при десериализации недоверенных данных.

Решения:

  • ObjectInputFilter (JDK 9+): белые/чёрные списки классов, лимиты графа.
import java.io.*;
import java.util.Objects;
 
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "maxdepth=50;maxbytes=1048576;!*;java.base/*;com.myapp.*"
);
 
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("in.bin"))) {
    in.setObjectInputFilter(filter);
    Object obj = in.readObject();
}
  • Никогда не десериализуйте данные из внешних источников стандартной Java-сериализацией.
  • Для сетевых протоколов — альтернативные форматы (см. ниже).

Совместимость версий (версионирование)

Что ломает совместимость:

  • удалили/изменили тип поля;
  • изменили иерархию (родителя);
  • переименовали класс/пакет;
  • нет подходящего конструктора (для Externalizable).

Стратегии:

  • фиксировать serialVersionUID;
  • для новых полей — давать значения по умолчанию в readObject;
  • использовать Serialization Proxy: сериализуется простой “DTO-прокси”, а не сама сложная сущность;
  • по возможности — перейти на протоколы с явной схемой (Protobuf/Avro).

Производительность и подводные камни

  • Медленнее и шумнее для GC, чем бинарные специализированные форматы (Protobuf/Kryo).
  • Плохо переносится между языками (Java-специфично).
  • Циклические графы объектов поддерживаются, но могут взрывать размер.
  • JPA-сущности с LAZY-полями: прокси-объекты библиотек часто не сериализуютсяNotSerializableException. Для DTO — маппинг (MapStruct), а не прямая сериализация.
  • transient поля после десериализации будут null/0/false — не забывайте корректно инициализировать.

Альтернативы (рекомендуются в продакшене)

1. JSON (чаще всего Jackson)

Плюсы: читаемо, кросс-языково, легко отлаживать. Минусы: объём, скорость ниже бинарных.

// Jackson
import com.fasterxml.jackson.databind.ObjectMapper;
 
ObjectMapper om = new ObjectMapper();
String json = om.writeValueAsString(new User("alice", "p@ss")); // пароль можно @JsonIgnore
User u = om.readValue(json, User.class);

Аннотации: @JsonProperty, @JsonIgnore, @JsonCreator, @JsonTypeInfo (полиморфизм), @JsonInclude.

2. Protocol Buffers (Google Protobuf)

Плюсы: быстро, компактно, строгая схема с версионированием, отличная кросс-языковая поддержка. Минусы: бинарный (нечитаемый глазами), нужна компиляция .proto.

// user.proto
syntax = "proto3";
package demo;
 
message User {
  string username = 1;
  string email = 2;
}

Генерируем Java-классы (protoc), далее:

UserProto.User u = UserProto.User.newBuilder()
    .setUsername("alice").setEmail("a@x.io").build();
byte[] bytes = u.toByteArray();
UserProto.User restored = UserProto.User.parseFrom(bytes);

3. Avro

Плюсы: динамичная/эволюционная схема (часто в Kafka), сжатие, бинарный формат, хорош для логирования событий.
Минусы: сложнее старт, чем JSON.

4. Kryo

Плюсы: очень быстро, компактно, может сериализовать произвольные графы.
Минусы: чувствителен к изменениям классов, нужен контроль регистраций, скорее in-JVM/кеши/Spark, чем публичные API.

Best practices (итого)

  1. Избегайте стандартной Java-сериализации в публичных протоколах и при работе с недоверенными данными.
  2. Фиксируйте serialVersionUID.
  3. Помечайте чувствительные данные как transient или используйте DTO без секретов.
  4. Для тонкой логики — writeObject/readObject, readResolve/writeReplace, Serialization Proxy.
  5. Для безопасности — ObjectInputFilter (JEP 290), лимиты глубины/байт/классов.
  6. Для межъязыкового взаимодействия и стабильного версионирования — Protobuf/Avro; для человекочитаемого — JSON/Jackson.
  7. С JPA/LAZY — сериализуйте DTO, а не сущности.
  8. В распределённых кэшах/очередях — используйте поддерживаемые сериализаторы (Kafka Serde, Redis codecs, Spring MessageConverter).