Javaの理論と実践: ガベージコレクションとパフォーマンス

ガベージコレクションに負担をかけないクラスを設計するための手ほどき、技、考え方

今回の「Javaの理論と実践」では、コラミニストBrian Goetz氏に執筆を担当していただき、コレクタの選び方によるパフォーマンスへの影響、コーディング形式の違いによるガベージコレクションへの影響、さらにメモリ確保やそれに関連する動作がJava仮想マシンに与える負担がここ数年でどのように変化したかについて論じていただきます。

Brian Goetz (brian@quiotix.com), Principal Consultant, Quiotix

Brian Goetz は18 年間以上に渡って、専門的ソフトウェア開発者として働いています。彼はカリフォルニア州ロスアルトスにあるソフトウェア開発コンサルティング会社、Quiotixの主席コンサルタントであり、またいくつかのJCP Expert Groupの一員でもあります。2005年の末にはAddison-Wesleyから、Brianによる著、Java Concurrency In Practiceが出版される予定です。Brian著による有力業界紙に掲載済みおよび掲載予定の記事のリストを参照してください。



2004年 1月 27日

Javaが登場した初期の頃、オブジェクトのメモリ確保にはかなりの非難がありました。多くの記事(いくつかは著者によるものでした)で、一時的なオブジェクトの作成は必要でない限り避けるべきだという開発者へのアドバイスが促されました。メモリ確保(とそれに伴うガベージコレクションのオーバーヘッド)の負荷が大きかったからです。確かに(パフォーマンスが重視される状況においては)それは良いアドバイスと言えました。しかし、さほどパフォーマンスが重要ではない、一般的な状況においては、それはとても受け入れられる内容とは言えませんでした。

メモリの確保にどれほど負担がかかるのか

JDKの1.0と1.1ではマークスイープ方式と呼ばれるコレクタが使われていました。この方式は、コレクションのタイミングでメモリコンパクションを実行するのですが、それがすべてのコレクションのタイミングでは実行されず、結果としてヒープ領域がガベージコレクションの動作により断片化される可能性がありました。そのため、JDK 1.0と1.1のJVMにおけるメモリ確保の負荷は、ヒープの未使用領域の管理に「first-first」や「best-fit」と呼ばれる試行錯誤法を使っているCやC++と同等のものでした。マークスイープ方式では、コレクションが動作するタイミングでヒープ全体を走査する必要があったため、メモリの解放にも負担がかかっていました。これでメモリの管理をあまりマシンに依存できなかった経緯もご理解いただけるでしょう。

(SunのJDK 1.2以降から実装された)HotSpot方式のJVMではだいぶ問題が緩和されました。SunのJDKに世代別ガベージコレクタが実装されたのです。この方式では、コピーコレクタが若い世代の領域内で主に動作するため、ヒープの未使用領域には常に切れ目がなく、その結果、新たなオブジェクトをヒープに確保する手順は、リスト1に示したように、単にポインタを追加するだけで良くなりました。この方式の転換により、Javaのアプリケーションにおけるメモリ確保時の負荷は、Cのアプリケーションよりも格段に減少し、多くの開発者が最初に直面していた問題も緩和されました。同時に、コピーコレクタは使用されなくなったオブジェクトを参照しないため、Javaのアプリケーションにはありがちな、ヒープに確保した一時的なオブジェクトの回収にもほとんど負荷がかからなくなりました。単に使用中のオブジェクトを探索して生存領域にコピーし、残ったヒープを一気に回収するだけで良いのです。フリーリストも領域の合成もコンパクションも必要なく、ヒープを一掃して処理を再開するだけになったのです。この方式により、JDK 1.2では、オブジェクトごとの確保と解放にかかる負荷が共に軽減されました。

リスト1. 連続するヒープを利用した素早い領域確保
void *malloc(int n) { synchronized (heapLock) {
    if (heapTop - heapStart > n)
      doGarbageCollection();
    void *wasStart = heapStart;
    heapStart += n;
    return wasStart;
  }
}

