The King's Museum

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

【Effective Java】項目75:カスタムシリアライズ形式の使用を検討する(前半)

シリアライズする方法には次の二つの種類があります。

何の考慮もせずにデフォルトシリアライズ形式を用いることは、互換性やパフォーマンスの点で非常に危険です。 なるべくカスタムシリアライズ形式を用いるべきです。

デフォルトシリアライズ形式

デフォルトシリアライズを利用するには単に Serializable を実装します。 あとは何もしなくても、フレームワークが自動的にそのクラスの内部表現を効率的に符号化します。

すなわち、デフォルトシリアライズではクラスに含まれるすべてのプロパティが自動的にシリアライズされます。 そのため、本来はシリアライズするべきではない、実装上の都合やキャッシュなどのプロパティをすべてシリアライズしてしまいます。

たとえば、名前と年齢を持つ人物を表すクラスを考えてみます。 このクラスでデフォルトシリアライズを利用するには、単に次のように Serializable を実装するだけです。

public class Person implements Serializable {
    /**
     * 名前。非 null。
     * @serial
     */
    private String name;
    /**
     * 年齢。0以上。
     * @serial
     */
    private int age;

    public Person(String name, int age) {
        if (name == null) {
            throw new IllegalArgumentException("name = null");
        }
        if (age < 0) {
            throw new IllegalArgumentException("age: " + age + " < 0");
        }

        this.name = name;
        this.age = age;
    }
}

ここで、シリアライズに関するいくつか注意点があります。

@serial タグ

name と age のフィールドは private ですが Javadocドキュメンテーションコメントが含まれています。 private プロパヒットであっても、シリアライズされてしまえば公開された API として扱うため、ドキュメンテーションコメントをつけるべきです。

@serial タグを使えば Javadoc が「シリアライズ形式の文書化」に関する特別ページに、そのプロパティを掲載してくれます。

readObject メソッド

デフォルトシリアライズでは readObject メソッドの実装は必須ではありません。

しかし、不変式とセキュリティを保証するために readObject メソッドは必ず実装するべきです。

たとえば、Person では name と age には次の不変式があります。

  • name は null
  • age は 0 以上

もし、シリアライズされたファイルを操作して、これらの不変式を破壊するようなデータが仕込まれた場合、readObject がなければそのままデシリアライズされてしまうからです。 これに関する詳細については項目76で扱います。

カスタムシリアライズ形式

カスタムシリアライズを用いるためには Serializable を実装することに加えて readObject メソッドと writeObject メソッドを実装します。

readObject メソッドと writeObject メソッドで、開発者がシリアライズするべきプロパティを選びます。

これによって、単にクラスの物理表現(実装)がシリアライズするのではなく、オブジェクトの論理表現(仕様)をシリアライズすることができるようになります。

たとえば文字列のリスト示すクラスの StringList があるとします(これはあくまで例なので通常は List を使うべきです)。

public class StringList implements Serializable {
    private int size = 0;
    private Entry head = null;

    private static class Entry implements Serializable {
        String data;
        Entry next;
        Entry previous;
    }
    ...(省略)....
}

物理表現と論理表現

このクラスは論理的に表現すれば「文字列のリスト」ですが、物理的(実装的)に表現すると「文字列の双方向リンクリスト」と考えられます。 そして、デフォルトシリアライズを用いると物理表現である「文字列の双方リンクリスト」としてシリアライズされてしまいます。

これには次の4つの欠点があります。

  • 物理表現(実装)が公開 API となり、永久にサポートする必要がある
  • 多くの空間を消費する可能性がある
  • 多くの時間を消費する可能性がある
  • スタックオーバーフローを起こす可能性がある

一方、カスタムシリアライズを利用してクラスの論理表現をシリアライズすれば、これらの問題を回避し、よりシンプルにシリアライズすることができます。 論理的には StringList は論理的「リスト中の文字列数」と「文字列自身」を記憶しておけば十分だからです。

カスタムシリアライズを実装した StringList は次のようになります。

public class StringList implements Serializable {
    private transient int size = 0;
    private transient Entry head = null;

