Сериализация в JVM, Android и Kotlin Multiplatform: полный разбор Serializable, Externalizable, Parcelable и kotlinx.serialization

Архитектурное исследование разных путей превращения объектов в байты. Анализируем четыре фундаментальных подхода на основе бенчмарков: рефлексивную автоматизацию Serializable, ручное управление Externalizable, платформенную оптимизацию Parcelable и универсальную кодогенерацию kotlinx.serialization. Изучаем не только как работают эти механизмы, но почему они были созданы именно так, какие компромиссы заложены в их архитектуру и как они проявляют себя в разных средах, от серверной JVM до мобильного Android и кроссплатформенного Kotlin.

45мин чтенияJava
Share:

Введение

В этой статье мы рассмотрим технологии сериализации и десериализации данных в контексте экосистемы Android, Java, Kotlin JVM и Kotlin Native и KMP.

Сериализация и десериализация являются фундаментальными операциями в современной разработке. Эти процессы используются повсеместно: от хранения данных в локальном хранилище устройства до передачи информации по сети. Многие NoSQL базы данных применяют собственные форматы сериализации для оптимизации производительности. Наиболее распространённые форматы обмена данными (JSON, XML, Protocol Buffers, MessagePack и другие) тесно связаны с процессами сериализации.

Существует два основных подхода к реализации сериализации. Первый основан на рефлексии (reflection), которая позволяет анализировать структуру объектов во время выполнения (runtime), но сопряжена с накладными расходами на производительность. Второй подход использует кодогенерацию во время компиляции (compile-time), что значительно ускоряет обработку данных за счёт генерации специализированного кода. Например, Protocol Buffers требует предварительного описания контракта данных в .proto файлах, что позволяет генерировать оптимизированный код и устранять избыточные операции при десериализации.

При оценке решений для сериализации обычно приоритетным является скорость обработки, затем размер сериализованных данных, и лишь потом потребление оперативной памяти. Впрочем, для мобильных устройств и встраиваемых систем баланс этих факторов может смещаться в пользу минимизации памяти и размера данных.

В рамках данной статьи мы проанализируем существующие библиотеки и подходы к сериализации объектов в JVM и Native экосистемах. Мы детально рассмотрим четыре основных подхода: классический Serializable из Java, его расширение Externalizable, специфичный для Android Parcelable, а также современный kotlinx.serialization. Для каждого из них мы проведём сравнительное тестирование производительности и выясним, какие решения являются наиболее эффективными в различных сценариях использования. Помимо получения конкретных метрик, мы также исследуем технические причины, определяющие производительность каждого подхода.

Разработчики часто утверждают, что Parcelable лучше Serializable, но не всегда могут аргументировать, в чём именно заключается это преимущество: в скорости выполнения, использовании памяти или размере данных. В последнее время набирает популярность миграция на kotlinx.serialization. С какой целью? И почему многие даже не слышали о существовании Externalizable?

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

На собеседованиях нередко звучат поверхностные утверждения: «X быстрее Y» или «Z лучше W». Но когда вопрос углубляется: «Почему быстрее? Как это работает под капотом?», зачастую следует пауза. Знание оказывается заимствованным из заголовков статей или чужих мнений.

Даже если в документации указано, что один подход превосходит другой, важно самостоятельно проверять и анализировать производительность в контексте ваших задач. В разработке, как и в инженерии, всё относительно.

Приступая теперь к анализу, сначала для единообразия терминологии начнём с определения базовых понятий и терминов.

Базис

Сериализация - это процесс преобразования структурированных данных (объектов, структур данных) в последовательность байтов или текстовое представление, пригодное для хранения или передачи. Сериализация «упаковывает» состояние объекта в формат, который можно сохранить в файл, передать по сети или поместить в базу данных.

Десериализация - это обратный процесс восстановления объекта из его сериализованного представления. Десериализация «распаковывает» последовательность байтов или текст обратно в структурированный объект с сохранением его типа и данных.

Описание фото

Рефлексия (Reflection) - это механизм времени выполнения, позволяющий программе анализировать и модифицировать свою собственную структуру и поведение. В контексте сериализации рефлексия используется для динамического обхода полей объектов без предварительной генерации кода. Естественно неформально рефлексия в мире разработки считается темной магией.

Кодогенерация (Code Generation) - это автоматическое создание исходного кода во время компиляции на основе аннотаций, схем данных или других метаданных. Кодогенерация устраняет накладные расходы рефлексии за счёт создания специализированных классов-сериализаторов. Сама кодогенерация достигается благодаря плагинам компилятора или анализаторов кода. В мире JVM это долгое время было APT, позже KAPT, далее эволюция Kotlin подарила KSP. Начиная с Kotlin 2.0 теперь так же есть возможность реализовать плагины компилятора, которая ранее была закрыта для сторонних разработчиков.

Описание фото

Контракт данных (Data Contract/Schema) - это формальное описание структуры данных, определяющее типы полей, их названия и правила валидации. Используется в Protocol Buffers (.proto файлы), Apache Avro и других схемо-ориентированных форматах.

Описание фото

Теперь, когда мы определились с базовой терминологией, перейдем к рассмотрению конкретных способов сериализации. Мы рассмотрим их в порядке исторической эволюции с точки зрения Kotlin и Android. Что интересно, в мире Java эволюции как таковой не произошло: первое решение, появившееся еще в ранних версиях JDK, продолжает использоваться и по сей день. Сначала мы пройдемся по каждому из подходов, разберем их под лупой, но никаких выводов по производительности делать не будем пока не рассмотрим все способы, в конце мы сравним каждый из подходов что бы понять какой из них оптимальнее в использований.

Интерфейс Serializable

Начнем наше погружение с интерфейса Serializable, который является самым первым и, пожалуй, наиболее распространенным способом сериализации в мире JVM. Serializable представляет собой интерфейс-маркер, то есть интерфейс, не содержащий никакой логики. Рассмотрим его исходный код:

package java.io;

public interface Serializable {
}

Как видите, интерфейс абсолютно пуст. Возникает логичный вопрос: как же пустой интерфейс может что-то делать? На самом деле, Serializable работает как маркер для JVM, сигнализирующий, что класс разрешает свою сериализацию. Вся магия происходит на уровне runtime через рефлексию, о чем мы подробно поговорим дальше.

Давайте создадим простой класс и посмотрим, как работает сериализация на практике. Для примера возьмем класс Person с базовой информацией о человеке:

data class Person( 
    val name: String, 
    val dateOfBirth: Int, 
    val address: String
): Serializable

Обратите внимание, что для превращения обычного класса в сериализуемый нам достаточно добавить : Serializable после объявления класса. Никаких дополнительных методов или полей реализовывать не нужно.

Теперь давайте попробуем сериализовать объект этого класса и сохранить его в файл. Процесс будет выглядеть следующим образом:

fun main(args: Array<String>) {
    val person = Person("John Wick", 1964, "New York")
    val file = File("serialization.bin").apply(File::createNewFile)

    val fileOutputStream = FileOutputStream(file)
    val objectOutputStream = ObjectOutputStream(fileOutputStream).use { stream ->
        stream.writeObject(person)
        stream.flush()
    }
}

Что здесь происходит? Мы создаём объект Person с данными о John Wick, затем создаём файл serialization.bin, открываем поток(FileOutputStream) для записи в этот файл и, наконец, оборачиваем его в ObjectOutputStream. Именно вызов writeObject(person) запускает весь механизм сериализации, превращая наш объект в последовательность байтов.

После выполнения кода мы получим файл serialization.bin с нашими сериализованными данными. Из любопытства давайте попробуем открыть этот файл в текстовом редакторе. Вот что мы увидим:

���sr�application.Person2�v��9��I�dateOfBirthL�addresst�Ljava/lang/String;L�nameq�~�xp���t�New Yorkt�	John Wick

Выглядит жутковато, не правда ли? Файл явно не предназначен для чтения человеком из-за бинарного формата, и текстовый редактор с трудом пытается интерпретировать байты как UTF-8 символы. Тем не менее, если присмотреться, среди “кракозябр” можно разглядеть вполне читаемые фрагменты: названия полей (name, address), их типы (Ljava/lang/String), название класса (Person) и даже значения полей (New York, John Wick).

Вы можете задаться вопросом: если сериализация представляет собой превращение объекта в байты, то почему мы видим не нули и единицы, а относительно читаемый текст? Ответ прост: текстовый редактор пытается интерпретировать байты как символы UTF-8. Некоторые байтовые последовательности случайно совпадают с кодами печатных символов, поэтому мы и видим эти фрагменты текста среди непонятных символов.

Но откуда же в файле взялась вся эта информация о структуре нашего класса? Мы ведь нигде явно не указывали, какие поля сохранять и как их называть. Здесь начинается самое интересное: Serializable полностью работает на основе Reflection API. JVM автоматически анализирует структуру нашего класса во время выполнения и извлекает всю необходимую метаинформацию.

Рефлексия и вся магия Serializable проявляется с момента создания ObjectOutputStream, а если быть точнее, с момента вызова функции writeObject. Именно в этом методе происходит вся основная работа: он берет наш объект класса Person, сериализует его и затем записывает в файл serialization.bin.

Важно понимать архитектуру: сам ObjectOutputStream на самом деле ничего не знает о нашем файле serialization.bin. Вместо этого ObjectOutputStream работает с FileOutputStream, который и является посредником между файлом и объектом. Это классический паттерн проектирования “Декоратор” (Decorator). На самом деле, большинство видов Stream-ов (наследники InputStream и OutputStream) являются декораторами, добавляющие дополнительную функциональность к базовому потоку(stream).

Процесс десериализации работает зеркально:

fun main(args: Array<String>) {
    val file = File("serialization.bin")
    val fileInputStream = FileInputStream(file)
    val objectInputStream = ObjectInputStream(fileInputStream).use { stream ->
        print(stream.readObject() as Person)
    }
}

Мы находим файл и затем передаем его в FileInputStream, чтобы восстановить объект Person из сохраненного набора байтов. Давайте убедимся, что интерфейс Serializable действительно необходим. Если мы уберем у класса Person наследование от интерфейса Serializable, то получим такую ошибку:

Exception in thread "main" java.io.NotSerializableException: Person

Эта ошибка подтверждает, что маркерный интерфейс Serializable является обязательным условием для сериализации объекта.

Внутреннее устройство Serializable

Теперь давайте погрузимся глубже и посмотрим, как работает сериализация под капотом. Рассмотрим исходный код метода writeObject класса ObjectOutputStream, ведь именно он принимает объект класса Person и преобразует его в набор байтов:

public class ObjectOutputStream extends OutputStream implements ObjectOutput, ObjectStreamConstants {
    
    public final void writeObject(Object obj) throws IOException {
        if (enableOverride) {
            writeObjectOverride(obj);
            return;
        }
        try {
            writeObject0(obj, false);
        } catch (IOException ex) {
            if (depth == 0) {
                writeFatalException(ex);
            }
            throw ex;
        }
    }
}

Этот метод своего рода Provider, если от класса унаследовались и переопределили открытый метод для переопределения, то будет использована логика наследника, иначе вызов передается методу writeObject0 который является стандартным у ObjectOutputStream, в нашем случае мы не наследовались от ObjectOutputStream поэтому мы попадаем в метод writeObject0:

public class ObjectOutputStream extends OutputStream implements ObjectOutput, ObjectStreamConstants {
    
    private void writeObject0(Object obj, boolean unshared)
            throws IOException
    {
        ...
        ObjectStreamClass desc = ObjectStreamClass.lookup(cl, true);
        ...
        
        if (obj instanceof String) {
            writeString((String) obj, unshared);
        } else if (cl.isArray()) {
            writeArray(obj, desc, unshared);
        } else if (obj instanceof Enum) {
            writeEnum((Enum<?>) obj, desc, unshared);
        } else if (obj instanceof Serializable) {
            writeOrdinaryObject(obj, desc, unshared);
        } else {
            if (extendedDebugInfo) {
                throw new NotSerializableException(
                        cl.getName() + "\n" + debugInfoStack.toString());
            } else {
                throw new NotSerializableException(cl.getName());
            }
        }
        ...
    }
}

Метод writeObject0 содержит обширную логику по проверке объекта (например, проверки на not nullable, no classable, not streamable). В приведенном выше фрагменте оставлена только та часть логики, которая непосредственно отвечает за работу сериализации стандартного объекта.

Обратите внимание на важную строку, которую мы специально оставили в коде: создание объекта ObjectStreamClass. Этот объект играет ключевую роль во всем процессе сериализации. Рассмотрим подробнее вызов ObjectStreamClass.lookup(cl, true). Именно здесь начинается реальная работа сериализации. Этот метод создает дескриптор класса (объект ObjectStreamClass), который фиксирует всю метаинформацию о типе, необходимую для его записи в поток. По сути, это сериализационный аналог рефлексии, но не на уровне исполнения, а на уровне протокола (контракта).

Метод lookup сначала проверяет внутренний кэш. Если дескриптор для данного класса уже существовал, он возвращается повторно. Если нет, то создается новый. В процессе создания выполняется полный анализ структуры класса: определяются сериализуемые поля объекта, вычисляется serialVersionUID, проверяется наличие специальных методов (writeObject, readObject, readResolve, writeReplace), устанавливается связь с родительским дескриптором, фиксируются флаги, указывающие на природу класса (enum, proxy, externalizable, record).

Параметр true во втором аргументе означает, что дескрипторы создаются не только для самого класса, но и для всей цепочки его сериализуемых предков. Это важно: сериализация в Java всегда знает структуру объекта вплоть до первого не-Serializable родителя, и именно lookup формирует эту иерархию.

