Сериализация данных: тест производительности и описание применения

Сериализация (Serialize, в последующем «сохранение») – это процесс сохранения данных объекта во внешнем хранилище.
Эта операция работает в паре с обратной – восстановлением данных, называемой десереализацией (Deserealize, в последующем «восстановление»).


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


В Java такие готовые механизмы существуют и, даже, в количестве более одного. Давайте разберемся, какие механизмы есть и какие возможности они предоставляют для программ на Kotlin.


Само понятие сериализации никак не привязано к формату данных, в который будут сохранены данные, поэтому вне зависимости от того, какой результат будет получен – бинарный файл с собственной структурой, формат XML, JSON или даже текстовый файл – все это будет сериализацией.



Сериализация


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


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


Многие классы потоков, такие как Writer или PrintStream предоставляют готовые возможности для сохранения элементарных типов данных, но использовать эти так же неудобно, как и в классических языках программирования из-за очень большого числа описаний, которые необходимо проделывать.
Но, помимо работы с элементарными типами, в Java существует несколько разных типов готовых механизмов для сохранения данных классов и множество библиотек, реализующих работу с одними и теми же форматами, отличающихся друг от друга производительностью, объемом и предоставляемыми возможностями.


Ниже будут рассмотрены типовые способы сохранения данных: встроенные в стандартную библиотеку Java, а так же сохранение в формате XML и JSON.


Serializable


Простейшая возможность, существующая в стандартной библиотеке Java – это сохранение и восстановление данных в полностью автоматическом режиме в бинарной форме. Для реализации этой возможности необходимо всего лишь указать у всех классов, данные которых должны автоматически сохраняться и восстанавливаться, интерфейс Serializable в качестве реализуемого. Это интерфейс «маркер», который не требует реализации ни одного метода. Он используется просто для обозначения того, что данные этого класса должны сохраняться и восстанавливаться.


Пример использования


Использование этого класса элементарно – одной операцией и не требует написания ни одной лишней буквы.


Код программы
class DataClass(s : String) : Serializable {
  @JvmField var strField = ""
  @JvmField var intField = 0
  @JvmField var dbField = 0.0
  protected @JvmField var strProt = ""
  private var strPriv = ""
  @JvmField val valStr : String
  protected @JvmField val valProt : String

  init {
    valStr = s
    valProt = "prot=" + s

    strField = s + ":baseText"
    intField = s.hashCode()
    dbField = s.hashCode().toDouble() / 1000
    strProt = s+":prot"
    strPriv = s+":priv"
  }

  fun print() {
    outn("str = [%s]\nint = [%d]\ndb = [%f]", strField, intField, dbField)
    outn("prot = [%s]\npriv = [%s]", strProt, strPriv) 
    outn("value = [%s]\nprot value = [%s]", valStr, valProt)
  }
}

fun Action() {
  outn( "Simple object IO test" )

  val a = DataClass("dataA")
  outn("Saved contents:")
  a.print()

  Holder(ObjectOutputStream(File("out.bin").outputStream())).h.writeObject(a)
  val b = Holder(ObjectInputStream(File("out.bin").inputStream())).h.readObject()

  outn("Class: %s", b.javaClass.name)
  if (b is DataClass) {
    outn("Loaded contents:")
    b.print()
  }
}

В результате выполнения этого теста мы получим следующий вывод:


Результат
Simple object IO test
Saved contents:
str = [dataA:baseText]
int = [95356375]
db = [95356,375000]
prot = [dataA:prot]
priv = [dataA:priv]
value = [dataA]
prot value = [prot=dataA]
Class: app.test.Externalize.Test$DataClass
Loaded contents:
str = [dataA:baseText]
int = [95356375]
db = [95356,375000]
prot = [dataA:prot]
priv = [dataA:priv]
value = [dataA]
prot value = [prot=dataA]

Как уже видно из результата сохранение и восстановление объекта прошло успешно и, после восстановления, новый объект имеет точно такое же содержимое как сохраняемый.
При выполнении программы был создан файл "out.bin" размером в 244 байта в бинарном формате. Описание формата можно найти во множестве источников, но, на мой взгляд, разбираться в нем не имеет никакого смысла, достаточно, чтобы его успешно понимали операции сохранения и восстановления.


