デフォルトのシリアルバージョンUIDを作成するコードを読んでみる

ダイチャンさんが、ブログでJavaのシリアルバージョンUIDの話をされていて、最後の一文が自分も気になったので調べました。

ランタイムが未定義のserialVersionUIDにどんな値を割り当ててるのか、見る方法は分からなかった。誰か知ってますか?

SerializableとserialVersionUID - 都元ダイスケ IT-PRESS

元記事のコメントにnobeansさんが書いてくださったとおり、読むべきコードはこちら。

ドキュメントに計算の内容まで相当詳細に書かれていますが、勉強ということで自分用にまとめます。

シリアルバージョンコマンド(serialver)

serialVersionUIDの値は、開発システムによって提供されます。多くの開発システムでは、その値はserialverと呼ばれるコマンドの出力結果です。

『プログラミング言語Java 第4版』,p.490,ピアソン・エデュケーション,2007.4.

とのことで、JDKにもserialverというツールが付属しています。

このツールが、ObjectStreamClassのgetSerialVersionUID()を呼び、getSerialVersionUID()が、プライベートstaticなメソッドであるcomputeDefaultSUID(Class cl)を呼び出しています。

概要

ObjectStreamClass#computeDefaultSUIDがやっていることをざっくり説明すると、ひたすらクラスやメンバーの修飾子を確認して、条件に合ったら値を書きだし、最後にSHA-1ハッシュ値を得ています。

この時、クラスやメンバーの修飾子は、次の方法で確認します。

  1. 調べたいデータ(クラス、フィールド、メソッド、etc)の整数表現を得る
  2. Modifierのフィールドを利用して、調べたいパターンを作る
  3. 1と2を掛け合わせる

Modifierとは、java.lang.reflectパッケージにあるModifierクラスのことです。Modifierクラスは、クラス修飾子やアクセス修飾子の定数フィールドを持っています。フィールドの値は、ビット列です。

どんなビット列を持っているかは、ObjectStreamClassクラスのコードをマネして、確認できます。

public class Main {
    protected void compute(Class cl) {
        System.out.println(cl.getName() + ": ");
        
        // パラメータのビット表現をゲト
        int clazz = cl.getModifiers();
        System.out.printf("%16sB\t:getModifiers()\n",
                Integer.toString(clazz, 2));

        // Modifierが持つ定数フィールドの値を確認
        System.out.printf("%16sB\t:%s\n", Integer.toString(Modifier.PUBLIC, 2),
                Modifier.toString(Modifier.PUBLIC));
        System.out.printf("%16sB\t:%s\n", Integer.toString(Modifier.FINAL, 2),
                Modifier.toString(Modifier.FINAL));
        System.out.printf("%16sB\t:%s\n",
                Integer.toString(Modifier.INTERFACE, 2),
                Modifier.toString(Modifier.INTERFACE));
        System.out.printf("%16sB\t:%s\n",
                Integer.toString(Modifier.ABSTRACT, 2),
                Modifier.toString(Modifier.ABSTRACT));

        // 4つのクラス修飾子、アクセス修飾子を組み合わせた判定パターンを作成
        int accesscint = Modifier.PUBLIC | Modifier.FINAL | Modifier.INTERFACE
                | Modifier.ABSTRACT;
        System.out.printf("%16sB\t:public-final-interface-abstract\n",
                Integer.toString(accesscint, 2));

        // パラメータのクラス情報を判定した値をゲト
        int classMods = cl.getModifiers()
                & (Modifier.PUBLIC | Modifier.FINAL | Modifier.INTERFACE | Modifier.ABSTRACT);
        System.out
                .printf("%16sB\t:classMods\n", Integer.toString(classMods, 2));
    }

    public static void main(String[] args) {
        Main hoge = new Main();
        hoge.compute(Hoge.class);
    }
}

// 調査対象のクラス(ここではインタフェース)
interface Hoge {
    // メソッドなし
}

結果。

Hoge: 
     11000000000B	:getModifiers()
               1B	:public
           10000B	:final
      1000000000B	:interface
     10000000000B	:abstract
     11000010001B	:public-final-interface-abstract
     11000000000B	:classMods

上の例では、public、final、interface、abstractのビットパターンを作成して、Class#getModifiers()で取得した表現と比較しています。