    private static class Entry implements Serializable {
        String data;
        Entry next;
        Entry previous;
    }

    /**
     * この{@code StringList}インスタンスをシリアライズする。
     *
     * @serialData リストのサイズ({@code int})を書き出して、
     * 適切な順番にすべての要素({@code String})が続くようにする
     */
    private void writeObject(ObjectOutputStream s) throws IOException {
        s.defaultWriteObject();
        s.writeInt(size);
        for (Entry e = head; e != null; e = e.next) {
            s.writeObject(e.data);
        }
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        int num = s.readInt();
        for (int i = 0; i < num; i++) {
            add((String) s.readObject());
        }

    }
}

このカスタムシリアライズの実装にはいくつか注意点があります。 次回の記事でその注意点について述べます。

感想

長いので分割。。。 これを含めてあと4つか~。

【Effective Java】項目74:Serializable を注意して実装する

項目74からはオブジェクトシリアライズ API について説明します

オブジェクトをバイト列として符号化することをシリアライズバイト列からオブジェクトに復号化することをデシリアライズと呼びます。

シリアライズされたオブジェクトは仮想マシン間やネットワークをまたいで転送できるようになります。 また、ファイルに保存できるようになります。

Serializable

Java でクラスをシリアライズ可能にするには Serializable インタフェースを implements します。

一般的に、コレクションクラスや値クラスは Serializable を実装しても問題ありません。 しかし、スレッドプールなどの処理をまとめたクラスは Serializable を実装するべきではありません。

やるべき作業は単に Serializable インタフェースを implements するだけですが、その簡単さとは裏腹にいくつか考慮しなければならない点があります。

柔軟性の低下

Serializable を実装したクラスを一旦リリースしてしまうと、その実装を変更することは容易ではありません。

クラスをシリアライズ可能にすると、そのシリアライズ形式がクラスの公開 API の一部となります。 既存のシリアライズ形式を永久にサポートし続ける必要がでてきます。

カスタムシリアライズ形式を設計せずにデフォルトのシリアライズ形式を利用すると、シリアライズ形式はクラスの内部表現と結びついてしまいます。 すなわち、デフォルトのシリアライズ形式では private インスタンスフィールドでさえも公開されてしまうことになります。

最も単純な問題例はシリアルバージョン UID と呼ばれるストリーム一意識別子に関する問題です。 Serializable を implements したクラスは必ずこの識別子を保持するようになります。 これを明示的に指定しない場合には次の情報からコンパイラが自動生成します。

  • クラス名
  • クラスが実装しているインタフェース名
  • public と protected のメンバ

もし、識別子を明示的に指定しない場合、単に便利なメソッドを一つ追加しただけでこの識別子は変更されてしまいます。 結果として、互換性が破壊されてしまいます。

セキュリティ

二つ目に、シリアライズ可能にすることでバグやセキュリティホールが入り込む可能性を増大させるという問題があります。

シリアライズ時のオブジェクトは言語外の仕組みを使い、通常のコンストラクタを迂回して生成されます。 すなわち、本来のコンストラクタによって保証される不変式がオブジェクト生成時には保証されません。

そのため、まずはデシリアライズ処理時に不変式を満たすように正しく実装する必要があります。 また、不変式を満たさない生成中のオブジェクト内部に外部からアクセスができないようにしなければなりません。

テスト負荷の増大

三つ目に、シリアライズ可能なクラスでは変更時のテストの工数が通常よりも増えます。

新たなリリースのインスタンスシリアライズし、古いリリースのデシリアライズ処理でインスタンスが復元できるを確認する必要があります。 また、逆に、古いリリースのインスタンスシリアライズし、新しいリリースのデシリアライズ処理で復元できるかも確認する必要があります。

これらのテストは単に新旧のインスタンス間でシリアライズ/デシリアライズできるかというバイナリ互換性に加えて、 動作が意図しているものかどうかというセマンティクス互換性も検査する必要があります。

これの負荷はカスタムシリアライズ形式を正しく設計することで軽減できますが、それを完全になくすことはできません。

継承と Serializable

継承するために設計されたクラス(項目17)とインタフェースでは Serializable を拡張するべきではありません。