Особенности


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


  • Были сохранены и восстановлены абсолютно все поля, даже те у которых указан тип доступа "private" и "protected".
  • Обработаны были и поля, указанные как «val», т.е. неизменяемые по стандартам Kotlin.
  • Новый объект был создан, хотя конструктора без параметров у него нет, а существующий не вызывался.

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


Подробнее

Для сохранения данных используется специальный поток ObjectOutputStream (и аналоги) для загрузки. Этот поток умеет работать с любыми типами данных и, в том числе, с объектами целиком, чем мы и воспользовались. Формируемые этим потоком данные содержат независимый набор блоков информации, поэтому никаких ограничений на его использование нет. Можно сохранять в один поток сколько угодно объектов или элементарных типов, главное, при восстановлении, прочитать их в обратном порядке.


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


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


Возможности


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


Подробнее
  • Любой уникальный объект сохраняется в поток только один раз. Если сохраняется несколько объектов, которые являются ссылками на один и тот же, то данные объекта будут сохранены только для одного из них, а для остальных будет записана только ссылка на уже сохраненный.
    При восстановлении данных объекты будут восстановлены так, что все ссылки будут восстановлены в том же виде, какой существовал в оригинальных объектах.


  • При сохранении объектов автоматически отслеживаются их ссылки друг на друга и, при восстановлении, аналогичные объекты будут ссылаться на те же объекты. Т.е. если вы сохраняете объект «А» и объект «В» и при этом одним из полей объекта «В» является ссылка на сохраняемый объект «А», то будет сохранено не две различные копии класса «А», а только одна. При восстановлении полей новый объект «В» будет по прежнему ссылаться на объект «А» восстановленный из этого же потока, т.е. будет восстановлена связь меду объектами.
    Эта особенность позволяет абсолютно прозрачно сохранять связную иерархию объектов, ссылающихся друг на друга без разрушения связей и дублирования данных.


  • Поддерживается сохранение классов типа «enum» с корректным их восстановлением.


  • Поддерживается сохранение и восстановление любых объектов, которые имеет интерфейс-маркер Serializable. В частности, будут автоматически сохраняться все стандартные JDK коллекции, основанные на List, Set и Map т.к. все их реализации этот маркер имеют.
    Т.е. для того чтобы сохранить и восстановить все элементы списка или даже дерева не нужно писать никакого дополнительного кода, достаточно чтобы объекты были обозначены интерфейсом "Serializable".


  • Автоматически сохраняются и восстанавливаются данные всех предков и, при наследовании от этого класса, так же будут сохраняться все данные текущего.
    Никаких дополнительных действий для обеспечения сохранения и восстановления всей цепочки наследования предпринимать не нужно.

Для более точного управления процессом сохранения и восстановления данных можно использовать дополнительные механизмы.


Контроль версии


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


Такой механизм контроля совместимости существует. При сохранении библиотека автоматически вычисляет код для используемого класса, который описывает его состояние и, при восстановлении, проверяет совместим ли объект с теми данными, которые были сохранены для него ранее. Если к восстанавливаемому классу были добавлены поля или изменен их порядок, то восстановить данные из сохраненной копии можно, но если поля были удалены или у них изменился тип, то восстановить такие данные уже будет невозможно и, при попытке чтения такого объекта, будет выброшена ошибка.


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


class DataClass : Serializable {
 companion object {
    const private val serialVersionUID = 1L
  }
}

Это поле должно быть статической константой типа Long, описанной в классе.
В случае Kotlin эта константа обязана быть описана с использованием аннотации @JvmStatic или модификатора const, иначе библиотека загрузки его не увидит.


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


Для вычисления значения состояния класса можно воспользоваться утилитой «serialver» из поставки Java, но использовать ее неудобно, поэтому гораздо проще получить это значение программным путем. Для этого нужно в программе, которая использует нужный класс, вызвать метод для вычисления его состояния и полученное значение установить в поле serialVersionUID.


fun Action() {
  println( "ID: %d\n", ObjectStreamClass.lookup(DataClass::class.java).serialVersionUID )
  //…
}

Вывод программы:


ID: 991989581060349712

Управление сохраняемыми данными


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