Результатом вызова становится объект desc, который будет передан в writeOrdinaryObject. Все дальнейшие шаги (запись сигнатуры, serialVersionUID, набора полей и их значений) выполняются строго в соответствии с тем, что описано в этом дескрипторе. Если дескриптор изменить, изменится и байтовое представление.

Таким образом, ObjectStreamClass.lookup является точкой перехода от уровня кода к уровню протокола сериализации. До этого момента JVM работала с объектом как с экземпляром типа, после же работает с набором описанных структур и байтов, имея всю информацию о том, кто перед ним сейчас. Сам по себе этот вызов в первый раз занимает очень много времени и памяти, это первый залп использования рефлексии для сериализации.

Вернемся к методу writeObject0 и рассмотрим последовательность проверок, которые он выполняет. После создания дескриптора класса начинается каскад проверок типа объекта:

  1. Сначала проверяется, является ли объект строкой (String). Строковые значения обрабатываются специальным образом, так как сериализуются более эффективно.
  2. Далее проверяется, является ли класс массивом.
  3. После идет проверка на enum.

Любой enum в JVM является Serializable по умолчанию, все потому что все enum классы не явно наследуются от настоящего класса java.lang.Enum. А класс java.lang.Enum уже наследуется от интерфейса Serializable

  1. Наконец, если объект не подпадает ни под одну из специальных категорий, проверяется, реализует ли он интерфейс Serializable.

По итогу всех этих проверок происходит одно из двух: либо мы попадаем под категорию Serializable и продолжаем сериализацию, либо получаем ошибку NotSerializableException.

Для классов, реализующих Serializable, вызывается метод writeOrdinaryObject. После того как writeObject0 определяет, что данный объект действительно реализует интерфейс Serializable, управление передаётся в этот метод. Именно он отвечает за запись «обычного» объекта в поток(stream), то есть объекта, не являющегося строкой, массивом, перечислением или экземпляром Externalizable(про него будет далее в статье). В этом месте начинается реальная работа сериализационного механизма. Метод выглядит следующим образом, далее мы увидим его разбор:

public class ObjectOutputStream extends OutputStream implements ObjectOutput, ObjectStreamConstants {
    
    private void writeOrdinaryObject(Object obj,
                                     ObjectStreamClass desc,
                                     boolean unshared)
            throws IOException
    {
        ...
            desc.checkSerialize();

            bout.writeByte(TC_OBJECT);
            writeClassDesc(desc, false);
            handles.assign(unshared ? null : obj);

            if (desc.isRecord()) {
                writeRecordData(obj, desc);
            } else if (desc.isExternalizable() && !desc.isProxy()) {
                writeExternalData((Externalizable) obj);
            } else {
                writeSerialData(obj, desc);
            }
        }
        ...
    }
}

Первое, что делает writeOrdinaryObject, это вызов desc.checkSerialize(). Этот вызов не просто формальность, а гарантия того, что класс, описанный ObjectStreamClass desc, удовлетворяет всем требованиям контракта сериализации. Здесь происходит верификация флага Serializable, проверка корректности сигнатуры специальных методов writeObject, readObject, readResolve, writeReplace, а также проверка соответствия вычисленного serialVersionUID тому, что объявлен явно. Если класс нарушает контракт, поток прерывается исключением NotSerializableException. Таким образом, сериализация никогда не начнётся для объекта, который не способен пройти эти структурные проверки.

После успешной валидации в поток записывается первый управляющий байт TC_OBJECT. Этот байт представляет собой сериализационный маркер, используемый ObjectInputStream при чтении потока для распознавания того, что далее следует именно объектная структура, а не строка, массив, ссылка или иной тип элемента. Механизм Object Serialization в Java использует фиксированный бинарный протокол, где каждый элемент (объект, класс, поле, массив и т.д.) предваряется своим маркером. Для объекта это 0x73, то есть байт TC_OBJECT. Таким образом, запись объекта всегда начинается с этого маркера.

Следом вызывается writeClassDesc(desc, false). В этом месте сериализация переходит от конкретного экземпляра к описанию его класса. Метод writeClassDesc отвечает за запись дескриптора класса, то есть структуры, содержащей имя класса, serialVersionUID, количество и типы сериализуемых полей, а также ссылки на суперклассы. Если этот дескриптор уже встречался ранее в потоке, то вместо полного описания записывается ссылка TC_REFERENCE, указывающая на уже существующий дескриптор. Это экономит место и поддерживает консистентность структуры потока. Если же класс сериализуется впервые, то в поток последовательно записываются имя, версия, список полей и другие метаданные. Именно благодаря writeClassDesc десериализация на другой стороне способна понять, как восстанавливать объект: какой класс использовать, какие поля прочитать и в каком порядке.

После записи дескриптора выполняется handles.assign(unshared ? null : obj). Это ключевой момент, связанный с таблицей хэндлов (handle table). Object Serialization в Java гарантирует сохранение ссылочной целостности: если один и тот же объект встречается несколько раз в графе, сериализатор не будет писать его заново, а запишет ссылку (TC_REFERENCE) на уже записанный экземпляр. Для этого все объекты, прошедшие через поток, регистрируются в таблице хэндлов, где каждому объекту присваивается уникальный идентификатор. Вызов assign выполняет именно это назначение. Если же объект помечен как unshared, то он не регистрируется, и в дальнейшем на него нельзя будет сослаться повторно. Такой флаг используется редко, но имеет значение при необходимости полного разрыва ссылочной связанности между частями сериализуемого графа.

На следующем этапе writeOrdinaryObject определяет конкретную природу класса. Здесь начинается развилка на три категории:

  1. Если класс является record, вызывается writeRecordData.
  2. Если класс реализует Externalizable (и при этом не является proxy), вызывается writeExternalData.
  3. Во всех остальных случаях используется классическая ветвь writeSerialData.

Рассмотрим их последовательно.

1. Record. Если desc.isRecord() возвращает true, то текущий класс является Java Record. Сериализация record-классов подчиняется отдельной логике, введённой начиная с Java 16. В отличие от обычных классов, record не имеет изменяемого состояния, все его поля являются компонентами, определёнными в сигнатуре конструктора. Метод writeRecordData проходит по всем компонентам record в порядке их объявления и записывает их значения напрямую, без вызова writeObject или writeExternal. Это обеспечивает стабильный, детерминированный формат сериализации, независимый от пользовательских переопределений.

Если вы из той самой(старой) школы, где последняя знакомая версия Java это 8 или 11, то record-классы могли пройти мимо вас. Они появились только с Java 16 и стали для Java тем, чем data class давно является для Kotlin, то есть лаконичным способом объявить неизменяемую структуру данных, где конструктор, equals, hashCode и toString генерируются компилятором. Разница лишь в том, что Java сделала это без излишнего синтаксического романтизма.

2. Externalizable. Про Externalizable мы так же подробно пройдемся в этой статье, но пока для контекста его стоит тоже учитывать. Если класс реализует интерфейс Externalizable, управление передаётся в writeExternalData. В этом случае сериализация полностью делегируется самому объекту. Метод writeExternalData вызывает obj.writeExternal(ObjectOutput). Здесь именно класс решает, какие данные и в каком порядке записывать в поток. В отличие от Serializable, где платформа управляет сериализацией автоматически, Externalizable предоставляет полную свободу, но и полную ответственность разработчику. Важно отметить, что writeOrdinaryObject вызывает этот путь только если класс действительно Externalizable и не является proxy, поскольку динамические proxy обрабатываются иначе.

3. Serializable (обычный случай). Если объект не является record и не Externalizable, остаётся классическая сериализация Serializable. В этом случае вызывается writeSerialData(obj, desc). Именно этот метод и есть ядро стандартной сериализации Java. Он отвечает за последовательную запись всех сериализуемых полей объекта, включая унаследованные от суперклассов, а также за вызов пользовательских методов writeObject, если они определены в классе. Внутри writeSerialData сначала записываются данные суперклассов, затем поля текущего класса. Если в классе определён метод private void writeObject(ObjectOutputStream oos), он вызывается с передачей текущего потока, что позволяет переопределить стандартный формат записи. Если такого метода нет, вызывается defaultWriteFields, который просто записывает все поля по описанию из ObjectStreamClass desc.

Таким образом, writeOrdinaryObject не содержит непосредственно логики записи самих данных. Он лишь определяет маршрут, то есть какую стратегию применить для конкретного типа класса. Это точка маршрутизации, своего рода диспетчер сериализации, обеспечивающий единообразие протокола при сохранении гибкости для различных типов.

После выполнения одной из ветвей (record, externalizable или serializable) объект полностью записан в поток, а таблица хэндлов зафиксирована для поддержания ссылочной целостности. Нас особенно интересует третья ветвь, то есть обычная сериализация через Serializable. Давайте рассмотрим метод writeSerialData более подробно:

public class ObjectOutputStream extends OutputStream implements ObjectOutput, ObjectStreamConstants {

    private void writeSerialData(Object obj, ObjectStreamClass desc)
            throws IOException
    {
        ObjectStreamClass.ClassDataSlot[] slots = desc.getClassDataLayout();
        for (int i = 0; i < slots.length; i++) {
            ObjectStreamClass slotDesc = slots[i].desc;
            if (slotDesc.hasWriteObjectMethod()) {
                PutFieldImpl oldPut = curPut;
                curPut = null;
                SerialCallbackContext oldContext = curContext;

                try {
                    curContext = new SerialCallbackContext(obj, slotDesc);
                    bout.setBlockDataMode(true);
                    slotDesc.invokeWriteObject(obj, this);
                    bout.setBlockDataMode(false);
                    bout.writeByte(TC_ENDBLOCKDATA);
                } finally {
                    curContext.setUsed();
                    curContext = oldContext;
                }

                curPut = oldPut;
            } else {
                defaultWriteFields(obj, slotDesc);
            }
        }
    }

}

Как мы помним из предыдущего раздела, этот метод вызывается для объектов, которые не являются ни record, ни externalizable, а представляют собой обычные классы, реализующие Serializable. Метод writeSerialData является тем местом, на который в большинстве случаев и замыкается сериализация. Если writeOrdinaryObject можно назвать маршрутизатором, то writeSerialData является исполнителем, именно здесь происходит фактическая запись состояния объекта.

В начале метода формируется массив slots, получаемый через desc.getClassDataLayout(). Это внутреннее представление иерархии классов, участвующих в сериализации. Каждый элемент массива представляет собой ClassDataSlot, содержащий ссылку на ObjectStreamClass конкретного уровня наследования. Таким образом, slots задаёт строгий порядок обхода цепочки классов сверху вниз, от самого предка, объявившего сериализуемые поля, до конечного потомка.

Далее выполняется цикл по этим слотам. Для каждого класса, представленного в slotDesc, сериализатор проверяет, определён ли в нём пользовательский метод writeObject(ObjectOutputStream). Проверка осуществляется вызовом slotDesc.hasWriteObjectMethod(). Это та самая возможность, которая позволяет классу вмешаться в процесс сериализации и частично управлять тем, какие данные и в каком виде попадут в поток.

Если пользовательский writeObject найден, то создаётся контекст сериализационного коллбэка, представляющий собой объект SerialCallbackContext. Он необходим для корректного управления вложенными вызовами, в частности для обеспечения симметричной работы с readObject при десериализации. После этого включается блочный режим записи (bout.setBlockDataMode(true)), который группирует данные, записанные в процессе пользовательского writeObject, в единый блок. Это гарантирует, что весь пользовательский сегмент данных будет интерпретирован при чтении как одно логическое целое.

Далее вызывается сам метод slotDesc.invokeWriteObject(obj, this). Это точка, где фактическое управление передаётся пользовательскому коду. Если класс переопределил writeObject, его логика выполняется здесь, с возможностью напрямую вызывать defaultWriteObject() или вручную записывать отдельные поля. После завершения блока запись возвращается в нормальный режим (bout.setBlockDataMode(false)), а затем в поток добавляется байт TC_ENDBLOCKDATA, обозначающий конец пользовательских данных.

Все временные структуры (curPut, curContext) восстанавливаются, чтобы состояние сериализатора осталось консистентным. Если же extendedDebugInfo включён, стек отладочной информации очищается. В случае отсутствия пользовательского метода выполняется стандартный путь: defaultWriteFields(obj, slotDesc). Этот метод последовательно проходит по всем полям, определённым в ObjectStreamClass, и записывает их значения с использованием соответствующих механизмов сериализации (для примитивов это прямое бинарное значение, для ссылочных типов это рекурсивный вызов writeObject0).

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

Контроль над процессом сериализации

Теперь поговорим о нескольких важных механизмах, которые позволяют контролировать процесс сериализации. Представьте ситуацию: вы сериализовали объект и сохранили его в файл. Спустя месяц вы изменили класс, добавили новое поле или удалили старое. Что произойдет, если попытаться десериализовать старый файл? JVM может просто отказаться это делать, выбросив InvalidClassException. Для решения этой проблемы существует специальное поле serialVersionUID. Это уникальный идентификатор версии класса, который записывается в поток при сериализации. При десериализации JVM сравнивает serialVersionUID из потока с serialVersionUID текущего класса. Если они совпадают, десериализация продолжается, если нет, выбрасывается исключение. Если не указывать serialVersionUID явно, JVM вычислит его автоматически на основе структуры класса (имен полей, методов, модификаторов доступа). Проблема в том, что любое малейшее изменение в классе изменит этот хеш, и старые сериализованные объекты станут несовместимыми.