多くの場合、パフォーマンスに関するアドバイスは、長くは適用されません。確かに以前はメモリ確保の負荷が大きいという事実がありましたが、現在は違います。実際、負荷はとても少ないのです。ごくわずかな数値的理論を考慮したとしても、パフォーマンス向上のためにメモリ確保を避けることは一般的に好ましくありません。Sunが 10種類ほどのマシン命令でメモリ確保の負荷実験をしましたが、結果として、負荷はほとんどありませんでした。少数のオブジェクトの生成を避ける目的でプログラムの構造を複雑にしたり、メンテナンスの頻度を増やしたりといったリスクを負う必要は全くないのです。

もちろんメモリ確保だけでは話の半分も終わっていません。確保されたオブジェクトはたいてい、いずれは不要になり回収されますが、その時にもやはり負荷がかかります。しかしながらここにも良い知らせがあります。ほとんどのJavaのアプリケーションにおいて、大多数のオブジェクトは次の回収が実行される前に不要になります。小さな回収での負荷は、最後に行なわれた回収以降に割り当てられたオブジェクトの数ではなく、若い世代の領域にある使用中のオブジェクトの数に比例します。次の回収まで生き続けるオブジェクトは、若い世代の領域にはごくわずかしかないのですから、1回のメモリ確保に対する回収にかかる負荷は極めて少なくなります(単純に容量の範囲内でヒープサイズを広げた場合にはさらに負荷は少なくなります)。

さらなる負荷の軽減

JITコンパイラはさらなる最適化の導入によって、メモリ確保の負荷を0にすることを可能にしました。リスト2をご覧ください。getPosition()メソッドは点の座標を保持するための一時的なオブジェクトを作成しています。メソッドを呼ぶ側はこのPointオブジェクトを一時的に作成した後、すぐに破棄するような動作をします。JITコンパイラは、 Escape解析と呼ばれる技術により、このgetPosition()メソッドの呼び出しをインラインで実装し、Pointオブジェクトへの参照がdoSomething()メソッド内に残らないようにします。この手法により、JITコンパイラは次に、オブジェクトをヒープではなく、スタックに作成するようになり、さらに良好な動作環境では、メモリ確保の負荷を完全になくし、Pointオブジェクトの領域を直接レジスタに書き込むようになります。現行のSunのJVMでは、まだこの最適化技術は実現できていませんが、将来的にこの技術を取り入れる可能性はあります。ソースコードに手を加えることなく領域確保の負荷を軽減させる技術が今後も進歩すると考えると、ごくわずかな余分な負荷を軽減させるためにソースコードを修正したり、メンテナンスの頻度を増やしたりといったリスクを負うことはさらに意味がないように思えてきます。

リスト2. Escape解析により、多くの一時的領域確保は不要になります
void doSomething() { Point p = someObject.getPosition();
  System.out.println("Object is at (" + p.x, + ", " + p.y + ")");
}
...
Point getPosition() { return new Point(myX, myY);
}

メモリ確保技術の発展は拡張性の妨げになっていないか

リスト1で示した例では、メモリ確保が高速である一方、ヒープ領域へのアクセスがスレッドに同期していないとならない面がありました。このことは拡張性の妨げになっているのではないかと思われるかもしれません。実はこの負荷を大幅に軽減させるいくつかの巧妙なしくみがJVMに取り入れられているのです。IBMのJVMは「thread-local heaps」と呼ばれる技術を取り入れています。この技術では、それぞれのスレッドが(約1Kの)小さなメモリブロックをアロケータに要求します。小さなオブジェクトのメモリ確保はこのブロック内で行なわれます。この小さなブロックでは確保できないような大きな領域をプログラムが要求した場合には、グローバルアロケータがその領域を直接確保するか、新たなブロックを作成することによって解決します。この技術によって、ほとんどのメモリ確保は、共有ヒープの使用権を奪い合うことなく、行なわれるようになりました(SunのJVMでは、「Local Allocation Blocks」という呼び名で同様の技術を実現しています)。


ファイナライザは厄介者