Первой возможностью управления является механизм исключений.
Для того, чтобы исключить какое-то поле из списка обрабатываемых его нужно пометить специальным типом "transient". В Java для этого используется специальное ключевое слово, а в Kotlin необходимо использовать специальную аннотацию.


class DataClass : Serializable {
  @JvmField var strField = ""
  @JvmField var intField = 0
  @Transient
  @JvmField var dbField = 0.0
}

При обработке объектов этого класса библиотека сериализации не будет ни сохранять ни восстанавливать значения для поля "dbField". Все остальные поля будут сохранены и восстановлены как обычно.
Этот механизм удобно использовать в случаях, когда объекты поля, значения которых не имеет смысла или нельзя сохранять.
Устанавливать значения полей, которые не будут обрабатываться автоматически, программист должен самостоятельно, после загрузки. Для этого можно воспользоваться методом "readResolve", который описан ниже.


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


Код программы
open class DataClass(s : String) : Serializable {
  @JvmField var strField = ""
  @JvmField var intField = 0
  @JvmField var dbField = 0.0

  companion object {
    const private val serialVersionUID1 = 1L
    @JvmStatic val serialPersistentFields = arrayOf(
      ObjectStreamField("strField",String::class.java),
      ObjectStreamField("intField",Int::class.java)
    )
  }

  init {
    strField = s + ":baseText"
    intField = s.hashCode()
    dbField = s.hashCode().toDouble() / 1000
  }

  fun print() =
    outn("str = [%s]\nint = [%d]\ndb = [%f]", strField, intField, dbField)
}

fun Action() {
  val a = DataClass("dataA")
  outn("Saved contents:")
  a.print()

  Holder(ObjectOutputStream(File("out.bin").outputStream())).h.writeObject(a)
  val b = Holder(ObjectInputStream(File("out.bin").inputStream())).h.readObject()

  if (b is DataClass) {
    outn("Loaded contents:")
    b.print()
  }
}

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


Saved contents:
str = [dataA:baseText]
int = [95356375]
db = [95356,375000]
Loaded contents:
str = [dataA:baseText]
int = [95356375]
db = [0,000000]

Сохранение и восстановление данных вручную


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


Предположим, что в нашем объекте у одного из полей изменилось имя и при этом нужно обеспечить сохранение
и загрузку данных и с новым и со старым именем. Средства автоматизации справиться с таким изменением неспособны, но можно оперировать полями объекта вручную. У сериализуемого объекта можно описать функции writeObject и readObject, которые будут вызваны для загрузки и сохранения его содержимого.


Код программы
open class DataClass(s : String) : Serializable {
  @JvmField var strField = ""
  @JvmField var intFieldChanged = 0
  @JvmField var dbField = 0.0

  companion object {
    const private val serialVersionUID1 = 1L
    @JvmStatic val serialPersistentFields = arrayOf(
      ObjectStreamField("strField", String::class.java),
      ObjectStreamField("intField", Int::class.java)
    )
  }

  init {
    strField = s + ":baseText"
    intFieldChanged = s.hashCode()
    dbField = s.hashCode().toDouble() / 1000
  }

  fun print() =
    outn("str = [%s]\nint = [%d]\ndb = [%f]", strField, intFieldChanged, dbField)

  private fun readObject(s : ObjectInputStream) {
    val fields = s.readFields()
    strField = fields.get("strField", "" as Any?) as String
    intFieldChanged = fields.get("intField", 0)
  }

  private fun writeObject(s : ObjectOutputStream) {
    val fields = s.putFields()
    fields.put("strField", strField as Any?)
    fields.put("intField", intFieldChanged)
    s.writeFields()
  }
}

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


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


Код программы
open class DataClass(s : String) : Serializable {
  @JvmField var strField = ""
  @JvmField var intField = 0

  companion object {
    const private val serialVersionUID1 = 1L
  }

  init {
    strField = s + ":baseText"
    intField = s.hashCode()
  }

  fun print() =
    outn("str = [%s]\nint = [%d]", strField, intField)

  private fun readObject(s : ObjectInputStream) {
    strField = s.readUTF()
    intField = s.readInt()
  }

  private fun writeObject(s : ObjectOutputStream) {
    s.writeUTF(strField)
    s.writeInt(intField)
  }
}