data class Person(
    val name: String,
    val dateOfBirth: Int,
    val address: String
) : Serializable {
    companion object {
        private const val serialVersionUID: Long = 1L
    }
}

Иногда в классе есть поля, которые не нужно или нельзя сериализовать. Например, это могут быть временные вычисляемые значения, кеши или чувствительные данные вроде паролей. В Java для этого существует ключевое слово transient, а в Kotlin используется аннотация @Transient. При сериализации все помеченные поля будут проигнорированы, а при десериализации они получат значения по умолчанию (null для объектов, 0 для чисел, false для boolean).

data class User(
    val username: String,
    @Transient val password: String = "",
    @Transient val cache: Cache? = null
) : Serializable

Что если стандартной сериализации недостаточно? Может быть, вы хотите зашифровать данные перед записью или выполнить какую-то предобработку? Для этого можно переопределить специальные методы writeObject и readObject:

class SecureUser(
    val username: String,
    private var password: String
) : Serializable {
    
    @Throws(IOException::class)
    private fun writeObject(out: ObjectOutputStream) {
        out.defaultWriteObject()
        val encrypted = encrypt(password)
        out.writeObject(encrypted)
    }
    
    @Throws(IOException::class, ClassNotFoundException::class)
    private fun readObject(input: ObjectInputStream) {
        input.defaultReadObject()
        val encrypted = input.readObject() as String
        password = decrypt(encrypted)
    }
    
    companion object {
        private const val serialVersionUID = 1L
    }
}

Обратите внимание, что эти методы должны быть private. Это может показаться странным, ведь обычно private методы не вызываются извне, но JVM использует рефлексию для их вызова.

Еще один интересный случай связан с синглтонами. При десериализации JVM создаст новый экземпляр объекта, нарушив паттерн Singleton. У вас появятся два “синглтона”! Чтобы этого избежать, используется метод readResolve:

object DatabaseConnection : Serializable {
    private const val serialVersionUID = 1L
    
    var host: String = "localhost"
    var port: Int = 5432
    
    private fun readResolve(): Any = DatabaseConnection
}

Метод вызывается сразу после десериализации объекта и может вернуть либо тот же объект, либо совершенно другой. В случае синглтона мы просто возвращаем существующий экземпляр, игнорируя десериализованный. Аналогично работает writeReplace(), который вызывается перед сериализацией и полезен, когда вы хотите сериализовать объект в более компактном виде.

Несмотря на кажущуюся простоту, Serializable таит в себе немало проблем. Использование рефлексии делает сериализацию медленной, каждый раз при сериализации JVM анализирует структуру класса через Reflection API. Бинарный формат Java содержит много метаинформации (имена классов, пакеты, типы полей), что увеличивает размер сериализованных данных. Изменение структуры класса легко ломает совместимость, даже добавление нового метода может изменить автоматически вычисляемый serialVersionUID. Десериализация непроверенных данных может привести к уязвимостям, когда злоумышленник создает специально сформированный поток байтов, который при десериализации выполнит вредоносный код. Наконец, при десериализации JVM создает объект, обходя конструктор, что означает, что любые проверки валидности в конструкторе будут проигнорированы.

При этом, все те возможности, которые мы только что рассмотрели (serialVersionUID, transient, writeObject, readResolve), на самом деле не решают главную проблему. Наше вмешательство минимально. Эти механизмы больше похожи на хуки или стандартные конфигурации, которые позволяют Serializable правильно работать в специфических случаях. Поле serialVersionUID нужно для версионирования, readResolve сохраняет синглтоны, @Transient исключает ненужные поля. Но ни один из этих методов не дает нам реального контроля над процессом сериализации и десериализации. Мы по-прежнему не можем влиять на производительность, не можем оптимизировать размер данных, не можем изменить формат записи. JVM продолжает использовать рефлексию, продолжает записывать всю метаинформацию, продолжает работать медленно. Мы просто пассажиры в этом процессе, которым разрешили настроить пару параметров.

Что если мы хотим большего? Что если нам нужен реальный контроль над тем, как именно сериализуются наши объекты? Для таких случаев у Serializable есть брат на стероидах.

Интерфейс Externalizable

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

public interface Externalizable extends java.io.Serializable {
    void writeExternal(ObjectOutput out) throws IOException;
    void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
}

Обратите внимание, что Externalizable наследуется от Serializable. Это важная деталь, которая означает, что объекты Externalizable по-прежнему участвуют в общем механизме Java сериализации, но с принципиально другим подходом. JVM больше не использует рефлексию для обхода полей. Вместо этого она просто вызывает ваши методы writeExternal и readExternal, полностью перекладывая ответственность на вас. Полностью напоминает Parcelable из Android, не так ли?

Давайте перепишем наш класс Person с использованием Externalizable:

class Person(
    var name: String = "",
    var dateOfBirth: Int = 0,
    var address: String = ""
) : Externalizable {
    
    override fun writeExternal(out: ObjectOutput) {
        out.writeUTF(name)
        out.writeInt(dateOfBirth)
        out.writeUTF(address)
    }
    
    override fun readExternal(input: ObjectInput) {
        name = input.readUTF()
        dateOfBirth = input.readInt()
        address = input.readUTF()
    }
}

Сразу видна первая особенность: класс должен иметь конструктор без параметров. Это критическое требование. При десериализации JVM сначала создает экземпляр класса через этот конструктор, а затем вызывает readExternal для заполнения полей. Если конструктор отсутствует, вы получите InvalidClassException. В Kotlin это решается через параметры со значениями по умолчанию, как показано выше. Далее попробуем так же сериализоваться, на этот раз наш класс реализует Externalizable, по этому фаил назовем “externalization.bin”

fun main(args: Array<String>) {
    val person = Person("John Wick", 1964, "New York")
    val file = File("externalization.bin").apply(File::createNewFile)

    val fileOutputStream = FileOutputStream(file)
    val objectOutputStream = ObjectOutputStream(fileOutputStream).use { stream ->
        stream.writeObject(person)
        stream.flush()
    }
}

Попробуем открыть файл как текстовый, видим что информации гораздо меньше, чем в случае с Serializable. Первым идет полное имя класса. Далее бросается в глаза, что отсутствуют имена полей и явные типы значений, привязанные к классам. За нечитаемым текстом находятся служебные маркеры протокола сериализации и последовательности байтов, соответствующие данным, записанным в writeExternal. Эти маркеры, такие как STREAM_MAGIC, STREAM_VERSION, TC_OBJECT, TC_CLASSDESC, TC_STRING, TC_ENDBLOCKDATA, TC_NULL, TC_REFERENCE, TC_BLOCKDATA и другие, играют роль структурных разделителей, позволяя JVM при десериализации понимать, где начинается и где заканчивается каждый элемент, а также определять их тип и контекст.

���sr�application.Person���O�!��xpw�	John Wick����New Yorkx

Теперь посмотрим, что происходит внутри. Помните метод writeOrdinaryObject из разбора Serializable? Тот самый каскад проверок типа объекта? Там была проверка на Externalizable, и если класс реализует этот интерфейс, управление передается в метод writeExternalData.

Давайте посмотрим на его реализацию:

public class ObjectOutputStream extends OutputStream implements ObjectOutput, ObjectStreamConstants {

    private void writeExternalData(Externalizable obj) throws IOException {
        PutFieldImpl oldPut = curPut;
        curPut = null;

        SerialCallbackContext oldContext = curContext;
        try {
            curContext = null;
            if (protocol == PROTOCOL_VERSION_1) {
                obj.writeExternal(this);
            } else {
                bout.setBlockDataMode(true);
                obj.writeExternal(this);
                bout.setBlockDataMode(false);
                bout.writeByte(TC_ENDBLOCKDATA);
            }
        } finally {
            curContext = oldContext;
        }

        curPut = oldPut;
    }
}

Код выглядит значительно проще, чем вся та сложная машинерия с обходом полей через рефлексию, которую мы видели в writeSerialData. Да, дескриптор класса всё равно создается через ObjectStreamClass.lookup в методе writeOrdinaryObject до вызова writeExternalData, это необходимо для записи информации о самом классе (его имени, иерархии). Но вот чего здесь нет, так это рекурсивного обхода иерархии классов для записи полей каждого уровня, нет вызова defaultWriteFields, который через рефлексию читает значения всех полей. JVM просто вызывает obj.writeExternal(this), передавая управление вашему коду. Вся ответственность за то, какие данные и как записывать, лежит на вас.

Обратите внимание на работу с блочным режимом данных (setBlockDataMode). Это технический момент, который обеспечивает правильную структуру сериализационного потока. В PROTOCOL_VERSION_2 (который используется по умолчанию с Java 1.2) данные записываются блоками, и каждый блок завершается маркером TC_ENDBLOCKDATA. Это позволяет JVM корректно определять границы данных объекта в потоке.

Процесс десериализации работает зеркально. Вместо сложного механизма восстановления полей через рефлексию, JVM создает объект через конструктор без параметров и вызывает readExternal:

public class ObjectOutputStream extends OutputStream implements ObjectOutput, ObjectStreamConstants {

    private void readExternalData(Externalizable obj, ObjectStreamClass desc)
            throws IOException {
        SerialCallbackContext oldContext = curContext;
        if (oldContext != null)
            oldContext.check();
        curContext = null;
        try {
            boolean blocked = desc.hasBlockExternalData();
            if (blocked) {
                bin.setBlockDataMode(true);
            }
            if (obj != null) {
                try {
                    obj.readExternal(this);
                } catch (ClassNotFoundException ex) {
                    handles.markException(passHandle, ex);
                }
            }
            if (blocked) {
                skipCustomData();
            }
        } finally {
            if (oldContext != null)
                oldContext.check();
            curContext = oldContext;
        }
    }
}

Снова видим, насколько это проще по сравнению с Serializable. Нет восстановления метаданных, нет рекурсивного чтения иерархии классов. Вызов obj.readExternal(this) и всё. Вы сами решаете, в каком порядке читать поля и как их интерпретировать.

Здесь важно понимать ключевое различие. При использовании Serializable JVM автоматически записывает в поток метаданные класса (имена полей, типы, информацию о пакетах). Помните тот “грязный” вывод файла serialization.bin, где среди байтов мы видели названия полей и типов? Всё это метаинформация, которую JVM добавляет автоматически. С Externalizable этого не происходит. В поток попадают только те данные, которые вы явно записали. Это делает сериализованные объекты значительно меньше по размеру.

Но с большой силой приходит большая ответственность. Вы должны гарантировать, что порядок записи в writeExternal точно соответствует порядку чтения в readExternal. Если вы запишете сначала String, потом Int, потом String, вы обязаны читать в том же порядке. Любое несоответствие приведет к неправильной десериализации или исключению. JVM больше не следит за этим за вас.

class Person(
    var name: String = "",
    var dateOfBirth: Int = 0,
    var address: String = ""
) : Externalizable {
    
    override fun writeExternal(out: ObjectOutput) {
        out.writeUTF(name)
        out.writeInt(dateOfBirth)
        out.writeUTF(address)
    }
    
    override fun readExternal(input: ObjectInput) {
        name = input.readUTF()          // Порядок совпадает!
        dateOfBirth = input.readInt()   // Порядок совпадает!
        address = input.readUTF()       // Порядок совпадает!
    }
}

Еще один важный момент касается версионирования. С Serializable мы использовали serialVersionUID для контроля совместимости версий. С Externalizable вы можете реализовать собственную логику версионирования:

class Person(
    var name: String = "",
    var dateOfBirth: Int = 0,
    var address: String = "",
    var phoneNumber: String = ""
) : Externalizable {
    
    companion object {
        private const val VERSION = 2
    }
    
    override fun writeExternal(out: ObjectOutput) {
        out.writeInt(VERSION)
        // ... запись остальных полей
        out.writeUTF(phoneNumber)  // Новое поле в версии 2
    }
    
    override fun readExternal(input: ObjectInput) {
        val version = input.readInt()
        // ... чтение остальных полей
        
        if (version >= 2) {
            phoneNumber = input.readUTF()
        }
    }
}

Такой подход дает гибкость в управлении обратной совместимостью. Вы можете добавлять новые поля, изменять формат данных, и всё это будет работать, пока ваша логика в readExternal корректно обрабатывает разные версии.

Производительность Externalizable теоретически выше, чем у Serializable, потому что отсутствует overhead рефлексии. Но это не означает автоматический выигрыш. Если вы пишете неэффективный код в writeExternal или readExternal, производительность может быть хуже. Реальные цифры мы увидим в разделе бенчмарков.

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

От JVM к Android: почему Externalizable не подошел

Мы разобрали оба подхода к сериализации в JVM экосистеме. Serializable дает простоту использования, но платит за это производительностью и избыточностью данных. Externalizable предоставляет контроль, но требует больше кода и внимательности. Казалось бы, идеальное решение найдено, особенно для мобильных устройств, где важны и производительность, и размер данных.

Но когда Google разрабатывал Android, инженеры столкнулись с фундаментальной проблемой. Android это не просто Java на мобильном устройстве. Это экосистема с жесткими ограничениями: ограниченная память, батарея, процессоры с меньшей вычислительной мощностью (на момент создания Android). Но главное, это специфическая архитектура межпроцессного взаимодействия (IPC) через механизм Binder.

Давайте разберемся, в чем проблема. Оба механизма сериализации, которые мы рассмотрели, разрабатывались для JVM с определенными предположениями. Первое: сериализация обычно используется для долговременного хранения или передачи по сети. Второе: overhead создания потоков (ObjectOutputStream, ObjectInputStream) приемлем, потому что данные затем передаются куда-то далеко (на диск, по сети). Третье: формат должен быть совместим между разными версиями Java и даже разными JVM.

