The King's Museum

ソフトウェアエンジニアのブログ。

【Effective Java】項目77:インスタンス制御に対しては、readResolve より enum 型を選ぶ

シングルトンのクラスをシリアライズする場合、readResolve メソッドを使ってインスタンス制御するよりも enum 型による実装を選ぶべきです。

readResolve メソッド

まず readResolve メソッドについて説明します。

例えば、次のようなシングルトンクラスを考えます。

public class Elvis {
    public static final Elvis INSTANCE = new Elvis();

    private Elvis() {
    }
}

この Elvis クラスをシリアライズできるようにしたいとします。

この時、単に implements Serializable としただけではシングルトンとして不完全です。 デフォルトシリアライズ形式かカスタムシリアライズ形式かに関係なく、シングルトンとして不適切な実装です。

なぜなら、Serializable を implements したクラスの readObject メソッドは常に新しいインスタンスを生成するからです。 このインスタンスpublic static final Elvis INSTANCE = new Elvis(); で生成されているインスタンスとは異なるため、シングルトンにおけるインスタンスの唯一性を満たすことができません。

これに対し readResolve メソッドを使うと、readObject によって生成されたインスタンスを交換することができます。

シリアライズされたクラスが readResolve を持っている場合、次のように機能します。

  • readObject() によって新たなインスタンスが生成される
  • そのインスタンスに対して readResolve() が呼び出される
  • このメソッドが返すオブジェクトが、新たに生成されたオブジェクトの代わりとなる
    • この際、元々のオブジェクトへの参照は保持されません。

Elvis クラスの場合、次のようにしてシングルトン特性を持つ readResolve を実装できます。

private Object readResolve() {
    // 唯一の正しいインスタンスを返す
    return INSTANCE;
}

ここで注意が必要です。

インスタンス制御を行うクラスではすべてのプロパティは transient と宣言する必要があります。 そうしない場合、次の項目で説明する攻撃を用いると、シングルトンの特性である「インスタンスが一つしかない」という不変性を破ることができてしまうからです。

Stealer 攻撃

Serializable を実装しているシングルトンが非 transient のプロパティを含んでいる場合、シングルトン特性を破壊する攻撃が可能です。 この攻撃は少し複雑です。

シングルトンが非 transient のプロパティを含んでいる場合、シングルトンの readResolve() が実行される前に、その非 transient のプロパティがデシリアライズされます。 プロパティがデシリアライズされる時、そのプロパティの readObject() が呼び出されます。

この時、シングルトンのダミーのインスタンスを保持しておけば、INSTANCE とは別のインスタンスを保持したままにできます。

サンプルコード

具体的なコードを見ていきます。 まず、非 transient なプロパティを含む不完全なシングルトン Elvis クラスです。

// 非 transient プロパティを含む不完全なシングルトン実装
public class Elvis implements Serializable {
    public static final Elvis INSTANCE = new Elvis();

    private Elvis() {
    }
 
    // 非 transient なプロパティ
    private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"};

    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }

    private Object readResolve() {
        return INSTANCE;
    }
}

次にこの不完全なシングルトンコードを攻撃するコードです。

// Elvis クラスを攻撃するクラス
public class ElvisStealer implements Serializable {
    // シングルトン以外のインスタンスを保持しておくための static フィールド
    static Elvis impersonator;

    // もう一つの Elvis インスタンス
    private Elvis payload;

    private Object readResolve() {
        // payload に保持されている Elvis インスタンスを impersonator に保持しておく
        impersonator = payload;

        // このプロパティを文字列配列として偽装するので文字列配列を返す
        return new String[]{"A Fool Such as I"};
    }

    private static final long serialVersionUID = 0;
}

そして、Elvis をシリアライズしたバイトストリームを改変します。 favoriteStrings のプロパティ領域には本来、String[] 型のインスタンスが含まれていますが、これを ElvisStealer に変更します。

こうすると、Elvis クラスは favoriteStrings をデシリアライズしているつもりで、ElvisStaler をディシリアライズしてしまうのです。

改変したバイトストリームと、それを利用してインスタンスを二つ得るコードは次の通りです。

public class ElvisImpersonator {
    // 改変した Elvis ストリーム
    private static final byte[] serializedForm = new byte[]{
            (byte) 0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05,
            0x45, 0x6c, 0x76, 0x69, 0x73, (byte) 0x84, (byte) 0xe6,
            (byte) 0x93, 0x33, (byte) 0xc3, (byte) 0xf4, (byte) 0x8b,
            0x32, 0x02, 0x00, 0x01, 0x4c, 0x00, 0x0d, 0x66, 0x61, 0x76,
            0x6f, 0x72, 0x69, 0x74, 0x65, 0x53, 0x6f, 0x6e, 0x67, 0x73,
            0x74, 0x00, 0x12, 0x4c, 0x6a, 0x61, 0x76, 0x61, 0x2f, 0x6c,
            0x61, 0x6e, 0x67, 0x2f, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74,
            0x3b, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0c, 0x45, 0x6c, 0x76,
            0x69, 0x73, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x65, 0x72, 0x00,
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01,
            0x4c, 0x00, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64,
            0x74, 0x00, 0x07, 0x4c, 0x45, 0x6c, 0x76, 0x69, 0x73, 0x3b,
            0x78, 0x70, 0x71, 0x00, 0x7e, 0x00, 0x02
    };

    public static void main(String[] args) {
        Elvis elvis = (Elvis) deserialize(serializedForm);
        Elvis impersonator = ElvisStealer.impersonator;
        elvis.printFavorites();
        impersonator.printFavorites();
    }

    private static Object deserialize(byte[] stream) {
        try {
            InputStream inputStream = new ByteArrayInputStream(stream);
            ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
            return objectInputStream.readObject();
        } catch (Exception e) {
            throw new IllegalArgumentException(e);
        }

    }
}

このコードを実行すると、2つの Elvis インスタンスが存在していることが分かります。

[Hound Dog, Heartbreak Hotel]
[A Fool Such as I]

enum シングルトン

すべてを transient なプロパティに変更することで上記の攻撃は回避することができます。 しかし、よりよい方法は enum のシングルトンを使うことです。

enum のシングルトンについてはすでに項目3で述べました。 enum シングルトンは、通常のシングルトンパターンと違い、JVM によってインスタンスの唯一性が保証されるため非常に有利です。 また、enum はデフォルトでシリアライズ可能になっていることも重要です。

Elvis クラスを enum にすると次のようになります。

public enum Elvis {
    INSTANCE;
    private String[] favoriteSongs =
            {"Hound Dog", "Heartbreak Hotel"};

    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }
}

しかし、コンパイル時にインスタンスが分からないようなシリアライズ可能なシングルトンを書くためにはenum 型は使えません。

継承

final のクラスに readResolve() を書く際は private であるべきです。 一方、final でないクラスに readResolve() を書くときはそのアクセス可能性を検討する必要があります。

readResolve() が protected か public でサブクラスが存在してオーバーライドしていない場合、readResolve() がスーパークラスを返すことになるため ClassCastException を起こしやすいので注意が必要です。

感想

ついにあと1項目。 もっと感慨深いかと思ったけど、特にそんなこともなかった。。。

(c) The King's Museum