JavaRush /Java блог /Random UA /Серіалізація як вона є. Частина 1
articles
15 рівень

Серіалізація як вона є. Частина 1

Стаття з групи Random UA
На перший погляд серіалізація видається тривіальним процесом. Справді, що може бути простішим? Оголосив клас, що реалізує інтерфейс java.io.Serializable– і всі справи. Можна серіалізувати клас без проблем. Серіалізація як вона є.  Частина 1 - 1Теоретично це справді так. Практично ж є дуже багато тонкощів. Вони пов'язані з продуктивністю, десеріалізацією, з безпекою класу. І ще з багатьма аспектами. Про такі тонкощі й йтиметься розмова. Цю статтю можна розділити на такі частини:
  • Тонкощі механізмів
  • Навіщо потрібенExternalizable
  • Продуктивність
  • Зворотній бік медалі
  • Безпека даних
  • Серіалізація об'єктівSingleton
Приступимо до першої частини -

Тонкощі механізмів

Насамперед, питання на засипку. А скільки існує способів зробити об'єкт, що серіалізується? Практика показує, що понад 90% розробників відповідають це питання приблизно однаково (з точністю до формулювання) – такий спосіб один. Тим часом їх два. Про другий згадують далеко не всі, не кажучи вже про те, щоб сказати щось виразне про його особливості. Отже, які ж ці методи? Про перший пам'ятають усі. Це вже згадана реалізація java.io.Serializable, яка не потребує жодних зусиль. Другий метод - це також реалізація інтерфейсу, але вже іншого: java.io.Externalizable. На відміну від java.io.Serializable, він містить два методи, які необхідно реалізувати - writeExternal(ObjectOutput)і readExternal(ObjectInput). У цих методах якраз і є логіка серіалізації/десеріалізації. Зауваження.Надалі серіалізацію з реалізацією Serializableя іноді називатиму стандартною, а реалізацію Externalizable– розширеною. Ще однезауваження. Я навмисно не торкаюся нині такі повноваження управління стандартної серіалізацією, як визначення readObjectі writeObject, т.к. вважаю ці методи до певної міри некоректними. Ці методи не визначені в інтерфейсі Serializableі є фактично підпірками для обходу обмежень і надання стандартної серіалізації гнучкості. Натомість Externalizableметоди, що забезпечують гнучкість, закладені спочатку. Задамося ще одним питанням. А як, власне, працює стандартна серіалізація з використаннямjava.io.Serializable? А працює вона через Reflection API. Тобто. клас розбирається як набір полів, кожне з яких пишеться у вихідний потік. Думаю, зрозуміло, що ця операція неоптимальна за продуктивністю. Наскільки саме – з'ясуємо пізніше. Між цими двома способами серіалізації існує ще одна серйозна відмінність. А саме – у механізмі десеріалізації. При використанні Serializableдесеріалізації відбувається так: під об'єкт виділяється пам'ять, після чого його поля заповнюються значеннями потоку. Конструктор об'єкта у своїй не викликається. Тут треба ще окремо розглянути таку ситуацію. Добре, наш клас серіалізується. А його батько? Абсолютно необов'язково! Більше того, якщо успадкувати клас від Object- батько вже точно несеріалізується. І хай про поляObjectми нічого не знаємо, але у наших власних батьківських класах вони можуть бути. Що буде із ними? У потік серіалізації вони не потраплять. Які значення вони набудуть при десеріалізації? Подивимося цей приклад:
package ru.skipy.tests.io;

import java.io.*;

/**
 * ParentDeserializationTest
 *
 * @author Eugene Matyushkin aka Skipy
 * @since 05.08.2010
 */
public class ParentDeserializationTest {

    public static void main(String[] args){
        try {
            System.out.println("Creating...");
            Child c = new Child(1);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(baos);
            c.field = 10;
            System.out.println("Serializing...");
            oos.writeObject(c);
            oos.flush();
            baos.flush();
            oos.close();
            baos.close();
            ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
            ObjectInputStream ois = new ObjectInputStream(bais);
            System.out.println("Deserializing...");
            Child c1 = (Child)ois.readObject();
            System.out.println("c1.i="+c1.getI());
            System.out.println("c1.field="+c1.getField());
        } catch (IOException ex){
            ex.printStackTrace();
        } catch (ClassNotFoundException ex){
            ex.printStackTrace();
        }
    }