ファイナライザを持つオブジェクト(複雑なfinalize()メソッドを実装しているオブジェクト)は、他のオブジェクトに比べ、処理にかかるオーバーヘッドがはるかに高いため、頻繁に使ってはなりません。ファイナライザを持つオブジェクトは、メモリの確保と解放の両方に多くの時間を必要とします。メモリ確保時、JVM(少なくともHotSpot JVMでは)は、ファイナライザを持つオブジェクトをガベージコレクタに登録します。そのため、ファイナライザを持つオブジェクトは、通常よりも多くの時間を要するメモリ確保手順を踏むことになります。同様に、ファイナライザを持つオブジェクトは、メモリ解放時に(最低でも)2回のガベージコレクションサイクルを辿る必要があり、その都度ガベージコレクタがfinalize()メソッドを呼ぶことになるため、メモリの解放にも時間がかかります。ファイナライザを持つオブジェクトは、不要になった後もより長くメモリに滞在する性質を持つため、結果的に、メモリの確保と解放の両方に時間がかかるだけでなく、ガベージコレクタにも多くの負荷がかかることになります。そういった性質と、通常の運用ではfinalize()メソッドの動作が保証されていないか全く動作しないという事実を考慮すれば、ファイナライザが重宝される状況などほとんどありえないということがわかっていただけることでしょう。

どうしてもファイナライザを使わなければならない場合には、被害を抑えるのに役立つ手法がいくつかあります。ファイナライザを持つオブジェクトの数を限定してください。そうすることにより、ファイナライザの動作によってメモリの確保と解放に多大な負荷をかけるオブジェクトの数を最小限にすることができます。また、クラス設計の際には、ファイナライザを持つオブジェクトは使われなくなった後、オブジェクト内にデータを保持しないようにしてください。ファイナライザを持つオブジェクトは実際に回収されるまでに長い遅延が発生する可能性があるので、使われなくなった後にもデータを保持したままでいると、それだけメモリを圧迫することになるからです。とりわけ、標準ライブラリの拡張クラスを作る際には、この点に注意してください。


ガベージコレクタの動作を助けたつもりでも

Javaのプログラムでは、メモリの確保と解放が一度に実行されるとパフォーマンスが極度に低下します。そのため、オブジェクトプーリングやnull代入といった多くの巧妙な技術が考案されました。しかしながら、多くの場合において、こういった手法はパフォーマンスを向上させるどころか、むしろ低下させる結果を招いてしまいました。

オブジェクトプーリング

オブジェクトプーリングは率直な考え方です。頻繁に使われるオブジェクトをプール領域に確保しておき、そのオブジェクトが必要になった際には、新たに作ることをせず、プール領域から取得してくるというもので、何度もメモリ確保を繰り返して負荷をかけることを排除するといった考え方です。確かに、データベースとの接続やスレッドの生成が必要なクラスオブジェクトの作成は、メモリ確保時に高い負荷がかかりますし、プール領域にオブジェクトを確保しておけば、データベースとの接続等に限られたリソースを有効に使うことができるため、この手法は有効と言えます。しかしながら、こういった状況が発生することはごく稀なのです。

加えてオブジェクトプーリングには重大な欠点があります。一般的にオブジェクトのプールはすべてのスレッドから共有されます。そのため、プール内でのオブジェクトのメモリ確保処理は同期させる必要があり、それがパフォーマンスのボトルネックとなる場合があります。また、オブジェクトプーリングを実現させる場合には、メモリの解放も明示的に行なう必要があり、これを徹底しないと参照先のないポインタを作る危険性も発生します。さらに、パフォーマンスを十分に引き出すためには、プール領域のサイズを適切に設定することも必要です。プール領域のサイズが小さすぎると、新たなメモリ確保を廃止することが実現出来なくなりますし、逆に大きすぎると、回収されるべきリソースがいつまでもプール領域に残ってしまいます。回収の対象となるメモリの領域を限定すると、今度はプール領域の使用がガベージコレクタに更なる負荷をかけることになってしまいます。効果的なオブジェクトプーリングの実装手順を述べるのは容易なことではないのです。

2003年のJavaOne(参考文献参照)で発表された「Performance Myths Exposed」にて、Cliff Click博士は、具体的なベンチマークテストの結果を用いて、かなりの大きさを持つオブジェクトでない限り、現行のJVMにおいては、オブジェクトプーリングがパフォーマンスの低下を引き起こすということを提唱し、このことはメモリ確保のシリアライゼーションとダングリング・ポインターの危険性に関する議論に掲載されました。ごく稀なケースでない限り、オブジェクトプーリングは避けるべきだということです。

明示的null代入

