Сериализация в Java. Давайте разбираться

Java предоставляет разработчикам удобные инструменты для сериализации объектов. Несмотря на то, что они кажутся донельзя примитивными, их внутренняя реализация содержит много интересных моментов. В этой статье мы рассмотрим основы сериализации и некоторые нюансы, связанные с ней. И, конечно, посмотрим, как она работает изнутри.

ad0414fe02d66c43061f09c12b7dbf1f.png

Вступление

Сериализация в Java воспринимается как довольно простой механизм. Вот вам интерфейс Serializable — достаточно лишь его имплементировать и на этом, казалось бы, всё. Однако, если начать копать чуть глубже, выясняется, что имеется ряд нюансов и интересных моментов.

Данная статья организована следующим образом: мы поговорим о двух встроенных механизмах сериализации в Java — интерфейсах Serializable и Externalizable. Сначала обсудим их основы, а после одним глазком заглянем за кулисы каждого из процессов.

Освежим память

Сериализация — процесс, при котором данные объекта представляются в виде последовательности байтов. Обратный процесс, т.е. преобразование последовательности байтов в объект, называется десериализацией.

Первое, что приходит на ум, когда задаёшь себе вопрос «зачем это нужно?» — передача объекта по сети. Или же объект можно сериализовать и записать в файл, чтобы при следующем запуске программы десериализовать его и работать всё с тем же экземпляром.

В Java мы не можем просто взять и сериализовать обычный объект. Чтобы это можно было сделать, необходимо, чтобы объект реализовывал один из следующих интерфейсов:

  • Serializable;

  • Externalizable.

Рассмотрим каждый подробнее.

Serializable

Serializable — маркерный интерфейс, помечающий наш класс как сериализуемый.

Маркерные интерфейсы — это интерфейсы, которые не содержат методов.

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

  • объект должен реализовывать интерфейс Serializable;

  • поля нашего класса также должны быть сериализуемыми (примитивные типы поддерживают сериализацию по умолчанию);

  • первый предок нашего класса, не поддерживающий сериализацию, должен иметь конструктор без аргументов.

Думаю, с первым и вторым условиями всё понятно. А вот третье само по себе не очень очевидно. Оно вытекает из особенностей десериализации через Serializable, и про него мы поговорим чуть позже. Сначала примеры.

Класс, который будем сериализовывать:

public class Target implements Serializable {
    private int intField;
    private String strField;
    private FieldOfTargetClass field;
    private transient int transField;

    public Target(int i, String s, FieldOfTargetClass f, int trn) {
        this.intField = i;
        this.strField = s;
        this.field = f;
        this.transField = trn;
    }

    @Override
    public String toString() {
        return "{Target = " +
               "integerField: " + intField +
               ", stringField: " + strField +
               ", transientField: " + transField +
               ", customObjectField: " + field.toString() + "}";
    }
}

Класс, объект которого является полем сериализуемого класса:

public class FieldOfTargetClass implements Serializable {
    private int fieldInt;
    private String fieldString;

    public FieldOfTargetClass(int i, String s) {
        this.fieldInt = i;
        this.fieldString = s;
    }

    @Override
    public String toString() {
        return "{FieldOfTargetClass = " +
               "fieldId = " + fieldInt +
               ", fieldString='" + fieldString + "'}";
    }
}

Так, классы есть. Теперь создадим их экземпляры и сериализуем.

Сериализация объекта:

public static void main(String[] args) throws .... {
    Target target = new Target(112, "bzzz",
            new FieldOfTargetClass(13, "Friday"), 52);
    System.out.println(target);

    var fileOutput = new FileOutputStream("ser_obj");
    var objectOutput = new ObjectOutputStream(fileOutput);
    objectOutput.writeObject(target);
    fileOutput.flush();
    objectOutput.flush();
}

Вывод в консоль:

{Target = integerField: 112, stringField: bzzz, transientField: 52, customObjectField:

{FieldOfTargetClass = fieldId: 13, fieldString: 'Friday'}}

Мы создали объект класса FileOutputStream. Он записывает байтовое представление нашего объекта в файл. На этом долго останавливаться смысла нет. Как я уже говорил, запись объекта в файл — это лишь одно из возможных применений сериализации.

