Revised: 06th/Dec./2003; Since: Oct./14th/2003
オブジェクトの生成/破棄は、比較的コストの高い処理です。オブジェクトの生成を抑えるために、一旦生成したオブジェクトを、コレクションクラスや配列を使ってプールして再利用する工夫が古くから採用されています。
特に、ストリームや DB コネクション、スレッドなどの、生成時に環境のセットアップのコストが高い場合は、オブジェクトの生成を抑制することは、パフォーマンス上も冗長性の排除の点でも有効です。破棄に伴う GC の処理は、Java2 1.3/1.4 相当以上ではかなり改善されているため、生成のコストが高い場合を除いては、プーリングのオーバーヘッドの方が高くなる傾向にあります。
ここでは、オブジェクトの生成を抑制する設計として、次の 4 つの設計方法を紹介します。
デザインパターンで、Flyweight パターンと呼ばれるものがあります。本来は、小さなオブジェクト(フライ級オブジェクト)の多数の生成を避けるためのものですが、現在では、巨大なオブジェクト(ヘビー級オブジェクト)の生成負荷が高い場合に、非常に効果的です。
特に古い JVM の場合は、多数の小さなオブジェクトの生成/廃棄は、GC の起動に伴うオーバーヘッドのコストが高い処理でした。しかし、「オブジェクトの生成の抑制」で述べたように、プーリングなどのオーバーヘッドを必要とする管理機能を実装する場合は、管理コストのほうが高くなる可能性があります。HotSpot 相当以上の JVM を利用する場合は、小さなオブジェクトに対するプーリングを採用するかどうかの判断は、プロファイラによる検証結果にゆだねられます。
一方、大きなオブジェクトや生成負荷が高い場合は、プーリングが有効です。オブジェクトのプーリングを実現するデザイン・パターンが、Flyweight パターンです。
一般に、メソッド equals() が true を返す等価なオブジェクトを、演算子 == が true を返す一意なオブジェクトで置き換えることを、カノニカライゼーション (Canonicalization) と呼びます。オブジェクトの種類ごとに生成されるインスタンスの個数を一つに制限するので、インスタンス化の回数を減らし、データの一意化を促します。
Flyweight パターンを使って、カノニカライゼーションを実現するコードを見てみましょう。次のクラスのオブジェクトをプールすることを考えます。
プールされるオブジェクトのクラス:
class MyClass {
private String name;
public MyClass(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
MyClass が沢山入ったプールを管理するクラスとして MyClassManager を作ります。このクラスは、クライアント・コードからの要求に対して、MyClass 型オブジェクトの種類に応じた一意のオブジェクトを返します。クライアント・コードから見ると、常に同じ種類のオブジェクトが同じインスタンスで返されるので、カノニカル化が実現されています。 MyClassManager を、カノニカル化マップと呼びます。
MyClass 型オブジェクトを、内部の MyClass 型配列 pool[] で保持します。getMyClass() の引数の文字列と、プール内のオブジェクトを比べて、
カノニカル化マップ:
class MyClassManager {
private MyClass[] pool;
private int counts = 0;
// コンストラクタ
public MyClassManager(int size) {
pool = new MyClass[size];
}
// MyClassオブジェクトの取得
public MyClass getMyClass(String name) {
System.out.print("objs: " + counts + ", ");
if (counts > 0) {
for (int i = 0; i < counts; i++) {
// 既存の要素が利用可能である場合
if (pool[i].getName().equals(name)) {
return pool[i];
}
}
}
pool[counts] = new MyClass(name);
return pool[counts++];
}
}
カノニカル化マップである MyClassManager を利用するクライアントは、次のように記述できます。
カノニカル化マップのクライアント・コード:
class MyClassDemo {
public static void main(String[] args) {
// プール内の MyClass 型オブジェクトの個数を 5 個に指定
MyClassManager manager = new MyClassManager(5);
for (int i = 0; i < 10; i++) {
// 10 個の MyClass 型オブジェクトを生成
// MyClass obj = new MyClass(i);
// 5 個の MyClass 型オブジェクトを生成
MyClass obj = manager.getMyClass(Integer.toString(i % 5));
System.out.println("name: " + obj.getName());
}
}
}
MyClassManager は 5 個の要素をプールに保持できるように生成しています。
10 回繰り返すループ内で、オブジェクトの生成を行っています。
もし、MyClass のインスタンスを new するのであれば、10 個のオブジェクトが生成され、プールはパンクします。しかし、getMyClass() で生成するオブジェクトの名前として、0~4 の 5 種類だけしか指定していないので、重複する種類のオブジェクトはプール内の既存のオブジェクトが返されることになり、MyClass 型オブジェクトは 5 つだけしか生成されず、プールはパンクしません。
実行結果は、次のようになります。
>javac MyClassDemo.java >java MyClassDemo objs: 0, name: 0 objs: 1, name: 1 objs: 2, name: 2 objs: 3, name: 3 objs: 4, name: 4 objs: 5, name: 0 objs: 5, name: 1 objs: 5, name: 2 objs: 5, name: 3 objs: 5, name: 4
確かに、5 種類のオブジェクトしか生成されていません。
この例では、配列 pool[] の拡張のロジックが組み込まれていないために、manager 生成時に指定した要素数 5 個以上の種類のオブジェクトを取得しようとすると、カノニカル化マップであるオブジェクト manager のメソッド getMyClass() 内部で、例外 java.lang.ArrayIndexOutOfBoundsException が発生します。配列の拡張には、より大きなサイズの配列をつくり、既存のものを System.arraycopy() でコピーすることになります。拡張が伴う場合や、オブジェクトを一意に指定する識別子が存在する場合は、プールの実装のために、配列ではなく、Vector や HashMap のような、コレクション・フレームワークのクラスの利用を検討することもできます。
ここで例に挙げたように、オブジェクトの種類が制限されている場合は、複数の同じ種類のオブジェクトを作らずに、一意のオブジェクトを返す仕組みを作れば、オブジェクトの生成回数を減らすことができます。また、同じ種類のオブジェクトが、必ず同じインスタンスを参照するものであることが保障できれば、比較時には等価性を評価する equals() と、参照の同値を評価する == が同一の結果を示すことになり、データのインテグリティの面でも有効です。
Flyweight パターンには、別の利用方法として、生成済みの空のオブジェクトをプールしておき、早い者勝ちで、空いているオブジェクトを返すような場合にも使えます。この場合、管理オブジェクトには、再利用可能にするための終了ロジックを用意して、クライアント側では、使い終わったら、管理オブジェクトの終了メソッドを呼び出す必要があります。例えば、次のように実装できます。
pool[] と対になる boolean 型の配列 inUse[] に false をセットする
boolean[] inUse;
for (int i = 0; inUse.length; i++) {
inUse[i] = false;
}
inUse[i] が false である i に対して pool[i] を返し、inUse[i] に true をセットする
for (int i = 0; i < inUse.length; i++) {
if (inUse[i] == false) {
inUse[i] = true;
return pool[i];
}
}
pool[i] で取得したオブジェクトに、目的に応じた初期化を施して、実際に利用する。
// ...省略...
pool[i] をデフォルト値で初期化し、対応する inUse[i] に false をセットする
// objは終了メソッドの引数で受け取る
if (pool[i] == obj) {
// pool[i]の初期化処理
// ...省略...
// 初期済みオブジェクトを再利用可能にする
inUse[i] = false;
}
オブジェクトの再利用や、カノニカル化の理由で、プールを利用する場合は、Flyweight パターンの利用を検討しましょう。一般に、独自実装するよりも、既存のデザインパターン、アルゴリズム、コア・パッケージのクラスで提供している機能で利用できるものがないか探すようにしましょう。
Flyweight パターンで見たような、コンテナとなる管理オブジェクトは、サイズが巨大になる可能性があります。巨大なオブジェクトは複数生成されないようにしておきたいことがあります。また、生成されるオブジェクトを、種類毎に一意性を持たせるために管理オブジェクトを使っている場合も、管理オブジェクトが複数生成されると困ります。クラスに対するインスタンスの個数を制限するのが、Singleton パターン(一人っ子パターン)です。
Singleton パターンのポイントは、コンストラクタを private, static 修飾して、インスタンスはメソッド getter で取得するようにすることです。Flyweight パターンで挙げた MyClassManager の場合は、次のように変更します。
class MyClassManager {
// 自身のSingletonインスタンス
private static MyClassManager manager = new MyClassManager(10);
// ファクトリ自身の複製を作られないようにする
private MyClassManager(int size) {
pool = new MyClass[size];
inUse = new boolean[size];
for (int i = 0; i < size; i++) {
pool[i] = new MyClass();
inUse[i] = false;
}
}
// ファクトリのインスタンスを取得
public static MyClassManager getManager() {
return manager;
}
// 以下同じ
private MyClass[] pool;
private boolean[] inUse;
...
}
コンストラクタもフィールドも private 修飾されているため、クライアントコードからは生成できません。static 修飾されているフィールド manager は、クラスのロード時に一回だけ初期化されるため、複数生成されることはありません。
クライアント側では、static メソッドである getManager() を使って、唯一のインスタンス manager にアクセスすることになります。
この例では、たった一つのインスタンスしか存在できないようにしていますが、インスタンスを配列で保持したり、内部にカウンターを持つことなどによって、生成される個数を制限するように変更することもできます。
因みに、マルチ・スレッドで利用する場合は、pool に生成するオブジェクトの種類に重複が生じないようにするために、getMyClass() などに syncronized 修飾するなどの工夫をしておく必要があります。
サイズが巨大であったり、一意性を確保したいなどの理由で、クラスに対して生成されるインスタンスの個数を制限したい場合は、Singleton パターンの利用を検討しましょう。
オブジェクトをプールするために、コレクション・フレームワークのクラスを利用することがよくあります。しかし、長期間にわたるコレクション・クラスの再利用に伴って、サイズが巨大になり、メモリを圧迫する弊害があります。一般に、サイズが自動的に拡大するオブジェクトは、GC によって回収されるまで、サイズを縮小(シュリンク)する仕組みを備えていないからです。これを解決するためには、管理ロジックを組み込む必要がありますが、弱い参照 (java.lang.ref.WeakReference) を利用することができます。
SDK 1.2 のコア・パッケージには、オブジェクトの参照強度を段階的に表現するパッケージである、java.lang.ref が導入されました。参照強度とは、GC の回収対象になる段階を表現するものです。
SoftReference 型のオブジェクトで実現される。強可到達ではなく、利用可能なメモリが少なくなることによって、参照が破棄されて GC の回収対象になる可能性を持つ。メモリが解放されると、参照が ReferenceQueue に追加される。"nice to have" なキャッシュの実装に適している。WeakReference 型のオブジェクトで実現される。強可到達でも、ソフト可到達でもなく、利用可能なメモリが少なくなり GC が起動すると、参照が破棄されて GC の回収対象になる。メモリが解放されると、参照が ReferenceQueue に追加される。ヒープとして割り振り可能なメモリ・サイズを圧迫する可能性があったり、カノニカル表現を実装したりするようなオブジェクトに適している。PhantomReference 型のオブジェクトで実現される。強可到達でも、ソフト可到達でも、弱可到達でもなく、他の参照とは異なり、メモリ領域が実際に解放される前に、対になる ReferenceQueue に追加され、当該の参照を通じて、終了処理を施すことが可能。クラス java.lang.ref.Reference を継承するこれらのクラスの参照強度を、メモリの解放が遅い順序で並べると、 PhantomReference > SoftReference > WeakReference になります。ここでは、弱い参照の利用例として、WeakedHashMap 型オブジェクトを、カノニカル表現のマッパーとして利用する例を挙げます。サンプルのコードに目を通していただければ、すぐにお分かりになる通り、カノニカル表現の実現だけではなく、キャッシュとして働きます。
参照 Reference は、ReferenceQueue クラスのインスタンスと共に利用されるのが一般的です。WeakReference は到達可能性が変更されると、GC によって登録されるキューを実装するものであり、対になる参照が GC に回収されたことを通知します。WeakReference の場合は、ReferenceQueue に登録されるようにするかどうかは任意です。登録する場合は、コンストラクタの引数として、参照対象 (referent) となるオブジェクト共に指定します。referent が null になると、WeakReference 型オブジェクトは、登録されている queue にエンキューされます。
// 参照キュー ReferenceQueue queue = new ReferenceQueue(); // 参照対象 Object referent = new MyClass(); // 弱い参照 WeakReference wref = new WeakReference(referent, queue)
コレクション・フレームワークの中で、java.util.WeakHashMap は、インタフェース java.util.Map の実装の一つです。
ほかの Map 実装クラスでは、キーは Map 型オブジェクトから直接参照されており、キーに対する明示的な参照がなくなっても、メソッド remove() や clear() で明示的に破棄されない限り、当該 Map オブジェクト自身が破棄されるまで生存し続けます。
一方、WeakHashMap 型オブジェクトの場合、キーは WeakReference 型オブジェクトの参照対象として存在し、WeakHashMap 型オブジェクトから直接的にではなく弱く参照されています。その結果、WeakHashMap 型オブジェクトが生存している間でも、メモリ使用率の変化に応じて、キーはいつでも GC の回収対象になる可能性があります。
値についてもキーが回収されると一緒に回収されます。WeakHashMap に限らず、一般に Map 型オブジェクトでは、キーが null になると、対応する値も廃棄対象となります。
SDK 1.4 では、WeakHashMap クラスは、格納する要素である Entry 型オブジェクトを次のように定義しています。
private static class Entry extends WeakReference implements Map.Entry {
private Object value;
private final int hash;
private Entry next;
Entry(Object key, Object value, ReferenceQueue queue, int hash, Entry next) {
super(key, queue);
this.value = value;
this.hash = hash;
this.next = next;
}
...
}
一方、HashMap クラスでは、次のように実装されています。
static class Entry implements Map.Entry {
final Object key;
Object value;
final int hash;
Entry next;
Entry(int h, Object k, Object v, Entry n) {
value = v;
next = n;
key = k;
hash = h;
}
...
}
双方を比べると、HashMap では、value と key の両方とも直接参照していることが分かりますが、WeakHashMap では、value は直接参照していますが、key は WeakReference を経由した間接参照になっていることが分かります。
次のコードは、Flyweight パターンで挙げた例 MyClassManager を、WeakHashMap で書き換えたものです。
import java.util.WeakHashMap;
class MyClassManager {
private WeakHashMap pool;
// コンストラクタ
public MyClassManager(int size) {
pool = new WeakHashMap(size);
}
// MyClassオブジェクトの取得
public MyClass getMyClass(String name) {
// 既存の要素が利用可能である場合
if (pool.containsKey(name)) {
return (MyClass)pool.get(name);
}
// 存在しない場合は新規追加
pool.put(name, new MyClass(name));
return (MyClass)pool.get(name);
}
}
この例では、pool が WeakHashMap 型オブジェクトで、キーに name、値に MyClass 型オブジェクトを保持しています。キーは WeakReference のインスタンスなので、メモリが逼迫すると GC によって回収される可能性がありますが、回収されてなくなってしまっても、次回の getMyClass() 起動時に新規に追加し直すことになるので、問題は生じません。そのときには、オブジェクトの生成負荷が掛かることになりますが、OutOfMemoryError の発生により、システム全体に影響を及ぼすよりも遥かにマシでしょう。まさに、キャッシュのような働きをしてくれるのです。
オブジェクトをプールするときに、管理するコレクション・オブジェクトが巨大になる場合は、管理対象のオブジェクトを弱い参照で保持できないか検討しましょう。
コレクション・クラス についての詳細は、本稿のコアパッケージの説明を参照くださ。
static フィールドはクラスのロード時に、ただ一回だけ初期化されると述べました。逆に言うと、クラスがロードされると、 static フィールドは初期化されます。
考え方として、本格的に稼動してトラフィックが増えているかもしれない後で初期化の負荷を掛けるよりも、 JVM 起動後の初期に初期化したた方が望ましいかもしれません。一方で、そのような明確な要件がない場合は、必要になったら初期化するという、怠惰な考え方の方がパフォーマンス上の利得が高いことが、経験的に知られています。怠惰な設計を、lazyu design と呼び、クラスのロードを遅らせることを、lazy loading と呼びます。
カノニカル化マップやオブジェクト・プールを初期化する場合、怠惰な初期化が好ましい場合があります。static フィールドのように、クラスのロード時に自動的に初期化されることを避ける怠惰な方策として、内部クラスを実装することが考えられます。
怠惰な実装では、プールは内部クラスで実装し、getter メソッドの内部で static な当該クラスのオブジェクトインスタンス化します。
論より証拠で、簡単なコードを示します。次のクラスは、MyClassManager 型オブジェクトのカノニカル表現を返すものですが、 lazy loading で実装されているものです。
lass MyClassManager {
private MyClassManager();
private static class SingletonOnlyOne() {
static final MyClassManager manager = mew myClassManager();
}
public static MyClassManager() {
return SingletonOnlyOne.
}
}