明示的null代入は、単に使い終わった参照オブジェクトにnullを代入するというものです。この手法はオブジェクトをよく早くガベージコレクションの対象にするという考え方から生まれました。少なくとも理論上はそれが正しいとされています。

明示的null代入が、有効であるというだけでなく事実上必要とされる場合もあります。それはオブジェクトへの参照が、実際に使われる範囲よりも広いスコープで要求されている場合や、プログラムの仕様上それが妥当だと考えられている場合です。ローカル変数を使わず、一時的なバッファにオブジェクトの参照を確保しておくためのスタティックもしくはインスタンスの領域を使う場合(参考文献からリンクしている「Eye on performance: Referencing objects」での使用例をご参照ください)や、プログラムの必要性からではなく、実行時に常に参照される可能性があるという意味で、オブジェクトの参照を配列に保持しておく場合などが、これに当てはまります。配列による単純な有限スタックの実装を示したリスト3のクラスをご覧ください。仮に明示的null代入を行なわずにpop()を呼んだ場合、このクラスはメモリリーク(正確には「意図しないオブジェクト保持」と呼びます。また「オブジェクト浮遊」と呼ばれることもあります)を引き起こす可能性があります。なぜなら、stack[top+1]に保持された参照は、既にプログラム上は必要とされなくなっているのに、ガベージコレクタがまだこれを必要なものとみなしているからです。

リスト3. オブジェクト浮遊を避けるスタックの実装例
public class SimpleBoundedStack {
  private static final int MAXLEN = 100;
  private Object stack[] = new Object[MAXLEN];
  private int top = -1;
  public void push(Object p) { stack [++top] = p;}
  public Object pop() {
    Object p = stack [top];
    stack [top--] = null;  // explicit null
    return p;
  }
}

1997年9月の「Java Developer Connection Tech Tips」(参考文献参照)のコラムにて、Sunはメモリリークの危険性に関する警告を促し、上記の例のpop()のような場合において、どれほど明示的null代入が重要であるかを説明しました。しかしながら、多くのプログラマはこの警告を軽視し、ガベージコレクタの動作を助けるものと期待して、明示的null代入を使っています。多くの場合、これは全くガベージコクレタの助けにはなっておらず、実際にはプログラムのパフォーマンスを損ねる場合さえあるのです。

いくつかの悪い例をまとめたリスト4をご覧ください。この例はリンクトリストを実装したものであり、ファイナライザがリストを辿って、後に続くオブジェクトをすべてnullにするように動作しています。ファイナライザがどれほど悪いものかについては既に論じてきましたが、この例では状況がさらに悪くなっています。なぜならこの例では、見かけ上ガベージコクレタの動作を助けているようでありながら実際には何の助けにもなっておらず、むしろ動作の妨げになるような余計な処理を実行しているからです。リストを辿ることはCPUサイクルを消費し、使われていないオブジェクトを参照してキャッシュに取り入れる動作までしてしまいます。ガベージコレクタに任せてしまえば、これらの処理は完全に除外することができます。なぜなら、ガベージコレクタは使われなくなったオブジェクトを全く参照しないからです。結局のところ、参照にnullを代入してもガベージコレクタの動きを助けることにはなりません。リストの先頭が使われなくなった時点でリストの残りの部分も使われなくなるのですから。

リスト4. ファイナライザと明示的null代入の両用により全体のパフォーマンスが極度に低下する例(決してやらないでください)
public class LinkedList {
  private static class ListElement {
    private ListElement nextElement;
    private Object value;
  }
  private ListElement head;
  ...
  public void finalize() { try {
      ListElement p = head;
      while (p != null) {
        p.value = null;
        ListElement q = p.nextElement;
        p.nextElement = null;
        p = q;
      }
      head = null;
    }
    finally {
      super.finalize();
    }
  }
}

明示的null代入は、リスト3に示したスタックの例ように、パフォーマンス上の理由からプログラムが通常のスコープルールに従わない場合にのみ使用するようにしてください(より正確に言うと、パフォーマンスの悪化はやむを得ないとしても、スタックの配列が変化する度にメモリを再配置して要素をコピーするような実装が望ましいのです)。

明示的ガベージコレクション