Восстановление синглетонов


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


Это поведение, так же, можно обеспечить.


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


Код программы
class LinkedData private constructor(@JvmField val value : Int) : Serializable {
  companion object {
    @JvmField val ZERO = LinkedData(0)
    @JvmField val NONZERO = LinkedData(1)
    @JvmStatic fun make(v : Int) = if (v == 0) ZERO else NONZERO
  }

  private fun readResolve() : Any = if ( value == 0 ) ZERO else NONZERO
}

open class DataClass(v : Int) : Serializable {
  @JvmField val link = LinkedData.make(v)
  @JvmField var intField = v

  companion object {
    const private val serialVersionUID1 = 1L
  }

  fun print() =
    outn("int = [%d]\nlink = [%s]",
         intField,
         if (link == LinkedData.ZERO) "ZERO" else
         if (link == LinkedData.NONZERO) "NONZERO" else
           "OTHER!" )
}

Результат работы программы:


Saved contents:
int = [100]
link = [NONZERO]
Loaded contents:
int = [100]
link = [NONZERO]

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


Сохранение нестандартных классов, прокси


В случае, если необходимо реализовать прозрачную работу с собственными классами, например с коллекциями в собственном формате или обеспечить подмену интерфейса класса (проксирование), то это тоже можно реализовать.
Для этого нужно создать наследника для классов ObjectInputStream и ObjectOutputStream переопределив у них методы annotateClass или annotateProxyClass.


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


Недостатки


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


Подробнее

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


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


По моим измерениям, скорость работы этой библиотеки практически сопоставима со скоростью сохранения
и восстановления данных в формат XML, т.е. довольно медленно. Библиотека Serializable несколько быстрее, но значительно уступает в скорости работы практически всем другим способам сохранения и восстановления данных.


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


Формат сохраняемых данных – бинарный.


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


ВАЖНО: Serializable работает только с полями!


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


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



Externalizable


Второй метод, реализуемый штатными средствами Java – это интерфейс Externalizable.


Все объекты реализующие этот интерфейс и их наследники могут быть сохранены в поток теми же классами ObjectInput и ObjectOutput что и реализующие интерфейс Serializable, но, в отличии от последнего, сохранение и восстановление данных объектов происходит полностью в ручном режиме.
Интерфейс реализуется с помощью методов readExternal и writeExternal, на совести которых лежит сохранение и восстановление всех элементов класса.


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


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


Отличия от `Serializable`

Принципиальное отличие работы кода Externalizable заключается в следующем:


  • Не будут автоматически сохраняться данные предков объекта и, для их сохранения нужно явно описать действия по их сохранению.


  • Сохранено будет только то, что явно закодировано в функции сохранения и именно в той форме, в которой оно закодировано. Никакой автоматизации по сохранению полей объекта не будет.


  • Любая автоматизация по восстановлению объектов и ссылок на них будет производиться только тогда, когда они сохраняются и восстанавливаются как объекты.


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


  • Создание объектов производится через вызов его конструктора, поэтому каждый сохраняемый объект обязан иметь конструктор без параметров.

Код программы
class LinkedData private constructor(@JvmField val value : Int) {
  companion object {
    @JvmField val ZERO = LinkedData(0)
    @JvmField val NONZERO = LinkedData(1)
    @JvmStatic fun make(v : Int) = if (v == 0) ZERO else NONZERO
  }
}

open class DataClass : Externalizable {
  @JvmField var link : LinkedData
  @JvmField var intField : Int

  constructor() { link = LinkedData.ZERO; intField = 0 }
  constructor(v : Int) { link = LinkedData.make(v); intField = v }

  fun print() = outn("int = [%d]\nlink = [%s]", intField,
                     if (link == LinkedData.ZERO) "ZERO" else
                       if (link == LinkedData.NONZERO) "NONZERO" else
                         "OTHER!")

  override fun readExternal(s : ObjectInput) {
    link = if ( s.readByte().toInt() == 0 ) LinkedData.ZERO else LinkedData.NONZERO
    intField = s.readInt()
  }

  override fun writeExternal(s : ObjectOutput) {
    s.writeByte(if(link == LinkedData.ZERO) 0 else 1)
    s.writeInt(intField)
  }
}