継承して利用するために設計されたクラスが Serializable を implements している場合、サブクラスの実装にかなりの手間がかかります。

しかし、継承のために設計されたクラスがシリアライズ可能でない場合、シリアライズ可能なサブクラスを書くことは不可能かもしれません。 シリアライズ可能なサブクラスを書くためには、親クラスにアクセス可能なパラメータ無しコンストラクタが必要です。

内部クラスと static メンバークラス

内部クラスは Serializable を実装するべきではありません。 内部クラスはそれを包含するエンクロージングインスタンスに対する参照や、外部スコープからのローカル変数の値を保持するためのコンパイラが生成するフィールドを使用しています。 これらに対するデフォルトのシリアライズ形式は未定義で不明瞭で、動作は保証されません。

一方、static メンバークラスはエンクロージングインスタンスに対する参照などを持っていません。 そのため、自由に Serializable を実装できます。

【Effective Java】項目73:スレッドグループを避ける

スレッドグループはもはや利用するべきではありません。

スレッドグループのスレッド一覧を取得するための enumerate メソッドは、スレッドを格納する十分な大きさの配列が渡されない場合、勝手に一部のスレッドを無視します。 また、スレッドの数を取得する activeCount メソッドは、それを呼び出した後も正しいスレッド数を表しているとは限りません。

このように、スレッドグループの API が提供する多くの機能には欠陥があります。 もし、スレッドをまとめて管理したい場合には java.util.concurrent のエグゼキューターフレームワークを使うべきです。

setUncaughtExceptionHandler

スレッドグループのみが提供していた機能に「キャッチされない例外に対する制御を指定する」という機能があります。ThreadGroup.uncaughtException です。

Java 5 以降は Thread に setUncaughtExceptionHandler が追加されているため、こちらを使うべきです。

これで完全に ThreadGroup を使う必要はなくなりました。

【Effective Java】項目72:スレッドスケジューラに依存しない

プログラムの正しさやパフォーマンスをスレッドスケジューラーの動作に依存させてはいけません。

実行可能なスレッド数はプロセッサの数と比べて非常に大きくならないようにするべきです。 そうしておけば、スレッドスケジューラーがどのようなアルゴリズムを採用していてもプログラムは正しく動作します。

実行可能なスレッド数を少なくするためにはスレッド数を適切に制限するべきです。 エグゼキューターフレームワーク項目68)では、スレッド数を制限したスレッドプールを作成できます。

ビジーウェイト

特定の条件を満たすまでスレッドを待機させるためにビジーウェイトを使うべきではありません。 ビジーウェイトはプログラムを脆弱にし、プロセッサへの不可が増大します。

例えば次のようなビジーウェイトを用いた CountDownLatch はとても遅いです。

public class SlowCountdownLatch {
    private int count;

    public SlowCountdownLatch(int count) {
        if (count < 0) {
            throw new IllegalArgumentException(count + " < 0");
        }
        this.count = count;
    }

    public void await() {
        while (true) {
            synchronized (this) {
                if (count == 0) {
                    return;
                }
            }
        }
    }

    public synchronized void countDown() {
        if (count != 0) {
            count--;
        }
    }

}

Thread.yield()

Thread.yield() は他のスレッドに処理を実行させる機会を与えます。 しかし、機会を与えるだけで実際に他のスレッドの処理が実行されるとは限りません。

ある JVM 実装でパフォーマンスが改善する yield() 呼び出しが、他の実装では動作しないかもしれません。 実際、ある JVM 実装において yield() は何もしません。

Thread.yield() を利用する唯一の箇所はテストでした。 テストで yield を使うと実行パターンの状態空間の数が増え、バグをあぶり出すことができました。

しかし、前述したように JVM 実装によっては挙動が異なるため、普通は Thread.sleep(1) を使うべきです。 Thread.sleep(0) は実装によっては何もしないので、Thread.sleep(1) を使うべきです。

【Effective Java】項目71:遅延初期化を注意して使用する

遅延初期化(lazy initialization)は、フィールドの値が必要になるまで初期化を遅らせる手法です。 static フィールドとインスタンスフィールドの両方に利用できます。