    public static class Parent {
        protected int field;
        protected Parent(){
            field = 5;
            System.out.println("Parent::Constructor");
        }
        public int getField() {
            return field;
        }
    }

    public static class Child extends Parent implements Serializable{
        protected int i;
        public Child(int i){
            this.i = i;
            System.out.println("Child::Constructor");
        }
        public int getI() {
            return i;
        }
    }
}
Він прозорий – у нас є несеріалізований батьківський клас та серіалізований дочірній. І ось що виходить:
Creating...
Parent::Constructor
Child::Constructor
Serializing...
Deserializing...
Parent::Constructor
c1.i=1
c1.field=5
Тобто при десеріалізації викликається конструктор без параметрів батьківського класу, що не серіалізується . І якщо такого конструктора не буде – при десеріалізації виникне помилка. Конструктор же дочірнього об'єкта, того, який ми десеріалізуємо, не викликається, як було сказано вище. Так поводяться стандартні механізми при використанні Serializable. При використанні Externalizableситуація інша. Спочатку викликається конструктор без параметрів, а потім вже на створеному об'єкті викликається метод readExternal, який і вичитує, власне, всі свої дані. Тому - кожен реалізуючий інтерфейс Externalizable клас повинен мати громадський архітектор без властивостей! Більше того, оскільки всі спадкоємці такого класу теж будуть вважатися такими, що реалізують інтерфейсExternalizable, у них теж має бути конструктор без параметрів! Ходімо далі. Існує такий модифікатор поля, як transient. Він означає, що це поле не повинно бути серіалізованим. Однак, як ви самі розумієте, це вказує лише на стандартний механізм серіалізації. При використанні Externalizableніхто не заважає серіалізувати це поле, так само як і віднімати його. Якщо поле оголошено transient, то при десеріалізації об'єкта воно набуває значення за замовчуванням. Ще один досить тонкий момент. При стандартній серіалізації поля, що мають модифікатор static, не серіалізуються. Відповідно, після десеріалізації це поле значення не змінює. Зрозуміло, при реалізаціїExternalizableсеріалізувати та десеріалізувати це поле ніхто не заважає, проте я вкрай не рекомендую цього робити, тому що це не так. це може призвести до важких помилок. Поля з модифікатором finalсеріалізуються, як і звичайні. За одним винятком їх неможливо десеріалізувати при використанні Externalizable. Бо final-поляповинні бути ініціалізовані в конструкторі, а після цього в readExternal змінити значення цього поля буде неможливо. Відповідно – якщо вам необхідно серіалізувати об'єкт, що маєfinal-поле, вам доведеться використовувати лише стандартну серіалізацію. Ще один момент, який багато хто не знає. При стандартній серіалізації враховується порядок оголошення полів у класі. У всякому разі, так було в ранніх версіях, в JVM версії 1.6 реалізації Oracle вже порядок неважливий, важливий тип та ім'я поля. Склад методів з дуже великою ймовірністю вплине на стандартний механізм, при тому, що поля можуть взагалі залишитися тими ж. Щоб уникнути цього, є наступний механізм. У кожен клас, що реалізує інтерфейс Serializable, на стадії компіляції додається ще одне поле -private static final long serialVersionUID. Це поле містить унікальну ідентифікатор версії серіалізованого класу. Воно обчислюється за вмістом класу – полям, їх порядку оголошення, методам, порядку оголошення. Відповідно, за будь-якої зміни в класі це поле змінить своє значення. Це поле записується в потік серіалізації класу. До речі, це, мабуть, єдиний відомий мені випадок, коли staticполе серіалізується. При десеріалізації значення цього поля порівнюється з наявним у класу у віртуальній машині. Якщо значення не збігаються – ініціюється виняток на кшталт цього:
java.io.InvalidClassException: test.ser2.ChildExt;
    local class incompatible: stream classdesc serialVersionUID = 8218484765288926197,
                                   local class serialVersionUID = 1465687698753363969