開発者が、ガベージコレクタの動作を助けるものと誤解しやすいものの3番目はSystem.gc()の呼び出しです。このメソッドはガベージコレクションを起動させます(実際には、単に今がガベージコレクションに適したタイミングであるかもしれないということを提案するだけなのですが)。しかしながらSystem.gc()は、ヒープのすべての使用中オブジェクトを辿り、不要部分を回収して古い世代のコンパクションを行なうという、完全な回収処理を実行するため、大掛かりな動作になりかねません。一般的には、どのタイミングでヒープの回収を行なうか、また完全な回収処理を実行するかどうかは、システムに判断させたほうが良いのです。多くの場合は、小さなガベージコレクションだけで十分に事足ります。さらに悪いことに、System.gc()の呼び出しは開発者の予期しない場所に埋め込まれていて、必要以上に実行されてしまう場合もあるのです。アプリケーションの中に、ライブラリに埋め込まれた、隠れたSystem.gc()の呼び出しがあるかもしれないと不安に思う場合は、”-XX:+DisableExplicitGC”オプションでJVMを起動するようにしてください。そうすれば、System.gc()の呼び出しとガベージコレクションの起動は行なわれなくなります。

再び不変オブジェクトに戻って

「Javaの理論と実践」は不変オブジェクトに関する議論なしでは終わりません。オブジェクトを不変にすることによりバグの原因となるクラスを排除することができます。クラスを不変にしないことの最も一般的な理由の1つは、不変クラスの使用がパフォーマンスの低下を引き起こすという考えがあることです。その考えは正しい場合もあれば、間違っている場合もあるのですが、不変オブジェクトを使うことによって格段に、おそらくは驚くほどにパフォーマンスを向上させる場合もあります。

多くのオブジェクトは他のオブジェクトへの参照を保持するものとして機能します。参照される側のオブジェクトが変化した時の動作には2通りがあります。ひとつは参照を更新すること(可変オブジェクト内で行なう場合)、そしてもうひとつは新しい参照を保持するオブジェクトを再作成すること(不変オブジェクト内で行う場合)です。リスト5は、簡単なホルダークラスの実装例を示しています。包含しているオブジェクトがごく一般的な小さいもの(Mapインターフェース内にあるMap.Entryやリンクトリストなど)と想定すると、新たな不変オブジェクトの作成は、オブジェクトの世代間で動作する世代間ガベージコレクタの動作の違いによる、隠れたパフォーマンスの向上が生まれることがわかります。

リスト5 参照を保持する可変オブジェクトと不変オブジェクト
public class MutableHolder {
  private Object value;
  public Object getValue() { return value; }
  public void setValue(Object o) { value = o; }
}
public class ImmutableHolder {
  private final Object value;
  public ImmutableHolder(Object o) { value = o; }
  public Object getValue() { return value; }
}

多くの場合、ホルダーオブジェクトが別のオブジェクトを参照するように更新されると、新たに参照されるようになったオブジェクトは自分よりも新しくなります。MutableHolderクラスオブジェクトをsetValue()メソッドで更新した場合は、古いオブジェクトが新しいオブジェクトを参照するようになります。一方、新たなImmutableHolderクラスオブジェクトを作成した場合は、新しいオブジェクトが古いオブジェクトを参照するようになります。たいていのオブジェクトが自分よりも古いオブジェクトを参照する後者においては、世代間ガベージコレクションがよりスムーズに動作します。古い世代の領域に生存しているMutableHolderクラスオブジェクトが更新された場合は、次の小さな回収の時に、そのMutableHolderクラスオブジェクトを含むカード領域にあるすべてのオブジェクトに対して世代間ガベージコレクションのチェックをしなければなりません。生存期間の長い保管用オブジェクトに可変クラスを使うと、世代間ガベージコレクションの作業量を増やすことになるのです(現行のSunのJVMに取り入れられている、世代間ガベージコレクタのwrite-barrierを実現させるための、カードマーキングアルゴリズムの説明を、前回の記事と本記事の参考文献から読んでみてください)。


パフォーマンスに関する良いアドバイスが悪いアドバイスになる時