まず、第一に、通常は遅延初期化を使うべきではありません。 初期化を遅らせる仕組みによって、フィールドへのアクセスコストが増加するからです。

遅延初期化を採用する理由は間違いなく「最適化」が目的ですが、項目55で述べたように多くの「最適化」は失敗します。 パフォーマンスを測定し、ほんとうに遅延初期化が必要かどうかを必ず判断してください。

マルチスレッド環境の遅延初期化

通常の初期化

遅延初期化しない通常の初期化は次のように行われます。

private final FieldType field = initializeField();

これは static フィールドの場合も同様です。 final キーワードがついていることが重要です。

final キーワードはマルチスレッド環境においても安全にフィールドが初期化されることを保証するからです。

通常の遅延初期化

次に最も一般的な遅延初期化の方法を紹介します。

private FieldType fieldType;
public synchronized FieldType getField() {
    if (field == null) {
        field = initializeField();
    }
    return field;
}

これは static フィールドでも、インスタンスフィールドでも有効です。 synchronized キーワードによって適切に同期化されていることが重要です。

最適化された遅延初期化

もし、単に同期化しただけの遅延初期化でパフォーマンス上の問題が発生するならば、いくつかの選択肢があります。

遅延初期化ホルダークラス

static フィールドの初期化には遅延初期化ホルダークラスイディオムを使うと効果的です。

private static class FieldHolder {
    static final FieldType field = initializeField();
}

static public FieldType getField() {
    return FieldHolder.field;
}

これは『クラスが利用されるまでクラスが初期化されない』という言語仕様を利用したイディオムです。

ダブルチェックイディオム

遅延初期化ホルダークラスはシンプルで効果的な実装ですが、インスタンスフィールドには適用できません。

インスタンスフィールドにはダブルチェックイディオムと呼ばれる遅延初期化が有効です。

private volatile FieldType field;

public FieldType getField() {
    FieldType result = field;
    if (result == null) {
        synchronized(this) {
            result = field;
            if (result == null) {
                field = result = initializeField();
            }
        }
    }
    return result;
}

synchronized で同期化する前に初期化されているかどうかをチェックし、この時点で初期化されていたらそのまま result を返します。 こうすることでほとんどの場合、同期化を避けて値を返すことが可能です。

もし、初期化されていない場合には念のために synchronized を使って同期化し、もう一度チェックします。 このように二重にチェックするためにダブルチェックイディオムと呼ばれています。

フィールドが volatile であることは非常に重要です。 また、一度、ローカル変数に代入することでパフォーマンスが改善します。

このイディオムは Java 5 未満のメモリセマンティクスでは動作が保証されないことに注意が必要です。

hjm333.hatenablog.com

単一チェックイディオム

もし、特に高いパフォーマンスが求められる場合で、二回以上初期化しても問題ないのであれば synchronized を外してもかまいません。

private volatile FieldType field;

public FieldType getField() {
    FieldType result = field;
    if (result == null) {
        field = result = initializeField();
    }
    return result;
}

きわどい単一チェックイディオム

もし、long と double 以外の基本データ型であれば、単一チェックイディオムから volatile を取り除いてもかまいません。

private int field;

public int getField() {
    int result = field;
    if (result == null) {
        field = result = initializeField();
    }
    return result;
}

この手法では、新しいスレッドがアクセスするたびに initializeField() が呼ばれる可能性があります。 スレッドは、volatile 修飾子がなくても自分のスレッドが行った変数の変更は見ることができる一方、他のスレッドの変更は見ることができない可能性があるからです。

これはかなりきわどい手法ですが意図通りに動作します。 実際、String インスタンスで hashCode 値をキャッシュするために利用されています。

【Effective Java】項目70:スレッド安全性を文書化する

開発者は、クラスがマルチスレッド環境で利用された場合にどのように振る舞うかを文書化しなければなりません。

マルチスレッド環境における振る舞いを文書化しない場合、クラスの利用者は十分な同期を行わない(項目66)かもしれませんし、逆に過度な同期を行う(項目67)かもしれません。