Є, однак, спосіб цю перевірку якщо не охвабони, то обдурити. Це може бути корисним, якщо набір полів класу та його порядок вже визначено, а методи класу можуть змінюватися. У цьому випадку серіалізації нічого не загрожує, проте стандартний механізм не дасть десеріалізувати дані з використанням байткоду зміненого класу. Але, як я вже сказав, його можна обдурити. А саме – вручну у класі визначити полеprivate static final long serialVersionUID. У принципі значення цього поля може бути абсолютно будь-яким. Деякі вважають за краще ставити його рівним даті модифікації коду. Деякі взагалі використовують 1L. Для отримання стандартного значення (того, яке обчислюється внутрішнім механізмом) можна використовувати утиліту serialver, що входить до постачання SDK. Після такого визначення значення поля буде фіксовано, отже десеріалізація завжди буде дозволена. Більше того, у версії 5.0 у документації з'явилося приблизно таке: вкрай рекомендується всім серіалізованим класам декларувати це поле в явному вигляді, бо обчислення за умовчанням дуже чутливе до деталей структури класу, які можуть відрізнятися в залежності від реалізації компілятора, і викликати таким чином несподівані InvalidClassExceptionпри десеріалізації. Оголошувати це поле краще якprivate, т.к. воно відноситься виключно до класу, в якому оголошується. Хоча у специфікації модифікатора не обумовлено. Розглянемо тепер ось який аспект. Нехай у нас є така структура класів:
public class A{
    public int iPublic;
    protected int iProtected;
    int iPackage;
    private int iPrivate;
}