В этом коде пришлось изменить описание поля link на изменяемое т.к. его приходится устанавливать вручную при загрузке и описать конструктор для создания загружаемого объекта. С целью иллюстрации преимуществ такого способа сохранения данных вместо объекта link сохраняется только описание его состояния.


Код загрузки и восстановления остался тем же, что использовался с примерами ранее, в результате в поток было сохранено имя объекта, но другой служебной информации в нем нет. В результате, объем сохраненного файла уменьшился в несколько раз.
Можно переписать код сохранения объекта «DataClass» с использованием явного вызова методов сохранения и восстановления, тогда доля служебной информации в полученном файле станет совсем незначительной.


Особенности


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


Подробнее

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


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


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


К примеру, если сохранять абсолютно все данные вручную, то использование классов ObjectStream теряет всякий смысл, а связанные с их использованием накладные расходы и ограничения могут только мешать. Становится гораздо проще использовать возможности записи и чтения данных, предоставляемые любым бинарным потоком.


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



Тестирование, сравнения


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


Данные, используемые для тестирования

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


Автоматически генерируемые случайные данные выглядят следующим образом:


  0) <noname>   addCaller.t_ForOrGetPut   org.sun.NotNotEmptyGetEach( SetEmptyCombineSplit )
  1) fNotRandom           addCaller.For(void, addCaller.t_HasEmpty Has, addCaller.t_Combine GetCombineHas )
  2) fOrSet           app.PutHasForGet( app.t_Set HasCombineAdd, app.t_ForNotOrAdd Combine )
  3) fHasSplit            org.sun.Set(  Set, sec.sun.t_JoinEmptyHasCombineCombine EmptyCombineOr )
  4) fJoinEach    sec.sun.t_OrForEmptySet   sec.sun.EachPutOrNot( org.sun.t_Combine SetHasSplitJoinEmpty, void )
  5) fEachSet   org.sun.t_RandomSplit   app.OrIsFor( sec.sun.t_Set CombineGetRandom, void )
  6) <noname>   app.t_NotSetForForGet   sun.NotHasForSplitAdd( org.sun.t_IsRandomOrHas Each, void)
  7) fNotSplit    addCaller.t_NotAdd      sec.sun.IsHasNot( app.t_HasForSplitHas ForGet, void )
  8) <noname>           sun.SetForSplitSet( PutCombine, void, void )
  9) fCombineNot  sun.t_SplitRandomGetRandom  sun.AddAdd( void, org.sun.t_NotRandomHasEmpty AddPutNotSplit )
 10) <noname>   addCaller.t_ForIs     sun.EachIs( NotFor, void, void,  PutSplitAddNot )
 11) fSetOr           app.HasJoin( OrOr, void, void, addCaller.t_NotAddHas Each )

Имена методов, типов и исходных файлов генерируются из набора случайных имен.
Имя исходного файла может отсутствовать, а текст <noname> и void обозначает синглетоны, обозначающие уникальные значения для имени файла или параметра.


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


Используемые средства


При тестировании использовались следующие способы сохранения и восстановления данных:


  • SerialFull — Полностью автоматическая работа интерфейса Serializable.


  • Extern+Ser — Реализация интерфейса Externalizable с кодом, в котором смешано ручное и автоматизированное сохранение данных.


  • ExternFull — Реализация интерфейса Externalizable с полностью ручным сохранением данных.


  • JsJsonMini — Библиотека «minimal-json», сохраняющая данные в формате JSON.
    В тесте использовалась библиотека minimal-json-0.9.4.jar, домашняя страница этого проекта находится тут: https://github.com/ralfstx/minimal-json.


  • Библиотека fasterXML-jackson так же сохраняющая данные в формате JSON.
    В тесте использовалась библиотека версии 2.0.4, домашняя страница этого проекта находится тут: https://github.com/FasterXML/jackson. С использованием этой библиотеки было реализовано два алгоритма работы с данными. Первый из них (JsJackAnn), полностью автоматический, управлялся только аннотациями, который называется в этой библиотеке annotations-databind подходом. Во втором (JsJackSream) было реализовано полностью ручной разбор дерева, называемого в этой библиотеке stream подходом.


  • Реализация штатного механизма Java для сохранения XML данных на основании классов «org.w3c.dom.Document и org.w3c.dom.Element.