serialver算出の流れ

さて、以下は、コードの内容を自然文で書くというナンセンスなメモですが・・・(マァイッカ・・・)Javadocに沿って確認します。

DataOutputStreamに値を書き出していき、最後にハッシュ化します。

	    ByteArrayOutputStream bout = new ByteArrayOutputStream();
	    DataOutputStream dout = new DataOutputStream(bout);

ストリームへの書き出しに使われるエンコードは、修正UTF-8といって、通常のUTF-8とは異なるそうです。

0. 事前判定

最初に、これからserialverを算出する対象のクラスが、

  • Serializableインタフェース自身でない
  • Proxyクラスである

かどうかを調べます。上2つのどちらかに該当する場合は、すぐに0Lを返して終了します。

なぜそうする必要があるのかは、よく分かりません。

プロキシクラスには、直列化可能なフィールドおよび 0L の serialVersionUID がありません。つまり、プロキシクラスの Class オブジェクトが java.io.ObjectStreamClass の static lookup メソッドに渡されたときに、返される ObjectStreamClass インスタンスには次の特性があります。

  • getSerialVersionUID メソッドを呼び出すと、0L が返される

(略)

Oracle Technology Network for Java Developers | Oracle Technology Network | Oracle

仕様なのは分かるけれども、ダイナミックプロキシって何に使われてるん?っていう・・・無知ですみません。すみません。(また宿題?)

とにかく、上記のクラス以外の場合だけ、次の処理に進みます。

1. クラス名

クラス名を取得して、ストリームに書き出します。

2. クラス修飾子

public、final、interface、abstractのビットパターンを生成して、対象クラスのパターンを確認します(上のコードは、ちょうどこの部分を参考にしました)。

ここで、ちょっとバグ対応らしき処理が入ります。

まず、定義されているメソッドの数をClass#getDeclaredMethods()で取得します。次に、対象クラスがinterfaceのとき、メソッド数が0ならabstractのビットを除去し、メソッド数が1以上ならabstractのビットを追加します。

たとえば、上のコードの実行結果では、左端にabstractのビットが立っています。しかし、対象クラス(インタフェース)には、メソッドが存在しません。そのため、この処理を通った後には、次のように左端のabstractビットが取り去られます。

     11000000000B	:classMods
宣言メソッド数:0
      1000000000B	:classMods インタフェースのメソッド数チェック済

ここまでの値を、一旦ストリームに書き出します。

3.インタフェース名