Далее мы создаём объект ObjectOutputStream, который и осуществляет сериализацию.

Теперь давайте посмотрим на десериализацию объекта:

public static void main(String[] args) throws .... {
    var fileInput = new FileInputStream("ser_obj");
    var objectInput = new ObjectInputStream(fileInput);
    Target deserializeTarget = (Target) objectInput.readObject();
    fileInput.close();
    objectInput.close();
    
    System.out.println(deserializeTarget);
}

Вывод в консоль:

{Target = integerField: 112, stringField: bzzz, transientField: 0, customObjectField:

{FieldOfTargetClass = fieldId: 13, fieldString: 'Friday'}}

fileInput считывает файл, содержащий поток байтов. objectInput десериализует и возвращает наш объект.

Если мы не хотим, чтобы поле участвовало в сериализации, следует использовать ключевое слово transient (в примере выше им помечено поле transientField). До сериализации поле transientField было равным 52. После десериализации мы получили объект, у которого это поле приняло значение по умолчанию.

Ради читаемости я опустил в коде исключения, выкидываемые методами и конструкторами. Снизу приведён перечень возможных исключений:

  • FileOutputStream, FileInputStream — FileNotFoundException

  • ObjectOutputStream, ObjectInputStream — IOException

  • writeObject — IOException

  • flush, close — IOException

  • readObject — ClassNotFoundException

Из базовых вещей, касающихся использования интерфейса Serializable, мы рассмотрели всё.

Теперь давайте копнём немного глубже

Здесь хотелось бы вернуться к условиям сериализации через Serializable. А точнее к вопросу о том, почему первый несериализуемый предок должен иметь конструктор без аргументов.

Для начала нужно посмотреть, как создаётся десериализуемый объект. Основываясь на документации (3.1, 11 пункт алгоритма), мы можем говорить о следующем: для создания десериализуемого объекта вызывается конструктор его первого несериализуемого предка.

А если у нас нет несериализуемого предка?

Несериализуемый предок у любого объекта есть всегда — класс Object. Однако, если в иерархии наследования между Object классом и нашим сериализуемым есть несериализуемый класс без no-args конструктора, мы столкнёмся с исключением InvalidClassException c сообщением no valid constructor.

Создание объекта через вызов конструктора его первого несериализуемого родителя? Звучит интересно. Предлагаю взглянуть на то, как это реализовано.

Первое, что кажется интересным — создание дескриптора класса.

Дескриптор — это объект класса ObjectStreamClass. Именно в нём выполняется создание десериализуемого объекта и восстановление его состояния. Он содержит поля, описывающие наш сериализуемый класс. Например, количество примитивных и непримитивных полей класса, объект конструктора для восстанавливаемого класса. Кстати, именно через этот конструктор будет создан наш десериализуемый объект.

Предлагаю взглянуть на конструктор дескриптора:

private ObjectStreamClass(final Class cl) {
    ....
    if (externalizable) {
        cons = getExternalizableConstructor(cl);
    } else {
        cons = getSerializableConstructor(cl);
        ....
    }
    ....
}

Привёл я этот фрагмент для того, чтобы наглядно показать, что в зависимости от типа сериализации конструктор восстанавливаемого объекта создаётся по-разному.

Давайте теперь посмотрим, как создаётся десериализуемый объект. Поскольку восстанавливается Serializable объект, мы заглянем в метод getSerializableConstructor. Оттуда нас сразу отправляют в метод newConstructorForSerizalization класса ReflectionFactory.

Код вышеупомянутого метода:

public final Constructor newConstructorForSerialization(Class cl) {
    Class initCl = cl;
    while (Serializable.class.isAssignableFrom(initCl)) {
        Class prev = initCl;
        if ((initCl = initCl.getSuperclass()) == null ||
            (!disableSerialConstructorChecks && 
            !superHasAccessibleConstructor(prev))) {
            return null;
        }
    }
    Constructor constructorToCall;
    try {
        constructorToCall = initCl.getDeclaredConstructor();
        int mods = constructorToCall.getModifiers();
        if ((mods & Modifier.PRIVATE) != 0 ||
                ((mods & (Modifier.PUBLIC | Modifier.PROTECTED)) == 0 &&
                        !packageEquals(cl, initCl))) {
            return null;
        }
    } catch (NoSuchMethodException ex) {
        return null;
    }
    return generateConstructor(cl, constructorToCall);
}

