Java のシリアライズ (serializer, 直列化) について

オブジェクトをファイルに書き出したり、ネットワーク経由で送信したりする、シリアライズの話です。 簡単なところではお手軽なファイル保存に使えます。他にもWeb アプリやデータベース、障害対策などに必須の技術です。

シリアライズの概要

シリアライズとは

シリアライズとは、一言で言うと以下の通りです。

「メモリ上のオブジェクトをバイト列 (ストリーム) にする」

絵で描くと、こんな感じです。

あまりに単純だけど、実感がつかめませんよね。 ちょっと趣向を変えて、Q&A で説明してみます。 結局、メモリ上に散らばったオブジェクトを、ファイルみたいに順番に書き出してデータにするのがシリアライズだということです。 以下、詳細な説明に入ります。

オブジェクト指向とシリアライズ

オブジェクト指向プログラミングでは、データは全てオブジェクトの中身として保持されています。オブジェクトはメモリ上にある抽象的な存在で、中には数値とか文字列とか、メモリアドレスとかが入っています。オブジェクトはスレッド間で共通に使うことができるため、スレッドプログラミングとも相性が良いです。
しかし、ネットワークを通じて離れたマシンとデータをやりとりする時や、データをファイルに保存したいって思った時には、オブジェクトをそのまま使うわけにはいきません。このため必要になるのが、「オブジェクトのシリアライズ」です。Java では、初めからシリアライズに相当する機構が入っています。

ちょっとだけ、オブジェクトの復習

動いているプログラムのデータは、すべてメモリに保持されています。C にあるようなプリミティブ型 (int や構造体) では、メモリ番地にそのままデータが入っていますが、オブジェクトの場合は、必ず「型情報」で始まります。下図に例を示します。

まず、型情報から始まります。(これは、メモリのどこかにある、クラスの構造体へのポインタです) 次に、色々な変数が続きます。変数には、int や double などのプリミティブ型の変数と、String などのポインタ変数があります。プリミティブ型の変数は、その場所に保持されていますが、ポインタ変数は、そのポインタが指すアドレスの値 (普通は 4 バイト) が入っています。
すでにあるクラスも、同じような構成になっています。String の例を示します。(String の実装を調べたわけではないので、正確ではないかもしれません)

Java では配列もオブジェクトなので、メモリ上に型情報を持ちます。なお、C の配列はオブジェクトではなく、型情報がありません。

シリアライズの具体的な操作

シリアライズでは、以下のような操作を行います。 その上で、メモリ上ではとびとびになっていたデータを、ぎちぎちにつめて、バイト列に変換します。 以下に例を示します。

MyClass の変数で、 int[] entries はポインタです。なので、メモリ上ではメモリ番地(0x123456 みたいな) が入っているのですが、バイト列では、そのバイト列の中でそのオブジェクトを表す値に書き換えられます。一方、int[] 型の中身に入っている、一つめと二つ目の要素 (10 と 20) は、そのままバイト列に格納されています。MyClass と int[] には型情報がありますが、それもシリアライズデータに含まれています。

Java 標準シリアライザの使い方

Java では、実はあらゆるオブジェクトにシリアライズの仕組みが入っています。 例えば、以下のような Moo クラスがあったとします。
class Moo {
  int v;
  // デフォルトコンストラクタ (引数無しコンストラクタ)
  public Moo() {}
  public Moo(int v) { this.v = v; }
  public void print(){ System.out.println("v = " + Integer.toHexString(v)); }
}
インスタンスを作る、MooMain クラスを書きます。
public class MooMain {
  public static void main(String[] args){
    Moo moo = new Moo(0x12345678);
  }
}
この moo というインスタンスを、 してみます。

オブジェクトのデータをファイルに保存 (シリアライズ)

まず、Moo クラスを Serializable を継承するように書き換えます。
class Moo implements Serializable {
  int v;
  public Moo() {}
  public Moo(int v) { this.v = v;}
  public void print(){ System.out.println("v = " + Integer.toHexString(v)); }
}
次に、シリアライズして、ファイルに保存するコードを書きます。 今回は dumpToFile 関数に記述してみました。
public class MooMain {
  public static void dumpToFile(Moo moo, String name){
    try {
      ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(name));
      oos.writeObject(moo);
    } catch(IOException ie){
      ie.printStackTrace();
    }
  }

  public static void main(String[] args){
    Moo moo = new Moo(0x12345678);
    MooMain.dumpToFile(moo, "moo.txt");
  }
}
これで、MooMain を実行すると、ファイルに保存されるようなりました。

ファイルからオブジェクトのデータを復元 (デシリアライズ)

先ほどのデータを読み込んでみます。 今回は MooMain の restoreFromFile 関数に記述してみました。
public class MooMain {
  public static Moo dumpToFile(String name){...}