В Android всё иначе. Когда вы запускаете новый Activity, передаете данные в Service или отправляете broadcast, это не сетевая операция и не запись на диск. Это IPC между процессами на одном устройстве через Binder. Объекты нужно сериализовать и десериализовать не для отправки в другую страну, а для передачи в соседний процесс. Это происходит постоянно, сотни раз в секунду. Каждый лишний байт, каждая лишняя операция напрямую влияют на отзывчивость интерфейса.

Попробуйте использовать Serializable для передачи Intent с данными между Activity. Это работает, Android поддерживает это. Но за кулисами происходит следующее: создается ObjectOutputStream, запускается механизм рефлексии (даже несмотря на Dalvik/ART, это всё равно медленно), записываются метаданные класса, создается множество временных объектов, создается ObjectInputStream на другой стороне, запускается обратная рефлексия, снова создаются временные объекты. И вот здесь начинается настоящая проблема для Android. Каждый временный объект это работа для Garbage Collector. На старых Android устройствах с ограниченной памятью и примитивным GC паузы сборки мусора напрямую влияют на плавность интерфейса. Пользователь видит подтормаживания, лаги, фризы. Всё это для того, чтобы передать объект в процесс, который находится рядом. Это как заказывать грузовик с целой логистической цепочкой, чтобы перенести коробку к соседу.

А что насчет Externalizable? Он быстрее Serializable, да, и дает больше контроля. Технически, его можно использовать даже в памяти через ByteArrayOutputStream, без реальных файлов или сокетов. При хорошей реализации в обычной JVM с оптимизирующим JIT компилятором Externalizable может показывать отличную производительность, иногда даже сравнимую или превосходящую Parcelable по чистой скорости сериализации/десериализации объекта.

Но в контексте Android IPC проблемы не в скорости самого Externalizable, а в том, что он не был спроектирован для этой задачи. Первая проблема: формат данных привязан к Java сериализационному протоколу с его служебными маркерами (TC_OBJECT, TC_ENDBLOCKDATA и прочее), которые добавляют лишние байты в каждую передачу. Вторая: вызовы writeExternal и readExternal проходят через слой абстракции ObjectOutputStream/ObjectInputStream, даже если работают с ByteArrayOutputStream в памяти. Эти потоки не интегрированы с Binder и требуют дополнительного копирования данных. Третья: это всё еще создает больше временных объектов и нагружает GC по сравнению с прямой записью в Parcel. Четвертая: нет нативной интеграции с Android runtime (ART), в то время как Parcel работает напрямую с механизмами IPC на уровне ядра.

Другими словами, Externalizable это быстрый механизм для JVM, но не оптимальный для специфики Android, где каждая IPC операция должна быть максимально эффективной, а интеграция с Binder критически важна.

Binder работает иначе. Он минимизирует копирование данных между процессами, используя однократное копирование через ядро Linux. Данные пишутся напрямую в буфер Parcel, который затем передается через Binder driver с минимальными издержками. Для этого нужен механизм сериализации, который “понимает” эту специфику и работает напрямую с бинарным буфером без промежуточных слоев абстракции.

Именно поэтому был создан Parcelable. Концептуально он очень похож на Externalizable: вы реализуете два метода (writeToParcel и конструктор из Parcel), вы сами контролируете, что и как пишете, вы отвечаете за порядок записи и чтения. Идея ручного управления процессом сериализации явно пришла из Externalizable. Но реализация полностью переработана для Android. Вместо потоков используется Parcel, который работает с flat, untyped binary buffer. Вместо Java сериализационного протокола используется минималистичный формат без метаданных о типах. Вместо создания временных объектов данные пишутся напрямую в буфер, что минимизирует нагрузку на GC.

Важная особенность: Parcel это untyped buffer. В нем нет информации о типах данных, нет имен полей, нет версионирования. Это означает, что совместимости между версиями класса (как у Serializable с его serialVersionUID) здесь нет. Вы полностью отвечаете за обратную совместимость. Если вы измените порядок полей в writeToParcel и забудете обновить порядок чтения в конструкторе, данные будут прочитаны неверно, и вы получите трудноотлавливаемые баги. В этом плане Serializable был более “прощающим”, автоматически обнаруживая несовместимость версий.

Изначально Parcelable приходилось писать вручную, что было утомительно и чревато ошибками. С появлением Kotlin ситуация изменилась. Плагин kotlin-parcelize (аннотация @Parcelize) автоматически генерирует весь boilerplate код во время компиляции, гарантируя корректность порядка записи и чтения полей. Это объединило контроль Externalizable с удобством Serializable.

Для тех кто сразу же начал распознавать Parcelable в Externalizable, да, Externalizable стал философской основой для Parcelable. Оба говорят: “не доверяй автоматике, возьми контроль в свои руки”. Но Parcelable идет дальше, отбрасывая весь багаж JVM сериализации и создавая решение с нуля, оптимизированное под специфику Android: минимальное копирование через ядро, отсутствие временных объектов, прямая работа с бинарным буфером без типизации.

Интерфейс Parcelable

Мы только что выяснили, почему Google не мог использовать существующие JVM решения для Android. Serializable слишком медленный из-за рефлексии и создает избыточную нагрузку на Garbage Collector через множество временных объектов. Externalizable, хоть и быстрее, но не интегрирован с Binder и привязан к Java сериализационному протоколу со всеми его маркерами и метаданными. Требовалось решение, специально спроектированное для мобильной платформы: быстрое, компактное и работающее напрямую с Binder механизмом Android.

Именно таким решением стал Parcelable. Давайте начнем с интерфейса и посмотрим, что он от нас требует:

public interface Parcelable {
    int describeContents();
    void writeToParcel(Parcel dest, int flags);
    
    interface Creator<T> {
        T createFromParcel(Parcel source);
        T[] newArray(int size);
    }
}

В отличие от Serializable, который был просто пустым маркером, здесь мы видим реальные методы, которые нужно реализовать. Метод writeToParcel() отвечает за запись данных объекта в специальный контейнер Parcel, а describeContents() сообщает системе о наличии специальных ресурсов (об этом позже). Кроме того, каждый класс должен предоставить CREATOR - специальный объект, который умеет создавать экземпляры из Parcel.

Давайте попробуем реализовать наш знакомый класс Person с использованием Parcelable. Вот как это выглядело до появления автоматической генерации:

data class Person(
    val name: String,
    val dateOfBirth: Int,
    val address: String
) : Parcelable {
    
    constructor(parcel: Parcel) : this(
        parcel.readString()!!,
        parcel.readInt(),
        parcel.readString()!!
    )
    
    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeString(name)
        parcel.writeInt(dateOfBirth)
        parcel.writeString(address)
    }
    
    override fun describeContents(): Int = 0
    
    companion object CREATOR : Parcelable.Creator<Person> {
        override fun createFromParcel(parcel: Parcel): Person {
            return Person(parcel)
        }
        
        override fun newArray(size: Int): Array<Person?> {
            return arrayOfNulls(size)
        }
    }
}

Посмотрите на этот код внимательно. Для трех простых полей нам потребовалось написать почти 40 строк boilerplate кода. Мы вручную прописываем, как записывать каждое поле в методе writeToParcel(), затем в точно таком же порядке читаем их в конструкторе из Parcel, и наконец создаем CREATOR с двумя методами. Причем порядок записи и чтения должен совпадать абсолютно точно. Если вы случайно запишете writeInt(dateOfBirth) перед writeString(name), а при чтении сделаете наоборот, вы получите баг, который будет очень сложно отловить.

Android Studio попыталась облегчить жизнь разработчикам, добавив готовый шаблон: достаточно было нажать Alt+Insert (или Cmd+N на Mac) и выбрать “Parcelable implementation”, чтобы IDE сгенерировала весь необходимый код. Но это решало проблему лишь частично. Стоило вам добавить новое поле в класс или изменить порядок существующих, и приходилось вручную обновлять все методы сериализации. Забыли добавить новое поле в writeToParcel? Получите тихий баг на продакшене.

Ситуация кардинально изменилась с появлением Kotlin. Сначала в плагине kotlinx-android-extensions появилась аннотация @Parcelize, которая автоматически генерировала всю реализацию во время компиляции. Теперь наш класс Person можно было записать так:

@Parcelize
data class Person(
    val name: String,
    val dateOfBirth: Int,
    val address: String
) : Parcelable

Три строки вместо сорока! Одна аннотация, и компилятор сам генерирует весь необходимый код, гарантируя корректность порядка записи и чтения.

Правда, kotlinx-android-extensions оказался слишком широким плагином. Помимо @Parcelize, он включал синтетические импорты для view (знаменитые import kotlinx.android.synthetic.main.*), которые через несколько лет признали антипаттерном и deprec ated в пользу ViewBinding. В итоге плагин разделили, и @Parcelize переехала в свой собственный компактный модуль kotlin-parcelize. Сейчас для его использования достаточно добавить в build.gradle:

plugins {
    id("kotlin-parcelize")
}

И всё. Никаких runtime зависимостей, никаких дополнительных библиотек. Вся генерация происходит на уровне компилятора, создавая оптимальный байткод.

Как это работает на практике

Прежде чем погружаться в технические детали, давайте посмотрим на реальное использование Parcelable в Android. Если вы заметили, в отличие от примеров с Serializable и Externalizable, где мы создавали файлы и смотрели их содержимое, здесь мы этого не делали. Почему?

Причина проста: Parcelable создавался не для сохранения в файлы, а для передачи данных между компонентами Android. Давайте посмотрим типичный сценарий: передача объекта из одного Activity в другой.

// FirstActivity.kt
val person = Person("John Wick", 1964, "New York")
val intent = Intent(this, SecondActivity::class.java)
intent.putExtra("person_data", person) // person реализует Parcelable
startActivity(intent)
// SecondActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    val person = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        intent.getParcelableExtra("person_data", Person::class.java)
    } else {
        @Suppress("DEPRECATION")
        intent.getParcelableExtra<Person>("person_data")
    }
    
    println(person) // Person(name=John Wick, dateOfBirth=1964, address=New York)
}

Что происходит за кулисами? Когда вы вызываете intent.putExtra(), Android сериализует объект Person в Parcel, передает этот буфер через Binder в новый процесс (если Activity запускается в другом процессе) или просто в новый компонент, а там десериализует обратно. Весь процесс занимает микросекунды. Никаких файлов, никаких потоков ввода-вывода, никакого длительного хранения.

А что если мы все-таки захотим посмотреть, как выглядят сериализованные данные и что действительно происходит под капотом? Давайте посмотрим на полный цикл работы с Parcel - от сериализации до десериализации:

val person = Person("John Wick", 1964, "New York")

val source = Parcel.obtain()
source.writeParcelable(person, 0)
val bytes = source.marshall()
source.recycle()

println("Размер: ${bytes.size} байт")
println("Данные (hex): ${bytes.joinToString(" ") { "%02x".format(it) }}")

val readable = bytes.filter { it in 32..126 }.map { it.toInt().toChar() }.joinToString("")
println("Читаемые символы: $readable")

val destination = Parcel.obtain()
destination.unmarshall(bytes, 0, bytes.size)
destination.setDataPosition(0)

val classLoader = Person::class.java.classLoader
val result = destination.readParcelable<Person>(classLoader)
destination.recycle()

println("Восстановленный объект: $result")

Вывод на Emulator Pixel 6 24 Api покажет:

Данные (hex): 1c 00 00 00 6b 00 7a 00 2e 00 61 00 70 00 70 00 6c 00 69 00 63 00 61 00 74 00 69 00 6f 00 6e 00 2e 00 74 00 61 00 72 00 6c 00 61 00 6e 00 2e 00 50 00 65 00 72 00 73 00 6f 00 6e 00 00 00 00 00 09 00 00 00 4a 00 6f 00 68 00 6e 00 20 00 57 00 69 00 63 00 6b 00 00 00 ac 07 00 00 08 00 00 00 4e 00 65 00 77 00 20 00 59 00 6f 00 72 00 6b 00 00 00 00 00
Читаемые символы: kz.application.tarlan.PersonJohn WickNew York
Восстановленный объект: Person(name=John Wick, dateOfBirth=1964, address=New York)

Что здесь происходит? Сначала мы получаем экземпляр Parcel через obtain() - это не создание нового объекта, а получение из пула. Parcel использует object pooling для минимизации аллокаций. Затем вызываем writeParcelable(), который в свою очередь вызывает наш метод writeToParcel() из сгенерированного кода. Метод marshall() возвращает сырой ByteArray - содержимое внутреннего буфера. После завершения работы обязательно вызываем recycle(), возвращая Parcel в пул.

Для десериализации процесс обратный: получаем новый Parcel, вызываем unmarshall() чтобы загрузить байты в буфер, сбрасываем позицию чтения на начало через setDataPosition(0), и читаем объект обратно через readParcelable(), передавая ClassLoader для загрузки нужного класса. И снова не забываем recycle().

Важное предупреждение: этот пример показан исключительно для демонстрации того, что находится внутри Parcel. Документация Android явно предупреждает: данные, полученные через marshall(), не должны использоваться для долговременного хранения. Нельзя сохранять их на диск, отправлять по сети, хранить в базе данных или SharedPreferences. Формат Parcel высоко оптимизирован именно для локального IPC и не гарантирует совместимость между разными версиями Android платформы. Если вам нужно сохранить данные надолго, используйте стандартную сериализацию (Serializable, kotlinx.serialization) или другие механизмы общего назначения.