Именно этот метод ищет первого несериализуемого предка и возвращает его конструктор.

И небольшой фрагмент из Javadoc этого метода:

Returns a constructor that allocates an instance of cl and that then initializes the instance by calling the no-arg constructor of its first non-serializable superclass. This is specified in the Serialization Specification, section 3.1, in step 11 of the deserialization process. If cl is not serializable, returns cl’s no-arg constructor. If no accessible constructor is found, or if the class hierarchy is somehow malformed (e. g., a serializable class has no superclass), null is returned.

Мы видим, что метод возвращает результат выполнения generateConstructor, в который мы передаём класс десериализуемого объекта и no-args конструктор первого несериализуемого предка. Предлагаю взглянуть:

private final Constructor generateConstructor(
                Class cl,
                Constructor constructorToCall) {

    ConstructorAccessor acc = new MethodAccessorGenerator().
        generateSerializationConstructor(
              cl,
              constructorToCall.getParameterTypes(),
              constructorToCall.getExceptionTypes(),
              constructorToCall.getModifiers(),
              constructorToCall.getDeclaringClass());

    Constructor c = newConstructor(
        constructorToCall.getDeclaringClass(),                            
        constructorToCall.getParameterTypes(),
        constructorToCall.getExceptionTypes(),
        constructorToCall.getModifiers(),
        langReflectAccess().
        getConstructorSlot(constructorToCall),
        langReflectAccess().
        getConstructorSignature(constructorToCall),
        langReflectAccess().
        getConstructorAnnotations(constructorToCall),
        langReflectAccess().
        getConstructorParameterAnnotations(constructorToCall));

    setConstructorAccessor(c, acc);
    c.setAccessible(true);
    return c;
}

В случае со вторым блоком всё понятно: мы создаём объект конструктора, но что идёт перед ним? Здесь всё сводится к понятию ConstructorAccessor.

ConstructorAccessor — это интерфейс, чьей реализации делегируется процесс создания объекта. Для наглядности покажу метод newInstance класса Constructor:

public T newInstance(Object... initargs) .... {
    ....
    ConstructorAccessor ca = constructorAccessor;   // read volatile
    if (ca == null) {
        ca = acquireConstructorAccessor();
    }
    @SuppressWarnings("unchecked")
    T inst = (T) ca.newInstance(initargs);
    return inst;
}

Код метода, в котором создаётся ConstructorAccessor:

public SerializationConstructorAccessorImpl
generateSerializationConstructor(Class declaringClass,
                                 Class[] parameterTypes,
                                 Class[] checkedExceptions,
                                 int modifiers,
                                 Class targetConstructorClass)
{
    return (SerializationConstructorAccessorImpl)
        generate(declaringClass,
                 "",
                 parameterTypes,
                 Void.TYPE,
                 checkedExceptions,
                 modifiers,
                 true,
                 true,
                 targetConstructorClass);
}

Что такое SerializationConstructorAccessorImpl?

Перейдём к его определению:

abstract class SerializationConstructorAccessorImpl
    extends ConstructorAccessorImpl {
}

Как вы можете видеть, класс полностью пустой. Но ясность вносит Javadoc:

Java serialization (in java. io) expects to be able to instantiate a class and invoke a no-arg constructor of that class’s first non-Serializable superclass. This is not a valid operation according to the VM specification; one can not (for classes A and B, where B is a subclass of A) write «new B; invokespecial A ()» without getting a verification error.

In all other respects, the bytecode-based reflection framework can be reused for this purpose. This marker class was originally known to the VM and verification disabled for it and all subclasses, but the bug fix for 4486457 necessitated disabling verification for all of the dynamically-generated bytecodes associated with reflection. This class has been left in place to make future debugging easier.

То есть SerializationConstructorAccessorImpl изначально создавался как «маркер» для JVM. Он сообщает, что в случае, если объект создали через конструктор его предка, ругаться не нужно. Именно благодаря тому, что наш ConstructorAccessor является SerializationConstructorAccessorImpl, при десериализации возможно создавать объект таким интересным образом.