В таблице ниже приведены данные о каждом использованном средстве.


Название Версия Источник Дополнительные библиотеки
XML, Serializable, Externalizable Java 1.8 Штатная реализация Java Не требуются, входят в комплект поставки Java.
minimal-json 0.9.4 https://github.com/ralfstx/minimal-json minimal-json-0.9.4.jar – 30Кб
fasterXML-jackson 2.0.4 https://github.com/FasterXML/jackson jackson-core-2.0.4.jar – 194Кб, jackson-databind-2.0.4.jar – 847Кб, jackson-annotations-2.0.4.jar – 34Кб

Процедура тестирования


Для тестирования была реализована утилита, исходный тест которой доступен по ссылке, приведенной в конце.


Описание утилиты тестирования

Интерфейс утилиты:


USAGE: SerializableTest.jar [-opts]

Where OPTS are:
  -Count=<number>        - set number of items to generate
  -Retry=<number>        - set number of iterations for each test
  -Out=<file>            - set file name to output
  -Nout                  - disable items output
  -gc                    - run gc after every test

Эта утилита последовательно производит следующие действия:


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

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



Результаты


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


Вывод программы тестирования
>sb -n "-c=100000" "-r=10"
Output file       : test_out
Number of elements: 100000
Number of retries : 10
Tests complete in 0:01:28.050 sec :: Save 0:00:31.903, Load 0:00:30.149, Total 0:01:02.103, Waste 0:00:25.947

В этом тесте создавалось 100.000 случайных элементов, которые были записаны на диск и прочитаны с него 10 раз каждым из тестов. На тест было затрачено 1мин 28сек, из которых 25.9сек на накладные расходы утилиты тестирования.


Место Имя Запись Лучш Худш Загрузка Лучш Худш Всего Лучш Худш Файл
6 SerialFull 0:00:07.599 2,34 0:00:04.217 1,05 1,45 0:00:11.826 1,56 0,41 18Мб
1 ExternFull 0:00:02.550 0,12 1,98 0:00:02.061 4,02 0:00:04.616 2,60 16Мб
5 Extern+Ser 0:00:05.744 1,52 0,32 0:00:04.112 1,00 1,51 0:00:09.862 1,14 0,69 22.5Мб
7 XMLw3c 0:00:06.278 1,76 0,21 0:00:10.337 4,02 0:00:16.620 2,60 32Мб
4 JsJsonMini 0:00:04.678 1,05 0,62 0:00:04.614 1,24 1,24 0:00:09.302 1,02 0,79 25.9Мб
3 JsJackAnn 0:00:02.776 0,22 1,74 0:00:02.431 0,18 3,25 0:00:05.215 0,13 2,19 25.9Мб
2 JsJackSream 0:00:02.278 2,34 0:00:02.377 0,15 3,35 0:00:04.662 0,01 2,56 25.9Мб

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


  • В колонке «место» указано место, которое занял тест от быстрого к медленному.
  • В колонке «имя» указан псевдоним теста.
  • В колонках «запись», «чтение» и «всего» указано время, в секундах, которое было потрачено тестом на выполнение соответствующей операции.
  • В колонке «лучш» и «худш» указано отличие, в «разах» от лучшего и худшего представителя в соответствующей категории.
  • Ячейками без данных обозначен лучший и худший результат в категории.
  • В колонке «файл» указан размер файла, который был сгенерирован для одного набора данных.


Комментарии


Serializable


Этот механизм очень медленный.


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


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


Externalizable


Этот способ предсказуемо оказался самым скоростным при работе и генерирующим минимальные файлы данных.


Подробнее

Однако скоростные и объемные показатели этого теста становятся заметными только при полном отказе от автоматизации для всех, часто используемых операций. Как только автоматизация используется более широко (тест Extern+Ser) производительность программы стремительно падает, а объем файла данных растет. Причины этого явления описаны в главе ранее.


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


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


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


Использовать такой способ не имеет особого смысла т.к. он крайне многословен и не имеет особых преимуществ в объеме данных или скорости ни перед библиотеками JSON, ни перед ручнй реализаций собственного формата.


minimal-json