public class B extends A implements Serializable{}
Інакше кажучи, ми маємо клас, успадкований від несеріалізованого батька. Чи можна серіалізувати цей клас і що для цього треба? Що буде зі змінними батьківського класу? Відповідь така. Так, серіалізувати екземпляр класу Bможна. Що для цього потрібно? А потрібно, щоб клас Aбув конструктор без параметрів, publicабо protected. Тоді при десеріалізації всі змінні класи Aбудуть ініціалізовані за допомогою конструктора. Змінні класи Bбудуть ініціалізовані значеннями потоку серіалізованих даних. Теоретично можна в класі Bвизначити методи, про які я говорив на початку – readObjectі writeObject, – на початку яких проводити (де-)серіалізацію змінних класу Bчерезin.defaultReadObject/out.defaultWriteObject, а потім – (де-)серіалізацію доступних змінних з класу A(у нашому випадку це iPublic, iProtectedі iPackageякщо Bзнаходиться з тим же пакеті, що і A). Однак, на мій погляд, для цього краще використати розширену серіалізацію. Наступний момент, який я хотів би торкнутися – серіалізація кількох об'єктів. Нехай у нас є така структура класів:
public class A implements Serializable{
    private C c;
    private B b;
    public void setC(C c) {this.c = c;}
    public void setB(B b) {this.b = b;}
    public C getC() {return c;}
    public B getB() {return b;}
}
public class B implements Serializable{
    private C c;
    public void setC(C c) {this.c = c;}
    public C getC() {return c;}
}
public class C implements Serializable{
    private A a;
    private B b;
    public void setA(A a) {this.a = a;}
    public void setB(B b) {this.b = b;}
    public B getB() {return b;}
    public A getA() {return a;}
}
Серіалізація як вона є.  Частина 1 - 2Що станеться, якщо серіалізувати екземпляр класу A? Він потягне за собою екземпляр класу B, який, у свою чергу, потягне екземпляр C, який має посилання на екземпляр A, той самий, з якого все починалося. Замкнене коло та нескінченна рекурсія? На щастя ні. Подивимося на наступний тестовий код:
// initiaizing
A a = new A();
B b = new B();
C c = new C();
// setting references
a.setB(b);
a.setC(c);
b.setC(c);
c.setA(a);
c.setB(b);
// serializing
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(a);
oos.writeObject(b);
oos.writeObject(c);
oos.flush();
oos.close();
// deserializing
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()));
A a1 = (A)ois.readObject();
B b1 = (B)ois.readObject();
C c1 = (C)ois.readObject();
// testing
System.out.println("a==a1: "+(a==a1));
System.out.println("b==b1: "+(b==b1));
System.out.println("c==c1: "+(c==c1));
System.out.println("a1.getB()==b1: "+(a1.getB()==b1));
System.out.println("a1.getC()==c1: "+(a1.getC()==c1));
System.out.println("b1.getC()==c1: "+(b1.getC()==c1));
System.out.println("c1.getA()==a1: "+(c1.getA()==a1));
System.out.println("c1.getB()==b1: "+(c1.getB()==b1));
Що ми робимо? Ми створюємо за екземпляром класів A, Bі C, ставимо їм посилання один на одного, після чого серіалізуємо кожен з них. Потім ми десеріалізуємо їх і проводимо серію перевірок. Що вийде в результаті:
a==a1: false
b==b1: false
c==c1: false
a1.getB()==b1: true
a1.getC()==c1: true
b1.getC()==c1: true
c1.getA()==a1: true
c1.getB()==b1: true
Отже, що можна отримати з цього тесту. Перше. Посилання на об'єкти після десеріалізації відрізняються від посилань до неї. Інакше висловлюючись, при серіалізації/десеріалізації об'єкт було скопійовано. Цей метод іноді використовується для клонування об'єктів. Другий висновок, більш істотний. При серіалізації/десеріалізації кількох об'єктів, що мають перехресні посилання, ці посилання залишаються дійсними після десеріалізації. Інакше кажучи, якщо до серіалізації вони вказували на один об'єкт, то після десеріалізації вони також вказуватимуть на один об'єкт. Ще один невеликий тест на підтвердження цього:
B b = new B();
C c = new C();
b.setC(c);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(b);
oos.writeObject(c);
oos.writeObject(c);
oos.writeObject(c);
oos.flush();
oos.close();
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()));
B b1 = (B)ois.readObject();
C c1 = (C)ois.readObject();
C c2 = (C)ois.readObject();
C c3 = (C)ois.readObject();
System.out.println("b1.getC()==c1: "+(b1.getC()==c1));
System.out.println("c1==c2: "+(c1==c2));
System.out.println("c1==c3: "+(c1==c3));
Об'єкт класу Bмає посилання об'єкт класу C. При серіалізації bсеріалізується разом з екземпляром класу С, після чого той самий екземпляр з серіалізується тричі. Що виходить після десеріалізації?
b1.getC()==c1: true
c1==c2: true
c1==c3: true
Як бачимо, всі чотири десеріалізовані об'єкти насправді є одним об'єктом – посилання на нього рівні. Як і було до серіалізації. Ще один цікавий момент – що буде, якщо одночасно реалізувати у класу Externalizableта Serializable? Як у тому питанні – слон проти кита – хто кого поборе? Поборе Externalizable. Механізм серіалізації спочатку перевіряє його наявність, а потім – наявність SerializableТак що якщо клас B, що реалізує Serializable, успадковується від класу A, що реалізує Externalizable, поля класу B серіалізовані не будуть. Останній момент – успадкування. При успадкування від класу, що реалізує Serializable, ніяких додаткових дій робити не треба. Серіалізація буде поширюватись і на дочірній клас. При наслідуванні від класу, що реалізуєExternalizableНеобхідно перевизначити методи батьківського класу readExternal і writeExternal. Інакше поля дочірнього класу не будуть серіалізовані. В цьому випадку треба б не забути викликати батьківські методи, інакше не будуть серіалізовані вже батьківські поля. * * * З деталями, мабуть, закінчабо. Однак є одне питання, яке ми не торкнулися, глобального характеру. А саме -

Навіщо потрібний Externalizable

Навіщо взагалі потрібна розширена серіалізація? Відповідь проста. По-перше, вона дає набагато більшу гнучкість. По-друге, найчастіше вона може дати чималий виграш за обсягом серіалізованих даних. По-третє, існує такий аспект як продуктивність, про який ми поговоримо нижче . З гнучкістю начебто зрозуміло все. Дійсно, ми можемо керувати процесами серіалізації та десеріалізації як хочемо, що робить нас незалежними від будь-яких змін у класі (як я говорив трохи вище, зміни у класі здатні сильно вплинути на десеріалізацію). Тому хочу сказати кілька слів про виграш за обсягом. Допустимо, у нас є наступний клас:
public class DateAndTime{