Что мы видим в hex дампе? Первые байты 1c 00 00 00 - это длина имени класса (28 символов). Затем идет полное имя класса kz.application.tarlan.Person в формате UTF-16 (каждый символ занимает 2 байта, отсюда все эти 00 между буквами). После этого идут данные полей: длина строки, сама строка “John Wick” в UTF-16, число 1964 (ac 07 в little-endian), и строка “New York” также в UTF-16.

Parcel записывает полное квалифицированное имя класса включая package. Строки хранятся в UTF-16. Если бы мы использовали writeToParcel() напрямую без записи имени класса через writeParcelable(), данных было бы меньше. Но для IPC полное имя класса необходимо для корректной десериализации на принимающей стороне. В контексте Intent между Activity данные живут микросекунды в памяти, поэтому overhead незаметен. Но это еще один аргумент против использования marshall() для хранения - формат содержит platform-specific детали вроде полных имен классов.

Кстати, Serializable тоже работает в Android через Intent:

// Это тоже валидный код, если Person реализует Serializable
val person = Person("John Wick", 1964, "New York")
intent.putExtra("person_data", person) // Android поддерживает и Serializable

Но за кулисами творится совсем другое. Android вынужден создать ObjectOutputStream, запустить рефлексию, записать все метаданные, создать множество временных объектов. На другой стороне то же самое: ObjectInputStream, рефлексия, парсинг метаданных, создание объектов. Результат тот же, но работает медленнее и создает значительную нагрузку на Garbage Collector. Именно поэтому в документации Android вы везде увидите рекомендацию: используйте Parcelable для передачи данных между компонентами.

Но давайте вернемся к вопросу: что же такое этот Parcel, в который мы пишем данные? Помните, мы говорили о том, что Android нужен механизм, работающий напрямую с памятью, без промежуточных слоев абстракции? Вот здесь и начинается самое интересное. Parcel - это не просто еще один Java класс для работы с данными. Это тонкая обертка над нативной C++ структурой, и работает он через JNI (Java Native Interface):

public final class Parcel {
    private long mNativePtr; // Указатель на нативную структуру
    
    public final void writeString(String val) {
        nativeWriteString(mNativePtr, val);
    }
    
    private static native void nativeWriteString(long nativePtr, String val);
}

Обратите внимание на поле mNativePtr - это просто число типа long, которое хранит указатель на C++ структуру. Когда вы вызываете parcel.writeString("John Wick"), на Java стороне происходит только перенаправление вызова в нативный метод nativeWriteString(). А дальше начинается работа C++ кода.

Нативная реализация находится в файле frameworks/native/libs/binder/Parcel.cpp в исходниках Android. Этот код работает напрямую с памятью: строка конвертируется в UTF-16, к ней добавляется информация о длине, и все это записывается в линейный буфер памяти. Никаких временных Java объектов, никаких слоев абстракции в виде ObjectOutputStream, просто запись байтов в память.

Теперь представьте, что происходит, когда вы передаете объект через Intent.putExtra() между Activity. Этот буфер памяти отправляется через Binder driver, который работает на уровне ядра Linux. Binder использует механизм copy-on-write, минимизируя копирование данных. На принимающей стороне создается новый Parcel, который получает указатель на этот буфер памяти, и вы просто читаете из него данные в том же порядке, в котором записывали. Вспомните ObjectOutputStream с его слоями абстракции, протоколами и метаданными - здесь ничего подобного нет. Только память, указатели и минимум накладных расходов.

Давайте теперь посмотрим, какой именно код генерирует компилятор для нашего простого класса Person с аннотацией @Parcelize. Если открыть скомпилированный .class файл через декомпилятор, мы увидим что-то вроде этого:

@Parcelize
data class Person(
    val name: String,
    val dateOfBirth: Int,
    val address: String
) : Parcelable {
    
    // Сгенерировано компилятором
    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeString(name)
        parcel.writeInt(dateOfBirth)
        parcel.writeString(address)
    }
    
    override fun describeContents(): Int = 0
    
    companion object {
        @JvmField
        val CREATOR = object : Parcelable.Creator<Person> {
            override fun createFromParcel(parcel: Parcel): Person {
                return Person(
                    parcel.readString()!!,
                    parcel.readInt(),
                    parcel.readString()!!
                )
            }
            
            override fun newArray(size: Int): Array<Person?> {
                return arrayOfNulls(size)
            }
        }
    }
}

Посмотрите на порядок операций: при записи мы вызываем writeString, затем writeInt, затем снова writeString. При чтении порядок абсолютно идентичен: readString, readInt, readString. Это не случайность и не прихоть. Это критически важное требование, потому что Parcel - это untyped buffer, плоский массив байтов без какой-либо информации о типах данных.

Когда вы вызываете parcel.readInt(), он просто берет следующие 4 байта из буфера и интерпретирует их как integer. Нет никакой проверки “а точно ли здесь int?”. Если вы случайно нарушите порядок - например, сначала запишете int, а при чтении попытаетесь прочитать string, вы получите совершенно некорректные данные или краш приложения. Именно поэтому ручная реализация Parcelable была такой опасной: одна ошибка, и баг готов.

С @Parcelize эта проблема решена на уровне компилятора. Он анализирует primary конструктор, генерирует методы записи и чтения в правильном порядке, и гарантирует их синхронизацию. Вы не можете ошибиться, потому что не пишете код вручную.

Теперь давайте рассмотрим более сложный случай: nullable поля. Как Parcel работает с null значениями, если это просто байты в памяти без метаданных о типах?

@Parcelize
data class Person(
    val name: String,
    val dateOfBirth: Int,
    val address: String?
) : Parcelable

Для nullable address генерируется код с проверкой:

override fun writeToParcel(parcel: Parcel, flags: Int) {
    parcel.writeString(name)
    parcel.writeInt(dateOfBirth)
    parcel.writeString(address) // Даже если address == null, это работает!
}

Ответ прост и элегантен: writeString() имеет встроенную поддержку null. Когда вы передаете null, метод записывает специальный маркер - значение -1 в качестве длины строки. При чтении readString() видит этот маркер и возвращает null. Получается, что Android поддерживал null-safety на уровне своего API еще до того, как Kotlin сделал эту концепцию центральной в языке. Интересно, что разработчики Android изначально заложили поддержку nullable типов, хотя Java этого вообще не знала.

Теперь давайте поговорим о методе describeContents(), который в нашем примере просто возвращает 0. Вы могли заметить, что мы никогда его не переопределяем, компилятор генерирует его автоматически. Зачем он вообще нужен? В 99% случаев действительно нужно просто вернуть 0. Но есть один специальный сценарий: file descriptors.

Представьте, что ваш класс содержит ParcelFileDescriptor - это может быть открытый файл, сокет или другой системный ресурс. Такие ресурсы требуют особой обработки при передаче между процессами, потому что это не просто данные в памяти, это реальные объекты операционной системы. В таком случае нужно вернуть Parcelable.CONTENTS_FILE_DESCRIPTOR, чтобы Binder понимал, что объект содержит системные ресурсы и обработал их корректно:

@Parcelize
data class FileWrapper(val fd: ParcelFileDescriptor) : Parcelable {
    override fun describeContents(): Int = Parcelable.CONTENTS_FILE_DESCRIPTOR
}

Второй параметр метода writeToParcel(Parcel dest, int flags) - это flags. В большинстве случаев он игнорируется, но может содержать флаг Parcelable.PARCELABLE_WRITE_RETURN_VALUE. Этот флаг говорит, что объект передается как возвращаемое значение из Binder вызова, и после записи некоторые ресурсы можно освободить, потому что на отправляющей стороне они больше не нужны.

А теперь посмотрим на более сложный сценарий: вложенные объекты. В реальных приложениях мы редко работаем с простыми классами из трех примитивных полей. Обычно у нас есть целые графы объектов, где один класс содержит другие классы. Например, Person может содержать объект Address:

@Parcelize
data class Address(val city: String, val street: String) : Parcelable

@Parcelize
data class Person(
    val name: String,
    val dateOfBirth: Int,
    val address: Address
) : Parcelable

Что происходит при сериализации? Когда компилятор доходит до поля address в классе Person, он генерирует вызов parcel.writeParcelable(address, flags). Этот метод в свою очередь вызывает address.writeToParcel(), и весь вложенный объект сериализуется рекурсивно. При десериализации происходит обратный процесс: parcel.readParcelable<Address>(Address::class.java.classLoader) читает данные и воссоздает объект Address. Вся рекурсия обрабатывается автоматически, без runtime накладных расходов, потому что все генерируется на этапе компиляции.

Теперь рассмотрим еще один распространенный случай: коллекции. Списки, множества, карты - стандартные структуры данных в любом приложении. Как Parcel работает с ними?

@Parcelize
data class Team(
    val name: String,
    val members: List<String>
) : Parcelable

Генерируется код:

override fun writeToParcel(parcel: Parcel, flags: Int) {
    parcel.writeString(name)
    parcel.writeStringList(members)
}

Для коллекций Parcel предоставляет специализированные оптимизированные методы. writeStringList() сначала записывает размер списка как integer, затем последовательно записывает каждую строку. При чтении readStringList() сначала читает размер, создает ArrayList нужной емкости, а затем читает строки одну за другой. Аналогично работают writeIntArray(), writeParcelableList(), writeMap() и множество других методов для различных типов коллекций. Каждый из них оптимизирован для конкретного типа данных, что делает сериализацию максимально эффективной.

Но у @Parcelize есть важное ограничение, о котором нужно помнить. Компилятор генерирует код только на основе primary конструктора класса. Если у вас есть свойства, объявленные вне конструктора, они просто проигнорируются:

@Parcelize
data class Person(
    val name: String,
    val dateOfBirth: Int
) : Parcelable {
    var address: String = "" // Это поле НЕ будет сериализовано!
}

Почему так? Потому что компилятор анализирует только сигнатуру primary конструктора. Он не знает и не может знать о свойствах, которые вы инициализируете в теле класса или через init блоки. Если вам нужно сериализовать такое свойство, просто добавьте его в primary конструктор.

Иногда встречаются ситуации, когда автоматической генерации недостаточно. Например, вам нужно сериализовать класс из сторонней библиотеки, который не реализует Parcelable. Или требуется специальная логика сериализации - скажем, шифрование данных перед записью. Для таких случаев существует интерфейс Parceler, который позволяет определить кастомную логику:

data class Person(
    val name: String,
    val dateOfBirth: Int,
    val address: String
)

object PersonParceler : Parceler<Person> {
    override fun create(parcel: Parcel): Person {
        return Person(
            parcel.readString()!!,
            parcel.readInt(),
            parcel.readString()!!
        )
    }
    
    override fun Person.write(parcel: Parcel, flags: Int) {
        parcel.writeString(name)
        parcel.writeInt(dateOfBirth)
        parcel.writeString(address)
    }
}

@Parcelize
@TypeParceler<Person, PersonParceler>
data class Team(
    val name: String,
    val leader: Person
) : Parcelable

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

Мы много времени потратили на объяснение внутреннего устройства Parcelable, и вы могли заметить, насколько он отличается от Serializable. Нативная реализация в C++, прямая работа с памятью без промежуточных объектов, отсутствие рефлексии, интеграция с Binder на уровне ядра Linux. Все эти архитектурные решения были сделаны неслучайно - они обеспечивают производительность, необходимую для IPC в реальном времени. Конкретные цифры мы увидим в разделе с бенчмарками.

Но было бы нечестно рассказать только о достоинствах. У Parcelable есть серьезные ограничения, которые нужно понимать.

Привязка к платформе. Parcelable работает только на Android. Это не кроссплатформенное решение. Представьте, что вы разрабатываете мобильное приложение с общим бизнес-слоем для Android и iOS. Ваши модели данных должны работать на обеих платформах. Но Parcelable существует только в Android SDK. На iOS его просто нет. Более того, с ростом популярности Kotlin Multiplatform появилась потребность в едином коде, который работает везде: на Android, iOS, в браузере через Kotlin/JS, на бэкенде через Kotlin/JVM, даже в нативных приложениях через Kotlin/Native. Parcelable в этой картине мира не вписывается никак.

Отсутствие версионирования. Помните serialVersionUID у Serializable? Здесь такого механизма нет. Если вы измените структуру класса между версиями приложения - добавите поле, удалите поле, поменяете порядок - вам придется вручную обрабатывать совместимость, как мы видели в примере с Externalizable. Это ваша ответственность, и никакой автоматической проверки не будет.

Ограниченность применения. Parcel оптимизирован для IPC, но не для долговременного хранения. Вы можете сохранить сериализованный Parcel в файл или SharedPreferences, технически это возможно. Но делать этого не стоит. Формат Parcel может измениться между версиями Android, и ваши сохраненные данные станут нечитаемыми после обновления системы. Parcel создавался для передачи данных между компонентами здесь и сейчас, а не для хранения на диск.

Отсутствие выбора формата. Parcelable сериализует данные в один единственный бинарный формат, оптимизированный для Binder. Но что если вам нужно отправить данные по сети? Современные API обмениваются JSON или Protocol Buffers. Что если нужно сохранить конфигурацию в человекочитаемом виде? Нужен YAML или TOML. Parcelable для этого не подходит. Он решает одну задачу - IPC в Android, и решает её блестяще. Но только эту одну задачу.

Именно эти ограничения создали запрос на универсальное решение. Представьте себе идеал: библиотеку, которая работает на всех платформах Kotlin, поддерживает множество форматов данных (JSON, Protobuf, CBOR, XML), использует кодогенерацию для максимальной производительности, обеспечивает type-safety на уровне компилятора, и при этом остается такой же простой в использовании, как @Parcelize.