Эта библиотека является очень маленькой по объему, но работает медленно.


Подробнее

Реализовать ее использование достаточно просто, хотя в процессе кодирования изрядно досаждают некоторые нелогичные условности реализации. При загрузке данных все они грузятся в память, поэтому эта библиотека занимает сравнительно много места в памяти. Никаких способов кроме полностью ручного формирования дерева для записи и его разбора при загрузке в библиотеке нет.


Маленький размер – это, по сути, единственное достоинство этой библиотеки.


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


fasterXML-jackson databind


Библиотека очень большая, работает быстро, но требует больших усилий по адаптации данных для ее использования.


Подробнее

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


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


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


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


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


Использовать эту библиотеку имеет смысл в случаях, если:


  • Существует множество несложных классов и часть сохраняемых свойств не существует в виде полей.


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


  • Размер используемых библиотек не играет никакой роли.

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


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


fasterXML-jackson stream


Самый быстрый способ сохранить и загрузить данные в формате JSON с минимальными требованиями к объему памяти.


Подробнее

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


Для использования этого способа достаточно библиотеки jackson-core, которая занимает 200Кб, что в 4 раз меньше объема для использования databind подхода.


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


XML


Этот способ является самым медленным и предъявляет самые большие требования к объему памяти.


Дерево для всех объектов файла дынных не просто строится при загрузке данных, но при этом потребляет просто неприличный ее объем. При тестировании этот тест не смог выполниться с количеством элементов более 400.000 штук из-за того, что 5Гб выделенной для JVM памяти оказалось недостаточно.


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


Использовать этот способ ни для больших исходных данных, ни для операций, где важна производительность, нельзя. Он работает медленно и потребляет много памяти.


Ссылки