  private short year;
  private byte month;
  private byte day;
  private byte hours;
  private byte minutes;
  private byte seconds;

}
Решта несуттєва. Поля можна було б зробити типу int, але це лише посаболо ефект прикладу. Хоча насправді поля можуть бути типуintз міркувань продуктивності. У будь-якому випадку суть зрозуміла. Клас являє собою дату та час. Нам він цікавий насамперед із погляду серіалізації. Можливо, найпростіше було б зберігати найпростіший часповітря. Він має тип long, тобто. при серіалізації він би зайняв 8 байт. З іншого боку, цей підхід вимагає методів перетворення компонент одне значення і назад, тобто. - Втрата у продуктивності. Плюс такого підходу – абсолютно божевільна дата, яка може поміститися у 64 біти. Це величезний запас міцності, найчастіше насправді не потрібний. Клас, наведений вище, займе 2 + 5 * 1 = 7 байт. Плюс службові витрати на клас та 6 полів. Чи можна якось утиснути ці дані? Напевно. Секунди та хвабони лежать в інтервалі 0-59, тобто. для їх подання достатньо 6 біт замість 8. Годинник – 0-23 (5 біт), дні – 0-30 (5 біт), місяці – 0-11 (4 біти). Усього, без урахування року – 26 біт. До розміру int залишається 6 біт. Теоретично, у деяких випадках цього може вистачити протягом року. Якщо ні – додавання ще одного байта збільшує обсяг поля даних до 14 біт, що дає проміжок 0-16383. Цього цілком достатньо в реальних додатках. Разом – ми зменшабо обсяг даних, необхідні зберігання необхідної інформації, до 5 байт. Якщо не до 4. Нестача та сама, що й у попередньому випадку – якщо зберігати дату упакованої, то потрібні методи перетворення. А хочеться так зберігати в окремих полях, а серіалізувати в упакованому вигляді. Ось тут доцільно використовувати Якщо ні – додавання ще одного байта збільшує обсяг поля даних до 14 біт, що дає проміжок 0-16383. Цього цілком достатньо в реальних додатках. Разом – ми зменшабо обсяг даних, необхідні зберігання необхідної інформації, до 5 байт. Якщо не до 4. Нестача та сама, що й у попередньому випадку – якщо зберігати дату упакованої, то потрібні методи перетворення. А хочеться так зберігати в окремих полях, а серіалізувати в упакованому вигляді. Ось тут доцільно використовувати Якщо ні – додавання ще одного байта збільшує обсяг поля даних до 14 біт, що дає проміжок 0-16383. Цього цілком достатньо в реальних додатках. Разом – ми зменшабо обсяг даних, необхідні зберігання необхідної інформації, до 5 байт. Якщо не до 4. Нестача та сама, що й у попередньому випадку – якщо зберігати дату упакованої, то потрібні методи перетворення. А хочеться так зберігати в окремих полях, а серіалізувати в упакованому вигляді. Ось тут доцільно використовувати а серіалізувати в упакованому вигляді. Ось тут доцільно використовувати а серіалізувати в упакованому вигляді. Ось тут доцільно використовуватиExternalizable:
// data is packed into 5 bytes:
//  3         2         1
// 10987654321098765432109876543210
// hhhhhmmmmmmssssssdddddMMMMyyyyyy yyyyyyyy
public void writeExternal(ObjectOutput out){
    int packed = 0;
    packed += ((int)hours) << 27;
    packed += ((int)minutes) << 21;
    packed += ((int)seconds) << 15;
    packed += ((int)day) << 10;
    packed += ((int)month) << 6;
    packed += (((int)year) >> 8) & 0x3F;
    out.writeInt(packed);
    out.writeByte((byte)year);
}

public void readExternal(ObjectInput in){
    int packed = in.readInt();
    year = in.readByte() & 0xFF;
    year += (packed & 0x3F) << 8;
    month = (packed >> 6) & 0x0F;
    day = (packed >> 10) & 0x1F;
    seconds = (packed >> 15) & 0x3F;
    minutes = (packed >> 21) & 0x3F;
    hours = (packed >> 27);
}
Власне, це все. Після серіалізації ми отримуємо службові витрати на клас, два поля (замість 6) та 5 байт даних. Що вже значно краще. Подальшу упаковку можна залишити спеціалізованим бібліотекам. Наведений приклад дуже простий. Його основне призначення – показати як можна застосовувати розширену серіалізацію. Хоча можливий виграш в обсязі серіалізованих даних – далеко не основна перевага, як на мене. Основна ж перевага, крім гнучкості... (плавно переходимо до наступного розділу...) Посилання на першоджерело: Серіалізація як вона є
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