Звучит слишком хорошо, чтобы быть правдой? Но именно такое решение создала команда JetBrains. Встречайте финального героя нашей истории.

kotlinx.serialization

История появления и философия

kotlin.png

В 2017 году Kotlin переживал настоящий бум. Google объявила его официальным языком для Android разработки, сообщество активно росло, а JetBrains начала амбициозный проект - Kotlin Multiplatform (KMP). Идея была революционной: писать код один раз и запускать его везде. На Android через Kotlin/JVM и Android Runtime, на iOS через Kotlin/Native с компиляцией в нативный код, в браузере через Kotlin/JS, на сервере через обычный JVM. Но для реализации этой идеи не хватало одного критически важного элемента.

Представьте разработчика, который пишет мобильное приложение с общим бизнес-слоем. Модели данных, сетевые запросы, работа с API - всё это должно работать одинаково на Android и iOS. На Android у него есть Parcelable для IPC, есть Gson или Moshi для JSON, есть множество готовых решений. Но стоит скомпилировать этот код для iOS через Kotlin/Native, и всё ломается. Parcelable не существует. Gson использует рефлексию, которая работает совсем по-другому (или вообще не работает) в native окружении. Moshi требует кодогенерации через KAPT, который не поддерживается в Kotlin/Native.

Получался замкнутый круг: KMP обещал “пиши один раз, запускай везде”, но для базовой задачи - сериализации данных - приходилось писать разный код для каждой платформы. JSON парсинг на Android решался одной библиотекой, на iOS другой, в JS третьей. А ведь сериализация - это фундаментальная операция, без которой не обходится ни один проект.

Команда JetBrains понимала: для успеха Kotlin Multiplatform нужна кроссплатформенная библиотека сериализации, которая работает одинаково хорошо на всех поддерживаемых платформах. Но просто “ещё одна библиотека сериализации” была бы половинчатым решением. Нужно было создать что-то принципиально новое, учитывающее уникальные особенности Kotlin как языка и опыт всех предыдущих решений.

В 2018 году в статусе experimental появилась первая версия kotlinx.serialization. Библиотека сразу выделялась своим подходом. В отличие от Gson, который использует рефлексию в runtime, kotlinx.serialization полностью работает через плагин компилятора. Аннотация @Serializable запускает генерацию специализированного кода еще на этапе компиляции. Это означает несколько важных вещей.

Производительность. Никакой рефлексии в runtime, никакого анализа структуры классов во время выполнения. Всё уже готово и оптимизировано на этапе компиляции. В JVM это дает скорость, сравнимую с Moshi или даже превосходящую его. В Kotlin/Native, где рефлексия ограничена и медленна, это критически важно.

Безопасность типов. Компилятор анализирует вашу структуру данных и генерирует типобезопасный код. Если вы попытаетесь сериализовать тип, для которого нет сериализатора, вы получите ошибку компиляции, а не runtime exception в продакшене. Это огромное преимущество по сравнению с reflection-based решениями, где ошибки проявляются только при выполнении.

Кроссплатформенность. Плагин компилятора работает на всех целевых платформах Kotlin. Один и тот же код с @Serializable компилируется в оптимальный байткод для JVM, в JavaScript для браузера, в нативный код для iOS. Нет никаких platform-specific зависимостей, нет различий в API между платформами.

Множественность форматов. В отличие от Parcelable, который работает только с одним форматом, kotlinx.serialization устроена модульно. Есть ядро библиотеки, которое определяет, как структура классов превращается в последовательность операций записи и чтения. А формат - это отдельный модуль. Хотите JSON? Подключите kotlinx-serialization-json. Нужен Protobuf? kotlinx-serialization-protobuf. CBOR для компактного бинарного представления? kotlinx-serialization-cbor. Тот же класс с одной аннотацией @Serializable может быть сериализован в любой из этих форматов без изменения кода.

К 2020 году библиотека вышла из experimental статуса и достигла версии 1.0, став стабильной и готовой к production использованию. Сегодня это де-факто стандарт для сериализации в Kotlin Multiplatform проектах и серьезная альтернатива Gson/Moshi в чистых JVM/Android приложениях.

Давайте посмотрим, как это работает на практике.

Как это выглядит на практике

Возьмем наш знакомый класс Person и посмотрим, как выглядит сериализация:

@Serializable
data class Person(
    val name: String,
    val dateOfBirth: Int,
    val address: String
)

fun main() {
    val person = Person("John Wick", 1964, "New York")
    val json = Json.encodeToString(person)
    println(json)
}

Вывод:

{"name":"John Wick","dateOfBirth":1964,"address":"New York"}

Одна аннотация @Serializable, и класс готов к сериализации. Синтаксис напоминает @Parcelize, но работает иначе и на всех платформах. Обратите внимание на результат: это не бинарный формат с метаданными из Serializable, не flat buffer из Parcel, а чистый JSON.

Но JSON - это только один из форматов. Помните, мы говорили про модульность? Тот же класс можно сериализовать в Protocol Buffers, CBOR, или даже XML:

val person = Person("John Wick", 1964, "New York")

val json = Json.encodeToString(person)
val protobuf = ProtoBuf.encodeToByteArray(Person.serializer(), person)
val cbor = Cbor.encodeToByteArray(Person.serializer(), person)

JSON читаемый и широко поддерживается. Protocol Buffers - компактный бинарный формат для эффективной передачи данных. CBOR - формат похожий на MessagePack, занимающий промежуточную позицию. Выбор формата зависит от задачи: для API используйте JSON, для мобильного кеша или сетевых протоколов - Protobuf или CBOR, для конфигураций - JSON или YAML.

И самое интересное: библиотека открыта для расширения. Если вам нужен специфический формат - скажем, YAML, TOML, или вообще свой проприетарный протокол - вы можете реализовать свой Encoder и Decoder. API спроектирован так, что сериализаторы, сгенерированные для ваших классов, будут работать с любым encoder’ом. Сообщество уже создало множество форматов: kotlinx-serialization-hocon для конфигов, kotlinx-serialization-properties для Java Properties файлов, есть даже экспериментальный YAML. Это уникальная возможность: один класс с одной аннотацией работает с десятками форматов без изменения кода.

Десериализация работает зеркально для любого формата:

val json = """{"name":"John Wick","dateOfBirth":1964,"address":"New York"}"""
val person = Json.decodeFromString<Person>(json)
println(person)

Вывод: Person(name=John Wick, dateOfBirth=1964, address=New York)

Внутреннее устройство: плагин компилятора

Помните, как Serializable использует рефлексию через ObjectOutputStream, как Externalizable требует ручной реализации методов, как Parcelable генерирует код через @Parcelize? С kotlinx.serialization подход принципиально иной.

Когда вы добавляете аннотацию @Serializable, плагин компилятора генерирует специальный сериализатор. Если декомпилировать байткод, увидим примерно следующую структуру:

object PersonSerializer : KSerializer<Person> {
    override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Person") {
        element<String>("name")
        element<Int>("dateOfBirth")
        element<String>("address")
    }
    
    override fun serialize(encoder: Encoder, value: Person) {
        val composite = encoder.beginStructure(descriptor)
        composite.encodeStringElement(descriptor, 0, value.name)
        composite.encodeIntElement(descriptor, 1, value.dateOfBirth)
        composite.encodeStringElement(descriptor, 2, value.address)
        composite.endStructure(descriptor)
    }
    
    override fun deserialize(decoder: Decoder): Person {
        // Логика чтения полей и создания объекта
    }
}

Ключевое отличие от всех предыдущих подходов: метод serialize обращается к полям напрямую - value.name, value.dateOfBirth, value.address. Никакой рефлексии, никаких getDeclaredFields() или field.setAccessible(true). Компилятор знает структуру класса и генерирует прямые вызовы. Это дает производительность, сравнимую с ручной реализацией в Externalizable, но без необходимости писать код вручную.

Второе важное отличие: архитектура разделяет “что сериализовать” и “как сериализовать”. PersonSerializer не знает ничего о JSON, он просто вызывает методы абстракции Encoder. А конкретный формат (JSON, Protobuf, CBOR) определяется экземпляром encoder’а:

val person = Person("John Wick", 1964, "New York")

// Тот же сериализатор, разные форматы
val json = Json.encodeToString(person)
val protobuf = ProtoBuf.encodeToByteArray(Person.serializer(), person)
val cbor = Cbor.encodeToByteArray(Person.serializer(), person)

Попробуйте сделать такое с Parcelable или Serializable - они жестко привязаны к своему формату.

Контроль над процессом сериализации

Как и в случае с Serializable, иногда требуется вмешаться в процесс сериализации. Представьте, что у вас есть поле с кешированными данными или временными вычислениями, которые не нужно сохранять. В Serializable мы использовали ключевое слово transient, здесь работает аннотация @Transient:

@Serializable
data class User(
    val id: String,
    val username: String,
    @Transient val cachedAvatar: Bitmap? = null,
    @Transient var lastAccessTime: Long = 0L
)

При сериализации поля cachedAvatar и lastAccessTime будут проигнорированы. JSON будет содержать только id и username. Обратите внимание: transient поля должны иметь default значения, иначе при десериализации компилятор не сможет создать объект.

Другая частая проблема: ваши Kotlin классы используют camelCase, но API сервера требует snake_case. Это классическая боль при интеграции с backend’ами. В Serializable, Externalizable и Parcelable такой возможности вообще нет - эти механизмы работают напрямую с именами полей класса. Пришлось бы либо писать поля в коде как user_id (нарушая Kotlin conventions), либо создавать отдельный DTO слой с маппингом, либо в случае Externalizable писать километры кода в writeExternal/readExternal.

Это одна из областей, где kotlinx.serialization больше напоминает библиотеки вроде Gson или Moshi, но с важным отличием: проверка на этапе компиляции. В Gson аннотация @SerializedName обрабатывается в runtime через рефлексию, здесь же плагин компилятора генерирует код сразу. Достаточно аннотации @SerialName:

@Serializable
data class ApiResponse(
    @SerialName("user_id") val userId: String,
    @SerialName("created_at") val createdAt: Long,
    @SerialName("is_active") val isActive: Boolean
)

При сериализации будет {"user_id":"123","created_at":1698765432,"is_active":true}, но в Kotlin коде вы продолжаете работать с идиоматичными именами userId, createdAt, isActive. Не нужны отдельные DTO классы, не нужен маппинг слой, не нужны расширения вроде MapStruct или ModelMapper. Среди всех четырех подходов к сериализации, которые мы рассмотрели, только kotlinx.serialization предоставляет такую гибкость из коробки.

Третья проблема: API эволюционирует, добавляются новые поля, старые становятся опциональными. В Serializable версионирование решается через serialVersionUID, но это хрупкий механизм. Здесь работают nullable типы и default значения:

@Serializable
data class User(
    val id: String,
    val username: String,
    val email: String? = null,
    val premium: Boolean = false
)

Поле email помечено как nullable с default значением null - оно может отсутствовать в JSON. Поле premium имеет default значение false - если его нет в JSON, используется значение по умолчанию. Если JSON содержит {"id":"123","username":"john"}, библиотека создаст объект с email = null и premium = false. Если JSON содержит все поля, используются значения из него.

Но если попытаться десериализовать JSON без обязательного поля:

val json = """{"username":"john"}"""
val user = Json.decodeFromString<User>(json)

Получите исключение: SerializationException: Field 'id' is required for type 'User', but it was missing. Type-safety работает и в runtime. В отличие от Gson, который молча подставит null даже для non-null типа (и приложение упадет позже с NullPointerException в production), здесь вы получите понятное исключение сразу при десериализации.

Работа с типами вне вашего контроля

Серьезная проблема возникает, когда нужно сериализовать класс, на который вы не можете навесить @Serializable. Это может быть класс из сторонней библиотеки, legacy Java код, или стандартные типы вроде java.util.Date. В Serializable такие классы просто не сериализуются корректно или создают огромные бинарные данные.

Хорошая новость: для многих распространённых типов сериализаторы уже существуют. UUID, BigDecimal, BigInteger, kotlinx.datetime типы - всё это поддерживается из коробки через отдельные модули. Например, для работы с UUID достаточно добавить зависимость kotlinx-serialization-core и использовать встроенный UUIDSerializer. Для java.time типов есть специальный модуль kotlinx-serialization-json-jvm с готовыми сериализаторами для LocalDateTime, Instant, Duration и других.

Но если вам нужен тип, для которого сериализатора нет, или нужна специфическая логика (например, шифрование перед записью), можно создать кастомный сериализатор. В kotlinx.serialization для этого существует интерфейс KSerializer<T>. Создаете объект, реализующий его, и указываете, как именно сериализовать и десериализовать этот тип:

object DateAsLongSerializer : KSerializer<Date> {
    override val descriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.LONG)
    
    override fun serialize(encoder: Encoder, value: Date) {
        encoder.encodeLong(value.time)
    }
    
    override fun deserialize(decoder: Decoder): Date {
        return Date(decoder.decodeLong())
    }
}

Теперь можно использовать этот сериализатор для полей типа Date:

@Serializable
data class Event(
    val title: String,
    @Serializable(with = DateAsLongSerializer::class)
    val timestamp: Date
)

Date будет сериализоваться как простое число (unix timestamp), а не как объект со всеми внутренними полями. При десериализации число автоматически превратится обратно в Date. Это работает для любых типов: библиотек работы с UUID, кастомных классов из closed-source зависимостей, Java коллекций со специфичной логикой.

Полиморфизм и sealed классы