Текст утилиты тестирования можно скачать по ЭТОЙ ССЫЛКЕ.

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 15

    0

    Можно вместо putFields просто писать данные в поток.


    private fun writeObject(s : ObjectOutputStream) {
        s.putObject(strField as Any?)
        s.putInt(intFieldChanged)
      }

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

      0

      Методы putFields \ readFields используются немного для другого.
      Они предназначены для загрузки полей, сохраненных ранее с другими именами или типами и, соответственно, для сохранения текущих полей в формате, который отличается от существующего в объекте.
      Добиться такого поведения простым сохранением объекта невозможно. Вернее, чтобы это обеспечить придется все сохранение\восстановление реализовывать самостоятельно.


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

        0

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


        Библиотека "RuedigerMoeller/fast-serialization", конечно, в 10 раз быстрее физически быть не может, но, возможно она действительно быстрая.
        Можно ее добавить в тест, если это кому-нибудь интересно.

          0

          Насколько можно доверять вашим замерам? Полноценный бенчмаркинг дело нелегкое, люди обычно пишут используют фреймворки, чтобы замеры были как можно более чистыми. По типу https://github.com/dotnet/BenchmarkDotNet для .NET

            0

            Абсолютно нельзя доверять.
            Ровно как и любому другому бенчмарку в любой области, буть то Antutu для телефонов или мой тест для библиотеки сериализации.


            Любой тест — это замер очень конкретных кода и данных. Единственное что можно сделать при тестировании универсальным — это нивелировать влияние платформы на которой запускается тест на его результаты. Я постарался это сделать. В остальном же, результаты будут очень сильно зависеть от той модели данных, которой оперирует тест.


            Возьмем, к примеру, библиотеку "minimal-json". Я не утверждаю что тесты, опубликованные на ее странице являются полным враньем только потому, что на моих данных она демонстрирует в разы худшие результаты. Различные данные накладывают свой отпечаток на результаты.
            Если посмотреть тесты, которые используются для этой библиотеки, то видно, что они основаны на очень маленьких наборах данных (примеры для тестов по 20-50Кб), тогда как в моем тесте за один раз обрабатывались объемы данных на два порядка больше.
            Во-первых, мне лично неинтересны результаты тестирования для маленьких наборов данных и, во-вторых, я считаю что работа с большими объемами демонстрирует преимущества и недостатки библиотек гораздо более ярко. Поэтому я считаю что мой тест для этой конкретной библиотеки демонстрирует более адекватные результаты чем те, которые приведены на ее сайте.


            Итого, возвращаясь к вопросу о доверии.


            1. Если данные, которыми Вы планируете оперировать более похожи на мои по объему и\или типу, то доверять соотношению производительности сравниваемых средств вполне можно. Если бы я так не считал, то не опубликовал бы статью вообще :)


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


            3. Простейшая библиотека для парсинга xml (!), которую я использую повседневно, работает на порядок (!!) быстрее лидеров этого теста. Правда реализована она на С++.
              Я специально убил довольно много времени на утилиту тестирования и на эту статью для того, чтобы не только поделиться своими результатами, но и обеспечить возможность всем провести собственные тесты именно для JVM.
              Если возникают сомнения в моих результатах, то можно вынуть исходники утилиты тестирования, собрать ее, погонять в разных условиях и сделать собственные выводы.
              Переделать модель используемых данных в ней будет довольно сложно, но поиграть с объемами можно легко.
            0
            Вы тут про Serializable, вот я и написал про FST.
            FST, в первую очередь, интересна как JDK Serialization compatible drop-in replacement. И она действительно неплоха в этом качестве.
              0

              Ткни плз пальцем в конкретный пример ее использования.
              Я почитал документацию, но мне показалось ее использование излишне усложненным, а простые примеры не папались.

        • UFO just landed and posted this here
            0
            Что это значит? В общем случае List и Map не Serializable, не знаю что имеется ввиду под Tree.

            Это значит, что поля с данными такого типа Serializable сохранит и восстановит автоматически.
            Иллюстрация:


            package jm.test.ktest
            
            import java.io.File
            import java.io.ObjectInputStream
            import java.io.ObjectOutputStream
            import java.io.Serializable
            
            class AA(ar : List<String>) : Serializable {
              @JvmField var list = ar
            
              override fun toString() : String =
                "AA( ${list.joinToString(",")} )"
            }
            
            fun main(args : Array<String>) {
            
              val a = AA(listOf("One", "Two", "Three"))
              println("Orig: $a")
              ObjectOutputStream(File("out.bin").outputStream()).use {
                it.writeObject(a)
              }
            
              ObjectInputStream(File("out.bin").inputStream()).use {
                println("Read: " + (it.readObject() as AA) )
              }
            }

            Вывод:


            Orig: AA( One,Two,Three )
            Read: AA( One,Two,Three )

            Наверное я криво это описал в тексте?
            Стоит как-то исправить или примера выше будет достаточно?


            На счет аннотаций Kotlin спасибо, почерпну.
            Вообще в Kotlin крайне много недокументированного, что часто раздражает.
            Например пришлось из его исходников вырезать списки ошибок и предупреждений, чтобы знать какой текст писать в @Suppress("?")

            • UFO just landed and posted this here
                0

                Согласен, можно реализовать интерфейс без маркера Serializable.


                Переформулирую так:


                Поддерживается сохранение и восстановление любых объектов, которые имеет интерфейс-маркер Serializable. В частности, будут автоматически сохраняться все стандартные JDK коллекции, основанные на List, Set и Map т.к. все их реализации этот маркер имеют.


                подходит?

                • UFO just landed and posted this here
                    0

                    Дык это и правильно — описание и должно соответствовать документации, иначе это дезинформация :)
                    Правда эта документация…
                    В документации Java есть многое, но проблема в том, что мест где все это сведено воедино с примерами применения и ссылками на аналоги, как правило нет и приходится рыть кучи альтернативных источников.
                    Из-за этого, собственно, и свел все в один текст.


                    ПС: Текст статьи поправил, спасибо за уточнение.
                    Отдельное спасибо за наводку о возможности реализовывать интерфейсы делегатами. Не знал об этом :)

                  0

                  К сожалению, это плохое решение на практике, так как часто в качестве типа чего угодно (полей, параметров, возвращаемых значений) используется просто List, и по нему статически неясно, сериализуемый он или нет. Если объявить класс Foo с такой сигнатурой, он будет несовместим с большинством существующего кода. Даже какие-нибудь Collections.unmodifiableList возвращает просто List, который сериализуем, если поданный на вход список сериализуем, и нет в противном случае. Здесь в системе типов джавы определённый провал.

            Only users with full accounts can post comments. Log in, please.