  public static Moo restoreFromFile(String name){
    try {
      ObjectInputStream ois = new ObjectInputStream(new FileInputStream(name));
      return (Moo)ois.readObject();
    } catch(IOException ie){
      ie.printStackTrace();
    } catch(ClassNotFoundException cfe){
      cfe.printStackTrace();
    }
    return null;
  }

  public static void main(String[] args){
    Moo moo = new Moo(0x12345678);
    MooMain.dumpToFile(moo, "moo.txt");
    Moo moo2 = MooMain.restoreFromFile("moo.txt");
    moo2.print();
  }
}
実行すると、moo.txt に moo インスタンスがシリアライズされ、さらにそれが moo2 に読み込まれて復元されます。
$ javac MooMain.java
$ java MooMain
v = 12345678

Java 標準のシリアライザの問題点

ほとんどの場合、標準のシリアライザで十分です。ただ、シリアライズするデータ量を最適化したい時や、グループの開発では、それでは不都合が生じる場合もあります。 少し例を見てみます。

もう少し複雑なシリアライズ例

先ほどの例では、Moo はプリミティブ型しかない単純なクラスでしたが、ポインタが絡む場合もちゃんと処理できます。 例えば、こんな例を見てみます。
class MyClass {
  int[] entries;

  public MyClass(int a, int b, int c){
    entries = new int[2];
    entries[0] = a;
    entries[1] = b;
    entries[2] = c;
  }
}

インスタンスを作ります。
MyClass m = new MyClass(10, 20, 30);
これをシリアライズしたら、こんな風になりました。 図に書くと、こんなかんじです。

配列をリンクリストに変えてみる

オブジェクト指向では、インターフェースと実装が分離しやすいという長所があります。プログラムを書いていて、実装を変えてみるのはよくあることです。 例えば、上の MyClass でも、内部実装を配列からリストに変えてみたくなるかもしれません。
class MyClass {
  class MyList {
    int v;
    MyList next = null;
    public MyList(int v){ this.v = v; }
    void add(int v){
      next = new MyList(v);
    }
  }

  MyList l;

  public MyClass(int a, int b, int c){
    l = new MyList(a);
    l.add(b);
    l.add(c);
  }
}
やりたいことは前と一緒で、int 3つを保持することです。 しかし、この新しい MyClass を Java 標準のシリアライザでシリアライズすると、とても大きなデータになってしまいます。

直接的な問題は、シリアライズをする時に、MyList 一つずつに「クラス名」「次へのポインタ (next)」が付いてくることです。 本来は int を 3 つ保持するだけでいいはずなのに、シリアライズデータが無駄に大きくなっていまいます。

意味を考えたシリアライズ

先ほどの リストのシリアライズの例ですが、もう一つの問題点は、シリアライズされたデータが実装に強く依存してしまうことです。 開発中に実装をリストにしたり配列にしたり…という試行錯誤はよくあることです。 でも、実際に意味的には、この MyClass のデータを保持するには、単に配列のデータを保存するだけで十分なはずです。 つまり、配列版もリスト版も、シリアライズしたいデータは同じで大丈夫なはずです。

これを実現するには、Java 標準のシリアライザに頼らず、自力でシリアライザを書く必要があります。 次は、このシリアライザを自力で実装する方法を説明します。

シリアライザを自力で実装

シリアライズのときに呼ばれるのは、readObject() と writeObject() という関数です。 元々呼ばれているのは Object#readObject() と Object#writeObject() ですが、 これを override することで、独自のシリアライズ動作を実現できます。
class MyClass implements Serializable {
  transient int[] entries;

  public MyClass(int a, int b, int c){
    entries = new int[3];
    entries[0] = a;
    entries[1] = b;
    entries[2] = c;
  }

  private void writeObject(ObjectOutputStream out) throws IOException {
    out.defaultWriteObject();
    out.writeInt(entries.length);
    for(int v: entries)
      out.writeInt(v);
  }
  
  
  private void readObject(ObjectInputStream in)
                          throws IOException, ClassNotFoundException {
    in.defaultReadObject();
    int size = in.readInt();
    entries = new int[size];
    for(int i = 0; i < size; i++)
      entries[i] = in.readInt();
  }
}
まず、シリアライズを標準のシリアライザに任せない、という宣言である、transient を int[] entries の前に書きます。これにより、そのままでは entries がシリアライズされなくなります。さらに、readObject() と writeObject() をオーバーライドして、データの書き込みと読み込みを実装しています。

シリアライズに関する雑多な話題

タイトル通りです。

transient を使おう

標準の Serializer を使う場合でも、明らかにシリアライズ対象に入れる必要が無い変数に対しては、transient と書くことで、シリアライズ対象から外れます。
class MyClass implement Serializable {
  transient int a;
  int b;
}
こうすると、a はシリアライズ対象に入りません。b は通常通りシリアライズされます。

SerialVersionUID を書こう