Одна из самых мощных возможностей, которой нет ни в Serializable, ни в Parcelable, ни в Externalizable без огромного количества boilerplate кода. Представьте API, который возвращает разные типы событий:

@Serializable
sealed class Event {
    abstract val timestamp: Long
}

@Serializable
@SerialName("user_login")
data class UserLoginEvent(
    override val timestamp: Long,
    val userId: String
) : Event()

@Serializable
@SerialName("purchase")
data class PurchaseEvent(
    override val timestamp: Long,
    val amount: Double,
    val currency: String
) : Event()

@Serializable
data class EventLog(val events: List<Event>)

Sealed классы - это закрытая иерархия типов, компилятор знает все возможные подтипы. При сериализации библиотека автоматически добавляет discriminator поле "type" с именем конкретного класса:

val log = EventLog(
    events = listOf(
        UserLoginEvent(1698765432000, "user123"),
        PurchaseEvent(1698765433000, 99.99, "USD")
    )
)

val json = Json.encodeToString(log)

Результат:

{
  "events": [
    {"type":"user_login","timestamp":1698765432000,"userId":"user123"},
    {"type":"purchase","timestamp":1698765433000,"amount":99.99,"currency":"USD"}
  ]
}

При десериализации библиотека смотрит на поле "type", понимает, какой именно подкласс создавать, и восстанавливает корректную иерархию:

val log = Json.decodeFromString<EventLog>(json)
when (val event = log.events[0]) {
    is UserLoginEvent -> println("User ${event.userId} logged in")
    is PurchaseEvent -> println("Purchase: ${event.amount} ${event.currency}")
}

Type-safety сохраняется полностью. Компилятор понимает, что в списке events могут быть только подтипы Event, а sealed класс гарантирует, что все возможные варианты известны. Если в JSON придет неизвестный тип, получите исключение. Если структура не соответствует, тоже исключение. Попробуйте реализовать такое с Serializable - придется писать километры кода с проверками типов, instanceof, кастами, и ручной маршрутизацией десериализации.

При всех своих преимуществах, kotlinx.serialization имеет ограничения, о которых важно знать.

Требование плагина компилятора. В отличие от Gson или Jackson, которые просто добавляются в зависимости, здесь нужен плагин компилятора. Для большинства проектов это не проблема, но в специфических сценариях с ограниченным контролем над компиляцией может стать препятствием.

Ограничения на структуру классов. Как и в @Parcelize, сериализуются только свойства из primary конструктора. Свойства в теле класса игнорируются. Плагин компилятора анализирует сигнатуру конструктора, не всю структуру класса.

Размер кода. Генерация сериализаторов для каждого класса увеличивает размер итогового APK/JAR. В крупных приложениях с сотнями data классов это может быть заметно. Цена за производительность и type-safety.

Версионирование. Нет встроенного механизма версионирования как serialVersionUID у Serializable. Обратная совместимость обеспечивается через default значения и nullable типы, но требует внимательности при эволюции схемы данных.

При этом kotlinx.serialization остается единственным полноценным кроссплатформенным решением для Kotlin. Один код работает на JVM, JS, Native с одинаковой производительностью и гарантиями type-safety.

Мы разобрали все четыре подхода к сериализации: Serializable с его рефлексией и Java legacy, Externalizable с полным ручным контролем, Parcelable с нативной оптимизацией для Android IPC, и kotlinx.serialization с кроссплатформенностью и множеством форматов. Каждый имеет свои сильные и слабые стороны, каждый решает определенный класс задач.

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

Бенчмарки: сравнение производительности

На протяжении всей статьи мы обсуждали различия в подходах: Serializable медленный из-за рефлексии, Externalizable быстрее благодаря ручному контролю, Parcelable оптимизирован для Android IPC, kotlinx.serialization использует кодогенерацию. Мы говорили о размере данных, о влиянии метаинформации на итоговый объем, о различиях между текстовыми и бинарными форматами. Но все это были теоретические рассуждения или общие утверждения.

Пришло время провести систематическое тестирование и получить конкретные метрики. Мы измерим четыре ключевых параметра:

  1. Скорость сериализации - сколько времени требуется на преобразование объекта в байты
  2. Скорость десериализации - сколько времени требуется на восстановление объекта из байтов
  3. Размер данных - сколько байт занимает сериализованное представление
  4. Количество аллокаций - сколько объектов создается в процессе работы

Четвертый параметр заслуживает особого внимания. Количество аллокаций памяти во многих сценариях важнее, чем скорость или размер данных. Почему? Потому что каждая аллокация это не просто выделение памяти, это будущая работа для Garbage Collector. Можно написать код, который выполняется за микросекунды и создает компактное представление данных, но если для этого пришлось создать сотни промежуточных объектов, цена операции многократно возрастает. Эти объекты нагружают heap, провоцируют GC паузы, фрагментируют память. На мобильных устройствах, где память ограничена, а энергоэффективность критична, частые GC циклы напрямую влияют на battery life и плавность интерфейса. Низкое количество аллокаций косвенно свидетельствует о хорошей архитектуре решения: эффективном использовании буферов, переиспользовании объектов, отсутствии избыточных копирований. Поэтому когда мы говорим об оптимальности сериализации, мы смотрим не только на время выполнения, но и на то, сколько мусора она после себя оставляет.

Тестирование проводится на Android устройстве, чтобы получить реалистичные данные для мобильной разработки.

Для измерения производительности используем Jetpack Benchmark library - официальный инструмент от Google для точного измерения производительности Android кода. Библиотека автоматически выполняет warmup итерации для прогрева JIT компилятора, затем запускает множество измерений, отбрасывает выбросы и вычисляет статистически значимые результаты.

Тестовые классы выбраны реалистичными - модель пользователя с различными типами данных:

@[kotlinx.serialization.Serializable Parcelize]
data class User1(
    var id: String ,
    var name: String,
    var email: String,
    var age: Int ,
    var isActive: Boolean ,
    var registrationDate: Long ,
    var tags: List<String> = emptyList()
) : Serializable, Parcelable {

    companion object {
        private const val serialVersionUID = 1L
    }
}

data class User2(
    var id: String = "",
    var name: String = "",
    var email: String = "",
    var age: Int = 0,
    var isActive: Boolean = false,
    var registrationDate: Long = 0L,
    var tags: List<String> = emptyList()
) : Externalizable {

    override fun writeExternal(out: ObjectOutput) {
        out.writeUTF(id)
        out.writeUTF(name)
        out.writeUTF(email)
        out.writeInt(age)
        out.writeBoolean(isActive)
        out.writeLong(registrationDate)
        out.writeInt(tags.size)
        tags.forEach { out.writeUTF(it) }
    }

    override fun readExternal(input: ObjectInput) {
        id = input.readUTF()
        name = input.readUTF()
        email = input.readUTF()
        age = input.readInt()
        isActive = input.readBoolean()
        registrationDate = input.readLong()
        val size = input.readInt()
        tags = List(size) { input.readUTF() }
    }
}

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

Для честного сравнения критически важно использовать одинаковые данные во всех тестах. Мы создали два класса: User1 и User2. Оба имеют абсолютно идентичную структуру (семь полей: id, name, email, age, isActive, registrationDate, tags) и заполняются одинаковыми значениями (пользователь John Wick с возрастом 55 лет, email “john.wick@continental.com” и списком из четырех тегов).

Почему два класса, а не один? Причина техническая: Externalizable и Serializable сериализуются одним и тем же API через ObjectOutputStream. Под капотом проверка в первую очередь проверяет, реализует ли класс Externalizable, и если да, использует его логику, игнорируя Serializable. Если бы мы создали один класс, реализующий оба интерфейса, то в тесте для Serializable фактически выполнялся бы код Externalizable, что сделало бы сравнение некорректным. Поэтому User1 используется для Serializable, Parcelable и kotlinx.serialization, а User2 только для Externalizable.

Разница в именах (User1 vs User2) составляет один символ. Поскольку имя класса записывается в сериализованные данные (особенно в Serializable и Parcelable), это влияет на размер, но влияние минимально: одна цифра занимает одинаковое количество байт в любой кодировке. Таким образом, мы сохраняем объективность сравнения размеров данных.

Для общей оценки мы будем сразу сериализовать и десериализовать по каждому способу, и оценить общее время этих двух процессов. Используем Microbenchmark из Jetpack Benchmark library. Эта конфигурация специально разработана для измерения микро-операций, которые выполняются за микросекунды или наносекунды. Библиотека запускает каждый тест тысячи раз (количество итераций зависит от скорости выполнения), автоматически определяет оптимальное количество прогревочных итераций для стабилизации JIT компилятора, собирает статистику (минимум, максимум, медиана, коэффициент вариации), отбрасывает выбросы и выдает статистически значимые результаты. Медианное значение используется вместо среднего, так как медиана более устойчива к выбросам, вызванным фоновыми процессами системы или сборкой мусора. Класс для бенчмарков следующий:

@RunWith(AndroidJUnit4::class)
class SerializationBenchmark {

    @OptIn(ExperimentalBenchmarkConfigApi::class)
    @get:Rule
    val benchmarkRule = BenchmarkRule(MicrobenchmarkConfig(traceAppTagEnabled = true))

    var user1 = User1(
        id = "user_123456789",
        name = "John Wick",
        email = "john.wick@continental.com",
        age = 55,
        isActive = true,
        registrationDate = 1672531200000L,
        tags = listOf("assassin", "legendary", "baba_yaga", "continental")
    )

    var user2 = User2(
        id = "user_123456789",
        name = "John Wick",
        email = "john.wick@continental.com",
        age = 55,
        isActive = true,
        registrationDate = 1672531200000L,
        tags = listOf("assassin", "legendary", "baba_yaga", "continental")
    )

    @Test
    fun javaSerializable() = benchmarkRule.measureRepeated {
        // Сериализация
        val baos = ByteArrayOutputStream()
        ObjectOutputStream(baos).use { it.writeObject(user1) }
        val serialized = baos.toByteArray()

        // Десериализация
        ByteArrayInputStream(serialized).use { bais ->
            ObjectInputStream(bais).use { it.readObject() as User1 }
        }
    }

    @Test
    fun javaExternalizable() = benchmarkRule.measureRepeated {
        // Сериализация
        val baos = ByteArrayOutputStream()
        ObjectOutputStream(baos).use { it.writeObject(user2) }
        val serialized = baos.toByteArray()

        // Десериализация
        ByteArrayInputStream(serialized).use { bais ->
            ObjectInputStream(bais).use { it.readObject() as User2 }
        }
    }

    @[Test OptIn(ExperimentalSerializationApi::class)]
    fun kotlinxSerializable() = benchmarkRule.measureRepeated {
        // Сериализация
        val protobufArray = ProtoBuf.encodeToByteArray(User1.serializer(), user1)

        // Десериализация
        val result: User1 = ProtoBuf.decodeFromByteArray(User1.serializer(), protobufArray)
    }

    @Test
    fun androidParcelable() = benchmarkRule.measureRepeated {
        // Сериализация
        val source = Parcel.obtain()
        source.writeParcelable(user1, 0)
        val bytes = source.marshall()
        source.recycle()

        // Десериализация
        val destination = Parcel.obtain()
        destination.unmarshall(bytes, 0, bytes.size)
        destination.setDataPosition(0)

        val classLoader = User1::class.java.classLoader
        val result: User1? = destination.readParcelable<User1>(classLoader, User1::class.java)
        destination.recycle()
    }
}

Обратите внимание на несколько важных деталей реализации этого бенчмарка, которые обеспечивают честность сравнения. Для Java Serializable и Externalizable мы убрали промежуточное создание файла, которое обычно демонстрируется в учебных примерах. Вместо этого используем ByteArrayOutputStream и ByteArrayInputStream для работы напрямую с байтовыми массивами в памяти. Почему это важно? Потому что при передаче между процессами и компонентами в Android нет промежуточного слоя для записи в файл. Данные передаются через память. Parcelable работает именно с байтами в памяти через буфер Parcel, и чтобы сравнение было корректным, Java Serializable и Externalizable также должны работать с массивами байт без дисковых операций.

Второй важный момент касается выбора формата для kotlinx.serialization. Мы используем ProtoBuf, а не JSON, хотя JSON гораздо популярнее и чаще ассоциируется с этой библиотекой. Причина проста: JSON это текстовый формат, который требует дополнительных затрат на парсинг строк, обработку escape-последовательностей, преобразование чисел из текстового представления в бинарное. ProtoBuf это бинарный формат, концептуально аналогичный тому, что используют Serializable, Externalizable и Parcelable. Все они работают с бинарным представлением данных. Если бы мы сравнивали JSON с бинарными форматами, kotlinx.serialization выглядела бы хуже не из-за качества самой библиотеки, а исключительно из-за особенностей текстового представления. Поэтому для честного сравнения выбран ProtoBuf. Это демонстрирует реальные возможности кодогенерации kotlinx.serialization без искусственного handicap’а в виде текстового формата.

Все тесты измеряют полный цикл: сериализация объекта в байты и немедленная десериализация обратно в объект. Это реалистичный сценарий для Android IPC, где данные сериализуются на стороне отправителя и сразу же десериализуются на стороне получателя. Далее результаты теста:

Результаты сериализации: байтовые представления

Для полного понимания различий между подходами рассмотрим, как выглядят сериализованные данные нашего объекта User в каждом из форматов.

Java Serializable:

Размер: 388 байт