対象のクラスが配列クラスでない場合のみ、ここでの処理を行います。これは、昔(1.2)、インタフェースを取得するメソッドの挙動が違ったらしく、そのための処置みたい・・・でもタブン枝葉なので気にしなくてOK。(2011/06/04 追記: メモ: JDK1.1とJDK1.2のArrayクラスの違い - 虎塚

Class#getInterfaces()で、クラスが直接実装しているインタフェース群を取得します。取得したすべてのインタフェース名を名前の順でソートして、ストリームに書き出します。

4. フィールド名

Class#getDeclaredFields()で、クラスに定義されたフィールドを取得します。フィールドを名前の順にソートして、配列に格納します。

さらに、publiv、private、protected、static、final、volatile、transientのビットパターンを生成します。

対象のフィールドが、

  • privateフィールドである
  • static transientフィールドである

のどちらかあれば、ストリームに

を書き出します。

5. クラス初期化子

もしクラス初期化子が存在すれば、

  • フィールド名として
  • staticを表すビットパターン
  • JVMタイプシグネチャ「()V」

を書き出します。

ところで、クラス初期化子の有無を確認するために、ネイティブのコードが呼ばれています。リフレクションを使えばフィールドやメソッドの情報は取れますが、static初期化のコードは取れません。だからネイティブのコードで確認する必要がある・・・のでしょうか?

  • (参考){OpenJDK6}/jdk/src/share/native/java/io/ObjectStreamClass.c

上のコードのJava_java_io_ObjectStreamClass_hasStaticInitializerというメソッドに、実装があります。

6. コンストラク

Class#getDeclaredFields()で、クラスに定義されたフィールドを取得します。フィールドをシグネチャの順にソートして、配列に格納します。

サテハテ、閑話休題

インタフェースやフィールドへの名前の順ソートと同じく、ここでもComparatorが骨格実装されているのですが、シグネチャ順のソートってどんな結果になるのでしょう?

分からなかったので確認してみます。

public class Main {

    protected void compute(Class cl) {
        Constructor[] cons = cl.getDeclaredConstructors();
        MemberSignature[] consSigs = new MemberSignature[cons.length];
        for (int i = 0; i < cons.length; i++) {
            consSigs[i] = new MemberSignature(cons[i]);
        }
        Arrays.sort(consSigs, new Comparator() {
            public int compare(Object o1, Object o2) {
                String sig1 = ((MemberSignature) o1).signature;
                String sig2 = ((MemberSignature) o2).signature;
                return sig1.compareTo(sig2);
            }
        });
        
        // ソート結果を表示して確認
        for (int i = 0; i < consSigs.length; i++) {
            MemberSignature sig = consSigs[i];
            System.out.println("[" + (i + 1) + "] " + sig.signature.toString());
        }
    }
    
    public static void main(String[] args) {
        Main hoge = new Main();
        hoge.compute(Hoge.class);
    }
    
    // 内部クラスMemberSignatureの実装とか色々省略。。。
}

// 調査対象のクラス(※上のコードでインタフェースだったのをクラスに変更)
class Hoge {

    // テキトーにコンストラクタを追加
    Hoge() {
    }

    Hoge(Object obj) {
    }

    Hoge(int i, int j) {
    }

    Hoge(String str) {
    }

    Hoge(long l, int i) {
    }
}

結果。

[1] ()V
[2] (II)V
[3] (JI)V
[4] (Ljava/lang/Object;)V
[5] (Ljava/lang/String;)V

JVMタイプシグネチャのアルファベット順にいくのか。そーか。という感じです。

・・・閑話終了。

ここでは、public、private、protected、static、final、synchronized、native、abstract、strictのビットパターンを生成します。

そして、private以外のコンストラクタについて、ストリームに、

  • フィールド名として
  • ビットパターン
  • JVMタイプシグネチャの「/」を「.」に代えた文字列

を書き出します。

7. メソッド

2.で取得しておいたメソッド群を使います。メソッドを名前の順でソートした後、さらにシグネチャの順でソートしています。

上のコンストラクタと同じく、public、private、protected、static、final、synchronized、native、abstract、strictのビットパターンを生成します。

ここでも、private以外のメソッドについて、ストリームに、

を書き出します。

8-9. ハッシュ値

最後に、やっとハッシュ化です。あとちょっと!

MessageDigesを使っています。"SHA"が渡されているので、SHA-1アルゴリズムで固定長ハッシュ値を得ています。

最終的な値を作成する方法は、知らなかったので勉強になりました。…説明しづらいので引用すると、

	    MessageDigest md = MessageDigest.getInstance("SHA");
	    byte[] hashBytes = md.digest(bout.toByteArray());
	    long hash = 0;
	    for (int i = Math.min(hashBytes.length, 8) - 1; i >= 0; i--) {
		hash = (hash << 8) | (hashBytes[i] & 0xFF);
	    }

ハッシュ値は、byteデータの配列として取得します。「配列からbyteデータを1個ずつ取り出して0xFFでマスクした値」と、「最終的な値(初期値0)を8ビットずつ左シフトした値」のORを取ることで、ハッシュ値を作成しています。

というわけで、デフォルトのシリアルバージョンUIDをめでたくゲットできました。お疲れさまでした。

プログラミング言語Java』の記述

元記事のコメントでmegascusさんが言及してくださっているので、『プログラミング言語Java 第4版』の記述も確認しておきます。

オブジェクトが書き込まれる時には、シリアルバージョンUID(serial version UID)(一意な識別子)である64ビットlong値が一緒に書き込まれます。デフォルトでは、この識別子は、完全なクラス名、スーパーインタフェースおよびメンバーの安全なハッシュ値です。

『プログラミング言語Java 第4版』,p.489,ピアソン・エデュケーション,2007.4.

イィィィグザクトリィ(そのとおりでございます)

余談

このコードで使われているDataOutputStream。flush後に、closeされていません。。。どういうことなの・・・。

特に悲しい話題ではありませんが、ネコを挟みつつお送りしました。