synchronized 修飾子を手がかりにしてクラスのスレッドセーフ性を確認することは大きな誤りです。 synchronized 修飾子は API の仕様ではなく実装の詳細だからです。

synchronized 修飾子があるからといってスレッドセーフは保証されませんし、synchronized 修飾子がなくてもスレッドセーフ性が保証される場合もあります。

スレッドセーフレベル

スレッドセーフには次のようなレベルがあります。

  • 不変(immutable)
  • 無条件スレッドセーフ(unconditionally thread-safe)
  • 条件付きスレッドセーフ(conditionally thread-safe)
  • スレッドセーフでない(not thread-safe)
  • スレッド敵対(thread-hostile)

不変

スレッドセーフレベルが不変の場合、クラスのインスタンスは不変であるため、外部同期は一切必要ありません。 String, Integer, BigInteger などがその例です。

無条件スレッドセーフ

スレッドセーフレベルが無条件スレッドセーフの場合、クラスは可変です。 しかし、すべてのパブリックメソッドはスレッドセーフであることが保証されます。

クラスは適切に内部同期されているため、外部同期は一切必要ありません。 例としては、ConcurrentHashMap などです。

無条件スレッドセーフを実現したい場合、プライベートロックイディオムを使うことが効果的です。

private final Object lock = new Object();
public void foo() {
    synchronized(lock) {
        ....
    }
}

クライアントがロックオブジェクトに触ることができると、悪意あるクライアントによってサービス拒否攻撃が行われる可能性があります。 このイディオムを用いれば、外部からロックオブジェクトに触ることが出来なくなり、この攻撃を防ぐことができます。

また、プライベートロックイディオムは継承のために設計されたクラスに対して有効です。

条件付きスレッドセーフ

このレベルの場合、クラスは可変であり、いくつかのメソッドは適切な外部同期を必要とします。 例えば Collections.synchronized ラッパーが返すコレクションです。

Collections.synchronized ラッパーが返すコレクションのイテレータを利用したい場合、次のような外部同期が必要です。

Map<K, V> map = Collections.synchronizedMap(new HashMap<K, V>()));
Set<K> set = m.keySet(); // ここでは同期は必要ない
// イテレータを利用する場合、map に対するロックが必要
synchronized (map) {
    for (K key: set) { 
        ....
    }
}

このように、条件付きスレッドセーフレベルではクライアントにロックオブジェクトを渡す必要があります。 そのため、条件付きスレッドセーフではプライベートロックイディオムを使うことはできません。

スレッドセーフでない

このレベルでは、クラスは可変ですべてのメソッドが外部同期が必要です。 例えば、ArrayList や HashMap などです。

スレッド敵対

このレベルのクラス/メソッドは、たとえ外部同期されていてもマルチスレッドで利用することは安全ではありません。

このクラスは Java ライブラリにもほとんどありません。 希有な例としては System.runFinalizersOnExit() です。

感想

System.runFinalizersOnExit() がなぜだめなのか調べた。

まずはリファレンスをチェック。

Runtime (Java Platform SE 7)

非推奨。 このメソッドは本質的に安全ではありません。ファイナライザがライブオブジェクトに対して呼び出される結果になる可能性があり、そのときにほかのスレッドがそれらのオブジェクトを並行して操作していると、動作が異常になるか、デッドロックが発生します。   終了時のファイナライズを有効または無効にします。これを実行することによって、自動的に呼び出されていないファイナライザを持つすべてのオブジェクトのファイナライザが呼び出され、Java Runtime の終了前に実行されるようになります。

なるほど、これをオンにすると終了時に各オブジェクトの finalize() メソッドが必ず呼び出されるようになる。 この時、そのオブジェクトが並行に実行されていたりする可能性があるので危険、ということだな。

【Effective Java】項目69:wait と notify よりコンカレンシーユティリティを選ぶ(後半)

固有のロック

Java は相互排他ロックを言語固有の機能として持っています。 このロックを利用するためには synchronzied ブロックを使います。