Строковое представление (все байты): ¬í??sr??kz.android.benchmark.User1????????????????I??ageZ??isActiveJ??registrationDateL??emailt??Ljava/lang/String;L??idq??~??L??nameq??~??L??tagst??Ljava/util/List;xp??????7????…j È??t??john.wick@continental.comt??user_123456789t??	John Wicksr??java.util.Arrays$ArrayListÙ¤<¾ÍˆÒ??[??at??[Ljava/lang/Object;xpur??[Ljava.lang.String;­ÒVçé{G????xp??????t??assassint??	legendaryt??	baba_yagat??continental

Hex представление (все байты): aced00057372001a6b7a2e616e64726f69642e62656e63686d61726b2e557365723100000000000000010200074900036167655a000869734163746976654a0010726567697374726174696f6e446174654c0005656d61696c7400124c6a6176612f6c616e672f537472696e673b4c0002696471007e00014c00046e616d6571007e00014c0004746167737400104c6a6176612f7574696c2f4c6973743b78700000003701000001856aa0c8007400196a6f686e2e7769636b40636f6e74696e656e74616c2e636f6d74000e757365725f3132333435363738397400094a6f686e205769636b7372001a6a6176612e7574696c2e4172726179732441727261794c697374d9a43cbecd8806d20200015b0001617400135b4c6a6176612f6c616e672f4f626a6563743b7870757200135b4c6a6176612e6c616e672e537472696e673badd256e7e91d7b47020000787000000004740008617373617373696e7400096c6567656e64617279740009626162615f7961676174000b636f6e74696e656e74616c

Статистика: Нулевых байт: 46, ASCII символов: 292

Java Externalizable:

Размер: 166 байт

Строковое представление (все байты): ¬í??sr??kz.android.benchmark.User2¼•xÁ’L±????xpwt??user_123456789??	John Wick??john.wick@continental.com??????7????…j È??????????assassin??	legendary??	baba_yaga??continentalx

Hex представление (все байты): aced00057372001a6b7a2e616e64726f69642e62656e63686d61726b2e5573657232bc95789dc1924cb10c000078707774000e757365725f31323334353637383900094a6f686e205769636b00196a6f686e2e7769636b40636f6e74696e656e74616c2e636f6d0000003701000001856aa0c800000000040008617373617373696e00096c6567656e646172790009626162615f79616761000b636f6e74696e656e74616c78

Статистика: Нулевых байт: 20, ASCII символов: 122

Kotlinx.serialization (ProtoBuf):

Размер: 110 байт

Строковое представление (все байты): user_123456789	John Wickjohn.wick@continental.com 7(0€ƒÕÖ0:assassin:	legendary:	baba_yaga:continental

Hex представление (все байты): 0a0e757365725f31323334353637383912094a6f686e205769636b1a196a6f686e2e7769636b40636f6e74696e656e74616c2e636f6d2037280130809083d5d6303a08617373617373696e3a096c6567656e646172793a09626162615f796167613a0b636f6e74696e656e74616c

Статистика: Нулевых байт: 0, ASCII символов: 94

Android Parcelable:

Размер: 296 байт

Строковое представление (все байты): ??????k??z??.??a??n??d??r??o??i??d??.??b??e??n??c??h??m??a??r??k??.??U??s??e??r??1????????????????u??s??e??r??_??1??2??3??4??5??6??7??8??9??????????	??????J??o??h??n?? ??W??i??c??k????????????j??o??h??n??.??w??i??c??k??@??c??o??n??t??i??n??e??n??t??a??l??.??c??o??m??????7??????????????È j…????????????????a??s??s??a??s??s??i??n??????????	??????l??e??g??e??n??d??a??r??y??????	??????b??a??b??a??_??y??a??g??a????????????c??o??n??t??i??n??e??n??t??a??l??????

Hex представление (все байты): 1a0000006b007a002e0061006e00640072006f00690064002e00620065006e00630068006d00610072006b002e0055007300650072003100000000000e00000075007300650072005f0031003200330034003500360037003800390000000000090000004a006f0068006e0020005700690063006b000000190000006a006f0068006e002e007700690063006b00400063006f006e00740069006e0065006e00740061006c002e0063006f006d000000370000000100000000c8a06a85010000040000000800000061007300730061007300730069006e0000000000090000006c006500670065006e00640061007200790000000900000062006100620061005f00790061006700610000000b00000063006f006e00740069006e0065006e00740061006c000000

Статистика: Нулевых байт: 169, ASCII символов: 113

Сравнительная таблица размеров

ПодходРазмер (байт)Относительно минимума
kotlinx.serialization (ProtoBuf)110Базовая линия (100%)
Java Externalizable166+51%
Android Parcelable296+169%
Java Serializable388+253%

Результаты могут показаться неожиданными. Parcelable, который мы позиционировали как оптимизированное решение для Android, занимает почти в три раза больше места, чем kotlinx.serialization и почти в два раза больше, чем Java механизмы. Почему?

Ответ кроется в кодировке строк. Parcel использует UTF-16 для хранения всех строковых данных. Посмотрите внимательно на hex представление Parcelable: между каждым символом видны нулевые байты (00). Это характерная особенность UTF-16, где каждый ASCII символ занимает не один, а два байта. Наш объект User содержит пять строковых полей: id, name, email, и четыре элемента в списке tags. В UTF-16 строка “John Wick” (9 символов) занимает 18 байт, “john.wick@continental.com” (25 символов) занимает 50 байт, и так далее.

В то же время Externalizable и kotlinx.serialization используют более компактные форматы. ObjectOutputStream.writeUTF() использует модифицированную версию UTF-8, где ASCII символы занимают один байт. Protocol Buffers использует собственную эффективную кодировку с variable-length encoding для чисел и оптимизированное представление строк.

Но вспомним контекст: Parcelable создавался не для минимизации размера данных при долговременном хранении, а для максимально быстрой передачи между процессами Android через Binder. UTF-16 выбрана неслучайно. Это нативная кодировка для Java/Kotlin строк в памяти. Когда Parcel записывает строку в UTF-16, он по сути копирует внутреннее представление String напрямую в буфер без перекодирования. Это быстро. При чтении десериализации также не требуется конвертация. Байты просто превращаются обратно в String. Никаких накладных расходов на перекодировку из UTF-8 в UTF-16 и обратно, как это происходит с другими механизмами.

Более того, в контексте IPC через Binder разница в 100-200 байт несущественна. Данные живут в памяти микросекунды, передача происходит внутри устройства через kernel с минимальным копированием. Здесь важнее скорость сериализации/десериализации, а не размер буфера. Если бы вам нужно было отправить данные по сети или сохранить в файл, выбор был бы другим. Но для IPC между Activity, Service и другими Android компонентами компромисс оправдан: больше памяти, но быстрее обработка.

Результаты производительности

Тестирование проведено на устройстве Nothing Phone (2a) (модель A065) с процессором Dimensity 7200 Pro (8 ядер, до 2.99 GHz), 12 GB RAM, Android 15 (API 35). Все тесты выполнены с использованием Jetpack Benchmark library в режиме компиляции speed с автоматическим прогревом JIT компилятора и статистической обработкой результатов.

Измеряем полный цикл: сериализация объекта в байты + немедленная десериализация обратно в объект. Это реалистичный сценарий для IPC, где данные сразу же читаются на другой стороне.

Таблица результатов (медианные значения):

ПодходВремя (нс)Время (мкс)Относительно fastestАллокаций
Android Parcelable2,8242.82Базовая линия (1.0×)12
kotlinx.serialization (ProtoBuf)4,7074.711.67× медленнее41
Java Externalizable9,5319.533.38× медленнее83
Java Serializable30,98530.9910.97× медленнее201

Детальная статистика:

ПодходMin (нс)Max (нс)Median (нс)CV*Итераций
Android Parcelable2,7992,8672,8240.56%42,680
kotlinx.serialization4,3984,8824,7072.47%16,697
Java Externalizable9,25210,1309,5311.88%11,228
Java Serializable30,35632,24330,9851.67%2,887

*CV (Coefficient of Variation) - коэффициент вариации, показывает стабильность результатов. Чем ниже, тем стабильнее.

Анализ результатов

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

Android Parcelable - безоговорочный лидер по скорости. Медианное время полного цикла составляет всего 2.82 микросекунды. Это в 1.67 раза быстрее kotlinx.serialization, в 3.4 раза быстрее Externalizable и почти в 11 раз быстрее Serializable. Коэффициент вариации всего 0.56% говорит об исключительной стабильности результатов. Причины такой производительности мы разбирали: прямая работа с памятью через JNI, отсутствие слоев абстракции, использование нативного UTF-16 без перекодирования, интеграция с Binder на уровне ядра, object pooling для минимизации аллокаций. Всего 12 аллокаций на операцию. Это Parcel.obtain() из пула и минимальные служебные объекты. Да, объект занимает 296 байт из-за UTF-16, но в контексте IPC это цена, которую стоит заплатить за такую скорость.

kotlinx.serialization (ProtoBuf) - баланс скорости и размера. Медиана 4.71 микросекунды, всего в 1.67 раза медленнее Parcelable, но при этом размер данных почти в три раза меньше (110 байт против 296). Это впечатляющий результат для решения, которое работает кроссплатформенно. Кодогенерация через плагин компилятора дает прямой доступ к полям без рефлексии, Protocol Buffers использует эффективную бинарную кодировку с variable-length encoding. 41 аллокация. Это создание ByteArray, внутренние буферы кодировщика и декодера. Коэффициент вариации 2.47% чуть выше, чем у Parcelable, но всё еще приемлем. Если бы это был JSON вместо ProtoBuf, результаты были бы хуже из-за парсинга текста и большего размера данных.

Java Externalizable - ручной труд без явных преимуществ. Медиана 9.53 микросекунды, в 3.4 раза медленнее Parcelable и в 2 раза медленнее kotlinx.serialization. При этом размер данных (166 байт) больше, чем у ProtoBuf (110 байт), хоть и меньше, чем у Parcelable (296 байт). Почему так? Мы вручную пишем данные через ObjectOutput, но это всё равно проходит через ObjectOutputStream с его буферизацией и протоколом. Создается ByteArrayOutputStream, затем ObjectOutputStream оборачивает его, записываются служебные маркеры протокола (TC_OBJECT, TC_BLOCKDATA), затем наши данные, затем ByteArrayInputStream и ObjectInputStream для чтения. 83 аллокации. Это всё те промежуточные объекты потоков и буферов. Мы получили контроль над порядком полей и возможность кастомной логики, но не получили реальной производительности. В современном мире с kotlinx.serialization и Parcelable у Externalizable остается мало сценариев применения.

Java Serializable - худший по всем параметрам. Медиана 30.99 микросекунд, почти в 11 раз медленнее Parcelable. Это не просто медленно, это катастрофически медленно для мобильного устройства. Размер данных 388 байт, самый большой из всех. 201 аллокация, в 16 раз больше, чем у Parcelable. Причины мы знаем: рефлексия через ObjectStreamClass.lookup(), обход полей через Field.get(), создание дескрипторов для каждого класса в иерархии, запись полных имен классов и типов полей, создание множества временных объектов. Коэффициент вариации 1.67% говорит о том, что результаты стабильны, но это плохое утешение, когда вы стабильно медленны. Единственное преимущество Serializable - простота добавления к классу (просто : Serializable), но цена этой простоты слишком высока для production кода.

Важное наблюдение о количестве итераций. Обратите внимание на колонку “Итераций” в таблице. Parcelable выполнил 42,680 итераций за отведенное время, kotlinx.serialization выполнил 16,697, Externalizable выполнил 11,228, а Serializable всего 2,887. Это не случайные числа. Benchmark library выполняет столько итераций, сколько успевает за фиксированное время с учетом статистической значимости. Чем медленнее операция, тем меньше итераций успевает выполниться. Разница в 14.8 раза между Parcelable (42,680) и Serializable (2,887) наглядно иллюстрирует пропасть в производительности.

Заключение: так говорили бенчмарки

Мы прошли путь от самых истоков сериализации в Java до современных кроссплатформенных решений. Начали с Serializable, который появился в JDK 1.1 и до сих пор используется, несмотря на очевидные проблемы с производительностью и безопасностью. Разобрали Externalizable, который дает контроль, но не решает фундаментальных проблем Java сериализации. Изучили Parcelable, созданный Google специально для Android IPC, где каждая микросекунда и каждая аллокация имеют значение. И завершили современным kotlinx.serialization, который работает везде, где работает Kotlin, от Android до iOS, от JVM до Native.

Цифры из бенчмарков говорят сами за себя. Parcelable в 11 раз быстрее Serializable и создает в 16 раз меньше объектов. kotlinx.serialization генерирует данные в 3.5 раза компактнее Serializable при сопоставимой скорости с Parcelable. Но главное не в абсолютных цифрах. Главное в понимании того, почему эти цифры именно такие. Рефлексия против кодогенерации. Универсальность против специализации. Простота использования против контроля.

Не существует одного правильного ответа на вопрос “какую сериализацию использовать”. Есть контекст, требования, ограничения. Для Android IPC выбор очевиден. Для сетевых API другой. Для кроссплатформенных проектов третий. Но теперь, когда вы знаете, как работает каждый подход под капотом, вы можете делать осознанный выбор, а не повторять чужие утверждения из заголовков статей.

Спасибо, что дочитали до конца. Надеюсь, эта статья дала вам не просто сравнительную таблицу, а глубокое понимание эволюции сериализации в экосистеме JVM и Kotlin. Теперь на собеседовании, когда вас спросят “Почему Parcelable быстрее Serializable?”, вы сможете объяснить про JNI, UTF-16, object pooling и Binder, а не просто сказать “потому что так в документации написано”.

Обсуждение

Комментарии