普通、オブジェクトは実行するプログラムの中で完結しています。だから、開発中に変数を増やしたり減らしたりしても大丈夫です。ところが、シリアライズは用いると、古いクラス定義でシリアライズしたデータを読み込んでしまったり、通信相手のシリアライズの定義が違っていたり…といった事故が起こります。たとえば int 型の変数を一つ増やしただけでも、シリアライズそれたデータは変わってしまい、大変なことになります。
これを防ぐために、クラスにバージョン ID を書くことができます。これにより、バージョンが異なる、同じ名前のクラスに復元できなくなります。バージョンIDは以下のように書きます。
class X implemnts Serializable { 
  private static final long serialVersionUID = 12345;
  ...
}
これによって、もしも serialVersionUID が異なるクラスのデータを保存しようとした場合、エラーが発生します。

シリアライズされたデータを解析してみる

先ほどのデータ (moo.txt) の形式は、Java の仕様で規定されています。 これを読んでみます。hexdump コマンドを使いました。
# hexdump -B moo.txt
バイト数  バイトコード (16進数で、左から右に)                ascii で読んだもの
--------  ------------------------------------------------   ----------------
00000000  ac ed 00 05 73 72 00 03  4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
00000010  33 33 33 02 00 01 49 00  01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|
どうも、v の値 (0x12345678) がそのまま出ているほど単純でもないですね。 あと、Moo って文字列も見えます。以下の文書を参考に、この Java 標準のシリアライズフォーマットを読み解いてみます。 ここの、下の方に書いてある文法定義を当てはめれば、自然と読めます。ここでは、解析した結果を見ていきます。

ヘッダ (magic version)

まずは、シリアライズ形式であることを示すマジックナンバー (ed ac) です。
ac ed 00 05 73 72 00 03  4d 6f 6f 00 00 00 00 33  |..sr..Moo....3|
33 33 33 02 00 01 49 00  01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|

Java のバージョン番号 (5, short 型なので 00 05) です。
ac ed 00 05 73 72 00 03  4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00  01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|

オブジェクト定義 始まり (newObject)

オブジェクト定義の始まりです。マジックナンバー TC_OBJECT (値は 0x73) があります。
ac ed 00 05 73 72 00 03  4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00  01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|
このオブジェクトは、このストリームで初めて出てきたクラスなので、クラス定義が必要です。 以降、クラス定義に移ります。(入れ子になっているので、あとでオブジェクト定義に戻ります)

クラス定義 始まり ()

クラス定義 (TC_CLASSDESC) の始まりです。マジックナンバー TC_CLASS (値は 0x72) があります。
ac ed 00 05 73 72 00 03  4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00  01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|

クラス名 (この場合は "Moo") です。
まず、文字列の長さ (3: 03 00) が先に来ます。
ac ed 00 05 73 72 00 03 4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00  01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|

その後に "Moo" のバイト列 (4d 6f 6f) が続きます。
ac ed 00 05 73 72 00 03 4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00  01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|

SerialVersionUID と呼ばれる、クラスに固有な ID です。(後述)
ac ed 00 05 73 72 00 03 4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00  01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|

Handle (このシリアライズデータでの、クラス定義の名前) です。02 が割り当てられています。
ac ed 00 05 73 72 00 03 4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00  01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|

変数一覧 (今回は int v のみ)

クラス内の変数一覧 (fields) です。
まずはここでリストアップされる変数の数です。ここでは 1 つ (00 01)です。
ac ed 00 05 73 72 00 03 4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00  01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|

次に、変数の定義 (int v) です。int はプリミティブ型なので、primitiveDesc に従います。
まずは、プリミティブ型の int を表すコード ('I': 49) です。
ac ed 00 05 73 72 00 03 4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00 01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|

変数名 ("v": 76) です。
まずは文字列の長さ (1 : 00 01) です。
ac ed 00 05 73 72 00 03 4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00 01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|
次に変数名 ('v' : 76) です。
ac ed 00 05 73 72 00 03 4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00 01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|

クラス定義の終わり

クラスアノテーションの情報 (classAnnotation) です。今回は何もないので、終端のマジックナンバー (TC_ENDBLOCKDATA: 76) を入れます。
ac ed 00 05 73 72 00 03 4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00 01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|
スーパークラスの情報 (superClassDesc) です。今回は特に明示的な継承はないので、TC_NULL (70) を入れます。
ac ed 00 05 73 72 00 03 4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00 01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|
これで、ようやくクラス定義が終わりです。オブジェクトの定義に戻ります。

変数の値

ようやく変数値です。すでにフィールドの定義と順番は決まっているので、データがつまっています。 今回は v の値である 12345678 がそのまま入っています。
ac ed 00 05 73 72 00 03 4d 6f 6f 00 00 00 00 33  |・..sr..Moo....3|
33 33 33 02 00 01 49 00 01 76 78 70 12 34 56 78  |333...I..vxp.4Vx|