固有のロックは次のような仕組みで動作します。

  1. synchronized ブロックに入る時、スレッドは指定されたオブジェクトの固有のロックを自動的に取得します
    1. スレッドがロックを取得できたら、ブロック内の処理を実行します
    2. 他のスレッドがロックを取得している場合、ロックが取得できるまでスレッドの実行は待機します
  2. synchronized ブロックから出る時、スレッドは指定されたオブジェクトのロックを自動的に解放します
  3. 当該オブジェクトのロック取得待ちをしていたスレッドの1つがロックを取得し、後続の処理を実行します

例えば次のようなコードを2つのスレッドAとBで実行するとします。

synchronized (obj) { // obj は Object 型のインスタンス
    ...処理A...
    ...処理B...
}
  1. まず、スレッド A がこのコードブロックを実行します。その際、スレッド A が obj の固有のロックを自動的に取得します。
  2. この時、スレッド B がこのコードを実行しようとします。その際、スレッド B は obj の固有のロックを自動的に取得しようとします。ただし、スレッドAによってロックは占有されていますので、解放されるまで待機します。
  3. この間に スレッド A は処理Aと処理Bを実行します。synchronized ブロックを抜ける時、スレッド A は obj の固有のロックは解放します。
  4. すると、待機していたスレッドBが解放されたロックを取得します。最後に、スレッドBはコードを実行し、ブロックをぬけた時に obj の固有のロックは解放されます。

なお、同じスレッドは同じオブジェクトのロックを取得することができます。 これを再突入可能といいます。

wait() と notify()

wait() と notify() は Java のスレッド間の調整を行うためのメソッドです。 より正確には条件キュー(Condition Queue)と呼ばれる機構を操作するための API です。

Java のすべてのオブジェクトは条件キューを持っていて、Object クラスの wait() と notify() メソッドによってこのキューを操作することができます。

Object.wait() を呼び出すと、呼び出したスレッドはそのオブジェクトの条件キューに入ります。 そして、そのスレッドはそこでブロックします。

Object.notify()、または Object.notifyAll() を呼び出すと、そのオブジェクトの条件キューに入っているどれかのスレッドのブロックを解放します。 どのスレッドが解放されるかは保証されません。

wait()、notify()、notifyAll() を呼び出すためにはロックが必要です。

synchronized(obj) { // obj のロックを取得する必要がある
    obj.wait(); // スレッドは条件キューに入り、スレッドの動作は止まる。そして、wait() はロックを解放する
}

一方、notify() や notifyAll() は条件キューに入ったスレッドを解放し、wait() の待ちを解除します。

synchronized(obj) { // obj のロックを取得する必要がある
    // 条件キューに含まれているすべてのスレッドのブロックを解放する
    obj.notifyAll();
}

wait() と notify() のイディオム

前述のように wait() と notify() は Java の条件キューを操作する低レイヤな API です。 wait() と notify() を正しく使うために必要なイディオムを次の通りです。

  • Object.notify(), Object.wait() を呼ぶ時はロックを取得します。
  • while ループで条件が満たされてない場合はループさせます
    • while ループ外で wait() を呼び出してはいけません
  • なるべく notifyAll() を使うべきです。

なぜなら、次のようなケースが考えられるからです。

  • 条件が満たされた状態で wait() に入ってしまうと、notify() が永久によばれない可能性がある
    • このために do-while ではなく while を使う必要があります
  • 条件が満たされていないのに wait() が目覚める可能性がある
    • このために if ではなくループを使う必要があります
  • 他の場所で notifyAll() が使われると、すべての wait() が目覚める
  • 待ちスレッドは希に偽りの目覚めを起こす場合がある。
  • 他のスレッドが悪意をもって notify を呼び出す可能性がある。

このイディオムを擬似コードにすると次のようになります。

// wait を利用するための標準イディオム
synchronized (obj) {
        while (<条件が満たされていない>) {            
            obj.wait();
        }
        // 満たされた条件に対して適切な処理を行う
    }
}

一般的にスレッドを起こす際には notifyAll() を使うべきですが、注意深く使えば notify() の方がパフォーマンスが少し高いです。

このように wait() や notify() を使うためには複雑なイディオムが必要となるので、通常は java.util.concurrent の並行ライブラリをそのまま利用するべきです。

(c) The King's Museum