Подведём итог для всего вышесказанного: десериализуемый объект создаётся не через свой конструктор, а через конструктор своего первого несериализуемого предка. И это довольно логично, ведь десериализуемый объект и его предки, поддерживающие сериализацию, восстанавливаются из потока байтов. Для них выполнять код из конструкторов и инициализаторов бессмысленно. При этом у нас остаются несериализуемые предки. Чтобы их инициализировать, мы вызываем конструктор первого несериализуемого родителя восстанавливаемого объекта.

А можно ли повлиять на процесс сериализации?

Несмотря на то, что сериализация через Serializable воспринимается как сугубо автоматический процесс, ситуация обстоит иным образом.

Исходя из документации, можно сделать вывод, что мы можем определить в нашем сериализуемом классе методы, в которых происходит управление процессами сериализации и десериализации. Вот пример двух из них:

  • writeObject — метод, вызываемый при сериализации. В нём осуществляется запись данных в поток;

  • readObject — метод, вызываемый при десериализации. В нём осуществляется инициализация полей объекта значениями из потока.

Предлагаю посмотреть, как мы можем их использовать.

Я добавил в наш изначальный класс методы readObject и writeObject:

public class Target implements Serializable {

    private int integerField;
    private String stringField;
    private FieldOfTargetClass field;
    private transient int transientField;

    // тот же конструктор, что и в примере использования Serializable

    private void writeObject(ObjectOutputStream out) throws .... {
        System.out.println("writeObject was executed");
        out.defaultWriteObject();
        out.writeInt(transientField);
    }

    private void readObject(ObjectInputStream input) throws ....{
        System.out.println("readObject was executed");
        input.defaultReadObject();
        this.transientField = input.readInt();
    }

    // тот же метод toString
}

Код, в котором осуществляется сериализация и десериализация:

public static void main(String[] args) throws .... {
    Target target = new Target(112, "bzzz", 
        new FieldOfTargetClass(13, "Friday"), 52);
    System.out.println(target);

    var fileOutput = new FileOutputStream("ser_obj");
    var objectOutput = new ObjectOutputStream(fileOutput);
    objectOutput.writeObject(target);
    fileOutput.flush();
    objectOutput.flush();

    var fileInput = new FileInputStream("ser_obj");
    var objectInput = new ObjectInputStream(fileInput);
    Target deserializeTarget = (Target) objectInput.readObject();
    fileInput.close();
    objectInput.close();
    
    System.out.println(deserializeTarget);
}

Давайте посмотрим, что будет выведено в консоль:

{Target = integerField: 112, stringField: bzzz, transientField: 52, customObjectField:

{FieldOfTargetClass = fieldId: 13, fieldString: 'Friday'}}

writeObject was executed

readObject was executed

{Target = integerField: 112, stringField: bzzz, transientField: 52, customObjectField:

{FieldOfTargetClass = fieldId: 13, fieldString: 'Friday'}}

Здесь хотелось бы заострить внимание на двух вещах. Во-первых, мы сериализовали поле transient. Ключевое слово transient служит маркером для автоматического механизма сериализации. Однако записывать и считывать его значения можно. Именно это мы и сделали в примере выше. Во-вторых, считывать поля в методе readObject нужно в том же порядке, в котором мы их записывали в методе writeObject.

Externalizable

Его главное отличие от Serializable в том, что он не является маркерным интерфейсом. Реализуя Externalizable, необходимо переопределить следующие методы:

  • writeExtrenal (ObjectOutput out) — в нём мы записываем в объект out значения сериализуемых полей;

  • readExternal (ObjectInput input) — в нём мы восстанавливаем объект, присваивая ранее сериализованным полям значения из объекта input.

Условия сериализации через Externalizable:

  • имплементировать интерфейс Externalizable;

  • переопределить методы readExternal и writeExternal;

  • поля, участвующие в сериализации, должны её поддерживать;

  • класс должен иметь публичный конструктор без аргументов.

Здесь четвёртое условие, как и в случае с Serializable, вытекает из особенностей десериализации. Их мы также рассмотрим чуть позже, для начала — примеры.

Я изменил классы, которые использовал в примере с Serializable.

