シングルトンのクラスをシリアライズする場合、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 クラスです。
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {
}
private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"};
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
private Object readResolve() {
return INSTANCE;
}
}
次にこの不完全なシングルトンコードを攻撃するコードです。
public class ElvisStealer implements Serializable {
static Elvis impersonator;
private Elvis payload;
private Object readResolve() {
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 {
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]
すべてを 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項目。
もっと感慨深いかと思ったけど、特にそんなこともなかった。。。