「 Java Developer's Journal」は2003年7月の特集で、パフォーマンスに関するアドバイスは、現在の状況や解決すべき問題点を正確に認識してないと、良かれと思って提案した内容でも逆にパフォーマンスを悪化させる結果に繋がる可能性が高いということを提唱しました。数々の有用な分析結果を載せた記事であっても、実際にやってみると状況が悪化する場合があるのです(残念ながら、とりわけパフォーマンスに関する話題ではこういった事態の発生する可能性が高いのです)。

この記事は、不定期的にガベージコレクションが停止することを想定せず、停止した場合にもその停止時間には厳しい制約があるといった、リアルタイム環境に必要とされる内容に関して書かれたものです。そこで執筆者たちはパフォーマンスの向上のために、参照のnull代入、オブジェクトプーリング、明示的ガベージコレクションを勧めました。ここまでのところで、読者には問題点と解決策が想定できるようになり、有効な内容であったと言えるでしょう(この先、実際に取り入れる場合にかかる費用の算出や、コンカレントコレクションなどの代用手段の検索で間違いを冒す可能性もあるのですが)。しかしながら、記事のタイトル(「Avoid Bothersome Garbage Collection Pauses」)や内容は広い範囲でのアプリケーション(おそらくすべてのJavaアプリケーションに適用できるのですが)に有効な内容ですが、パフォーマンスの向上という意味では、非常によくなく、危険を伴う内容です。

たいていのアプリケーションにおいては、明示的null代入、オブジェクトプーリング、明示的ガベージコレクションは、スループットを向上させるどころか悪化させてしまいます。もちろんそれにより個人のプログラミングスタイルを壊すことにもなります。リアルタイム環境や組み込み機器のように、スループットよりも予期可能性が重視される場合もありますが、多くのJavaアプリケーション(ほとんどはサーバサイドで動作するものですが)においては、スループットのほうを優先させるべきです。

この記事を通して言いたいことは、パフォーマンスに関するアドバイスは極めて限られた状況にのみ適用されるものであり、その対応が有効である期間も短いということです。パフォーマンスの改善は適材適所であり、ごく限られた状況で発生した特定の問題を解決するための手段として提案するものです。ですから、状況が変化した場合や、そもそもアドバイスが適用可能な状況ではなかった場合には、そのアドバイスは意味のないものになってしまいます。パフォーマンスを向上させようとして個人のプログラミングスタイルを台無しにしてしまう前に、実際にパフォーマンスに関する問題を抱えているのか、そしてそのアドバイスが問題解決の手段になるのかを十分考慮した上で適用するようにしてください。


要約

ガベージコレクションに関する話題はここ数年で多々論じられ、現代のJVMでは、メモリ確保の速度も向上し、以前に比べガベージコレクションによってシステムが停止する時間も短くなり、非常に性能が良くなりました。オブジェクトプーリングや明示的null代入は、かつてはパフォーマンスの向上に役立つ斬新なアイデアでしたが、メモリの確保とガベージコレクションにかかる負荷がかなり軽減された現代においては、もはや必要なものでも有り難いものでもなく(場合によっては危害を与えることもある)なってしまいました。

参考文献

コメント

developerWorks: サイン・イン

必須フィールドは(*)で示されます。


IBM ID が必要ですか?
IBM IDをお忘れですか?


パスワードをお忘れですか?
パスワードの変更

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


お客様が developerWorks に初めてサインインすると、お客様のプロフィールが作成されます。会社名を非表示とする選択を行わない限り、プロフィール内の情報(名前、国/地域や会社名)は公開され、投稿するコンテンツと一緒に表示されますが、いつでもこれらの情報を更新できます。

送信されたすべての情報は安全です。

ディスプレイ・ネームを選択してください



developerWorks に初めてサインインするとプロフィールが作成されますので、その際にディスプレイ・ネームを選択する必要があります。ディスプレイ・ネームは、お客様が developerWorks に投稿するコンテンツと一緒に表示されます。

ディスプレイ・ネームは、3文字から31文字の範囲で指定し、かつ developerWorks コミュニティーでユニークである必要があります。また、プライバシー上の理由でお客様の電子メール・アドレスは使用しないでください。

必須フィールドは(*)で示されます。

3文字から31文字の範囲で指定し

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


送信されたすべての情報は安全です。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Java technology
ArticleID=218531
ArticleTitle=Javaの理論と実践: ガベージコレクションとパフォーマンス
publish-date=01272004