Код, отвечающий за сериализацию/десериализацию, не изменился. Поэтому для читаемости я его опущу и оставлю только консольный вывод.

Класс, который мы сериализуем:

public class Target implements Externalizable {

    private int integerField;
    private String stringField;
    private FieldOfTargetClass field;
    private transient int transientField;

    public Target() { }

    public Target(int i, String s, FieldOfTargetClass f, int trn) {
        this.integerField = i;
        this.stringField = s;
        this.field = f;
        this.transientField = trn;
    }

    @Override
    public void writeExternal(ObjectOutput out) throws .... {
        out.writeInt(integerField);
        out.writeUTF(stringField);
        out.writeObject(field);
        out.writeInt(transientField);
    }

    @Override
    public void readExternal(ObjectInput in) throws .... {
        this.integerField = in.readInt();
        this.stringField = in.readUTF();
        this.field = (FieldOfTargetClass) in.readObject();
        this.transientField = in.readInt();
    }

    // метод toString тот же, что и в примере с Serializable
    ....
}

FieldOfTargetClass:

public class FieldOfTargetClass implements Externalizable {

    private int fieldId;
    private String fieldString;

    public FieldOfTargetClass() { }

    public FieldOfTargetClass(int i, String s) {
        this.fieldId = i;
        this.fieldString = s;
    }

    @Override
    public void writeExternal(ObjectOutput out) throws .... {
        out.writeInt(fieldId);
        out.writeUTF(fieldString);
    }

    @Override
    public void readExternal(ObjectInput in) throws .... {
        this.fieldId = in.readInt();
        this.fieldString = in.readUTF();
    }

    // метод toString тот же, что и в примере с Serializable
    .... 
}

Вывод в консоль при сериализации:

{Target = integerField: 112, stringField: bzzz, transientField: 52, customObjectField:

{FieldOfTargetClass = fieldId: 13, fieldString: 'Friday'}}

Вывод в консоль при десериализации:

{Target = integerField: 112, stringField: bzzz, transientField: 52, customObjectField:

{FieldOfTargetClass = fieldId: 13, fieldString: 'Friday'}}

Как и в случае с методами readObject и writeObject, которые мы определили в Serializable, стоит помнить, что чтение полей осуществляется в том же порядке, в котором мы их записываем в поток. Поскольку мы сериализуем поля вручную, записывая их значения в поток, поле transient мы тоже можем сериализовать.

Хотелось бы заострить ваше внимание на том, что наследники класса, реализующего Externalizable, сами являются Externalizable. Из этого следует, что на них также распространяется требование о наличии public no-arg конструктора.

Снова копнём немного глубже

Здесь мы рассмотрим, как происходит десериализация Externalizable объекта.

Вернёмся к созданию конструктора объекта в дескрипторе и взглянем на getExternalizableConstructor:

private static Constructor getExternalizableConstructor(Class cl) {
    try {
        Constructor cons = cl.getDeclaredConstructor((Class[]) null);
        cons.setAccessible(true);
        return ((cons.getModifiers() & Modifier.PUBLIC) != 0) ?
            cons : null;
    } catch (NoSuchMethodException ex) {
        return null;
    }
}

Методу мы передаём класс десериализуемого объекта. Он возвращает его конструктор без аргументов. Именно этот конструктор создаст нам объект.

Состояние объекта восстанавливается в методе readExternalData класса ObjectInputStream:

private void readExternalData(Externalizable obj, 
                              ObjectStreamClass desc) throws .... {
    ....
    if (obj != null) {
        try {
            obj.readExternal(this);
        } catch (ClassNotFoundException ex) {
            ....
        }
    }
    ....
}

Заключение

В этой статье мы с вами одним глазком заглянули за кулисы сериализации в Java. Моей целью было рассказать вам об её основах и интересных моментах, оперируя документацией и самим Java кодом. Однако стоит помнить, что сериализация содержит ещё много интересных моментов, о которых можно было бы рассказать. Увы, при всём моём желании я не смогу уместить их все в одну статью.

Думаю, пора заканчивать! Если у вас есть мысли, которыми вы хотите поделиться, буду рад пообщаться в комментариях.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Vladislav Bogdanov. Java serialization: let’s dig it up.

© Habrahabr.ru