Java アプリケーションは JVM 上で動作しますが、JVM テクノロジーについてご存知ですか?この記事 (このシリーズの最初の部分) では、Java ライトワンスの長所と短所、クロスプラットフォーム エンジン、ガベージ コレクションの基本、従来の GC アルゴリズム、コンパイルの最適化など、従来の Java 仮想マシンがどのように機能するかについて説明します。以降の記事では、今日の同時実行性の高い Java アプリケーションのパフォーマンスとスケーラビリティをサポートする最新の JVM 設計を含む、JVM パフォーマンスの最適化について説明します。
あなたが開発者であれば、突然インスピレーションがひらめき、すべてのアイデアがつながり、以前のアイデアを新しい視点から思い出すことができる、この特別な感覚に遭遇したことがあるはずです。私自身、新しい知識を学ぶ感覚が大好きです。 JVM テクノロジ、特にガベージ コレクションと JVM パフォーマンスの最適化を扱っているときに、このような経験を何度も経験しました。この新しい Java の世界で、これらのインスピレーションを皆さんと共有できればと思っています。私がこの記事を書いているのと同じように、JVM のパフォーマンスについて学ぶことに興奮していただければ幸いです。
この一連の記事は、JVM の基礎知識と JVM が実際に何を行うかについて詳しく知りたいすべての Java 開発者を対象に書かれています。概要としては、ガベージ コレクションと、アプリケーションの動作に影響を与えずに空きメモリの安全性と速度を無限に追求することについて説明します。 JVM の重要な部分、つまりガベージ コレクションと GC アルゴリズム、コンパイルの最適化、および一般的に使用されるいくつかの最適化について学びます。また、Java マークアップが非常に難しい理由についても説明し、パフォーマンスのテストをいつ検討すべきかについてのアドバイスも提供します。最後に、Azul の Zing JVM、IBM JVM、Oracle の Garbage First (G1) ガベージ コレクションの焦点など、JVM と GC のいくつかの新しいイノベーションについて説明します。
Java のスケーラビリティ制約の性質と、これらの制約によって Java デプロイメントを最適な方法で作成することがどのように強制されるのかについて、より深く理解してこのシリーズを読み終えていただければ幸いです。啓発の感覚と Java の良いインスピレーションを得られることを願っています。これらの制限を受け入れるのをやめて、制限を変更してください。まだオープンソースに取り組んでいない場合は、このシリーズがこの分野での開発を奨励するかもしれません。
JVM のパフォーマンスと「一度コンパイルすればどこでも実行できる」という課題
Java プラットフォームは本質的に遅いと頑固に信じている人たちに新しいニュースがあります。 Java が最初にエンタープライズ レベルのアプリケーションになったとき、JVM が批判された Java のパフォーマンスの問題はすでに 10 年以上前のものでしたが、この結論は現在では時代遅れです。確かに、今日のさまざまな開発プラットフォームで単純な静的タスクと決定論的タスクを実行すると、同じ JVM の下で、マシンに最適化されたコードを使用した方が、仮想環境を使用するよりもパフォーマンスが向上することがわかるでしょう。ただし、Java のパフォーマンスは過去 10 年間で大幅に向上しました。 Java 業界の市場需要と成長により、いくつかのガベージ コレクション アルゴリズム、新しいコンパイル技術革新、高度な JVM テクノロジを備えた多数のヒューリスティックと最適化が生まれました。これらのいくつかについては、今後の章で説明します。
JVM の技術的な美しさは、JVM の最大の課題でもあります。「一度コンパイルすればどこでも実行できる」アプリケーションは何もありません。 JVM は、1 つのユースケース、1 つのアプリケーション、または 1 つの特定のユーザー負荷に対して最適化するのではなく、Java アプリケーションが現在実行していることを継続的に追跡し、それに応じて最適化します。この動的な操作は、一連の動的な問題を引き起こします。 JVM に取り組んでいる開発者は、イノベーションを設計するときに (少なくとも実稼働環境でパフォーマンスを要求するときは) 静的コンパイルや予測可能な割り当てレートに依存しません。
JVMパフォーマンスの原因
私は初期の仕事で、ガベージ コレクションを「解決する」のが非常に難しいことに気づき、常に JVM とミドルウェア テクノロジに魅了されてきました。私の JVM に対する情熱は、JRockit チームに所属し、ガベージ コレクション アルゴリズムを独学でデバッグするための新しい方法をコーディングしていたときに始まりました (「参考文献」を参照)。このプロジェクト (JRockit の実験的な機能となり、決定論的ガベージ コレクション アルゴリズムの基礎となりました) は、私の JVM テクノロジへの旅の始まりでした。私は BEA Systems、Intel、Sun、Oracle で働いてきました (Oracle が BEA Systems を買収したため、短期間 Oracle で働いていました)。その後、Zing JVM を管理するために Azul Systems のチームに加わり、現在は Cloudera で働いています。
マシンに最適化されたコードは、より優れたパフォーマンスを実現する可能性があります (ただし、柔軟性は犠牲になります)。しかし、これは、動的な読み込みと急速に変化する機能を備えたエンタープライズ アプリケーションでそれを検討する理由にはなりません。 Java の利点を得るために、ほとんどの企業は、マシンに最適化されたコードによってもたらされるかろうじて完璧なパフォーマンスを犠牲にすることを厭いません。
1. コーディングや機能開発が容易(市場対応期間の短縮)
2. 知識豊富なプログラマーを獲得する
3. Java API と標準ライブラリを使用して開発を迅速化する
4. 移植性 - 新しいプラットフォーム用に Java アプリケーションを書き直す必要はありません
Javaコードからバイトコードへ
Java プログラマーであれば、おそらく Java アプリケーションのコーディング、コンパイル、実行に精通しているでしょう。例: プログラム (MyApp.java) があり、それを実行したいとします。このプログラムを実行するには、まず javac (JDK に組み込まれている静的 Java 言語からバイトコードへのコンパイラ) を使用してコンパイルする必要があります。 Java コードに基づいて、javac は対応する実行可能バイトコードを生成し、それを同じ名前 (MyApp.class) でクラス ファイルに保存します。 Java コードをバイトコードにコンパイルした後、Java コマンドを使用して (起動オプションを使用せずにコマンド ラインまたは起動スクリプトを使用して) 実行可能クラス ファイルを起動し、アプリケーションを実行できます。このようにして、クラスがランタイム (Java 仮想マシンの実行を意味します) にロードされ、プログラムの実行が開始されます。
これはすべてのアプリケーションが表面上で実行することですが、Java コマンドを実行すると正確に何が起こるかを見てみましょう。 Java仮想マシンとは何ですか?ほとんどの開発者は、継続的なデバッグ、つまり起動オプションの選択と値の割り当てを通じて JVM と対話し、悪名高い「メモリ不足」エラーを回避しながら Java プログラムの実行を高速化します。しかし、そもそも Java アプリケーションを実行するためになぜ JVM が必要なのか疑問に思ったことはありますか?
Java仮想マシンとは何ですか?
簡単に言えば、JVM は Java アプリケーションのバイトコードを実行し、そのバイトコードをハードウェアおよびオペレーティング システム固有の命令に変換するソフトウェア モジュールです。これにより、JVM では、最初に作成された Java プログラムを、元のコードを変更することなく、別の環境で実行できるようになります。 Java の移植性はエンタープライズ アプリケーション言語の鍵です。JVM が変換とプラットフォームの最適化を処理するため、開発者はさまざまなプラットフォームに合わせてアプリケーション コードを書き直す必要がありません。
JVM は基本的に、バイトコード命令マシンとして機能する仮想実行環境であり、基礎となる層と対話して実行タスクを割り当て、メモリ操作を実行するために使用されます。
JVM は、Java アプリケーションを実行するための動的なリソース管理も行います。これは、メモリの割り当てと解放をマスターし、各プラットフォームで一貫したスレッド モデルを維持し、CPU アーキテクチャに適した方法でアプリケーションが実行される実行可能命令を編成することを意味します。 JVM を使用すると、開発者はオブジェクトへの参照と、オブジェクトがシステム内に存在する必要がある期間を追跡する必要がなくなります。同様に、C のような非動的言語の問題点である、メモリをいつ解放するかを管理する必要もありません。
JVM は、Java を実行するために特別に設計されたオペレーティング システムと考えることができます。その役割は、Java アプリケーションの実行環境を管理することです。 JVM は基本的に、実行タスクを割り当ててメモリ操作を実行するためのバイトコード命令マシンとして基盤となる環境と対話する仮想実行環境です。
JVMコンポーネントの概要
JVM の内部構造とパフォーマンスの最適化について書かれた記事が数多くあります。このシリーズの基礎として、JVM コンポーネントを要約し、概要を説明します。この簡単な概要は、JVM を初めて使用する開発者にとって特に役立ち、その後のより詳細な説明についてさらに知りたくなるでしょう。
ある言語から別の言語へ - Java コンパイラーについて
コンパイラは 1 つの言語を入力として受け取り、別の実行可能ステートメントを出力します。 Java コンパイラには 2 つの主要なタスクがあります。
1. Java 言語の移植性が向上し、初めて作成するときに特定のプラットフォームに固定する必要がなくなりました。
2. 有効な実行可能コードが特定のプラットフォーム用に生成されていることを確認します。
コンパイラは静的または動的にすることができます。静的コンパイルの例は javac です。 Java コードを入力として受け取り、それをバイトコード (Java 仮想マシンで実行される言語) に変換します。静的コンパイラは入力コードを一度解釈し、プログラムの実行時に使用される実行形式を出力します。入力は静的であるため、常に同じ結果が表示されます。元のコードを変更して再コンパイルした場合にのみ、異なる出力が表示されます。
ジャストインタイム (JIT) コンパイラなどの動的コンパイラは、ある言語を別の言語に動的に変換します。つまり、コードの実行中に変換が行われます。 JIT コンパイラーを使用すると、コンパイラーの決定を使用して、手元の環境データを使用して、ランタイム分析を (パフォーマンスカウントを挿入することにより) 収集または作成できます。動的コンパイラーは、言語にコンパイルするプロセス中により適切な命令シーケンスを実装し、一連の命令をより効率的な命令に置き換え、さらには冗長な操作を排除することができます。時間が経つにつれて、より多くのコード構成データが収集され、より適切なコンパイルに関する決定が行われるようになります。このプロセス全体が、通常、コードの最適化と再コンパイルと呼ばれるものになります。
動的コンパイルには、動作に基づいた動的な変更に適応したり、アプリケーションのロード数が増加したときに新しい最適化を適用したりできるという利点があります。これが、動的コンパイラーが Java 操作に最適な理由です。動的コンパイラは外部データ構造、スレッド リソース、CPU サイクル分析と最適化を要求することに注意してください。最適化が深くなるほど、より多くのリソースが必要になります。ただし、ほとんどの環境では、最上位層によるパフォーマンスの向上はほとんどなく、純粋な解釈よりも 5 ~ 10 倍のパフォーマンスが向上します。
割り当てによりガベージコレクションが発生する
「Javaプロセスが割り当てたメモリアドレス空間」ごとに各スレッドに割り当てられるもので、Javaヒープと呼ばれたり、直接ヒープと呼ばれたりします。 Java の世界では、クライアント アプリケーションではシングル スレッド割り当てが一般的です。ただし、シングルスレッド割り当ては、今日のマルチコア環境の並列性を利用できないため、エンタープライズ アプリケーションやワークロード サーバーでは有益ではありません。
また、並列アプリケーション設計では、複数のスレッドが同時に同じアドレス空間を割り当てないように JVM に強制します。これは、割り当てられた領域全体にロックを設定することで制御できます。ただし、この手法 (ヒープ ロックと呼ばれることが多い) はパフォーマンスに非常に負荷がかかり、スレッドの保持またはキューイングがリソースの使用率やアプリケーションの最適化パフォーマンスに影響を与える可能性があります。マルチコア システムの良い点は、リソースやシリアル化の割り当て中にシングル スレッドのボトルネックを防ぐためのさまざまな新しい方法が必要になることです。
一般的なアプローチは、ヒープを複数の部分に分割することです。各パーティションはアプリケーションにとって適切なサイズになります。明らかに調整が必要です。割り当て率とオブジェクト サイズはアプリケーション間で大幅に異なり、同じスレッドの数も異なります。スレッド ローカル割り当てバッファ (TLAB)、またはスレッド ローカル領域 (TLA) は、スレッドが完全なヒープ ロックを宣言せずに自由に割り当てることができる特殊なパーティションです。領域がいっぱいになると、ヒープもいっぱいになります。これは、オブジェクトを配置するための十分な空き領域がヒープ上にないことを意味し、領域を割り当てる必要があります。ヒープがいっぱいになると、ガベージ コレクションが開始されます。
断片
TLAB を使用して例外をキャッチすると、ヒープが断片化され、メモリ効率が低下します。アプリケーションがオブジェクトを割り当てるときに TLAB スペースを増やすことができない、または完全に割り当てることができない場合、スペースが小さすぎて新しいオブジェクトを生成できないリスクがあります。このような空き領域は「断片化」と見なされます。アプリケーションがオブジェクトへの参照を保持し、残りのスペースを割り当てると、最終的にスペースは長期間にわたって空きます。
断片化とは、フラグメントがヒープ全体に散在し、未使用のメモリ領域の小さなセクションを通じてヒープ領域が無駄になることです。アプリケーションに「間違った」 TLAB スペースを割り当てると (オブジェクト サイズ、混合オブジェクト サイズ、参照保持率に関して)、ヒープの断片化が増加する原因になります。アプリケーションが実行されると、フラグメントの数が増加し、ヒープ内の領域を占有します。断片化によりパフォーマンスが低下し、システムは新しいアプリケーションに十分なスレッドとオブジェクトを割り当てることができなくなります。その場合、ガベージ コレクターはメモリ不足例外を防ぐことが困難になります。
TLAB 廃棄物は作業中に生成されます。断片化を完全または一時的に回避する 1 つの方法は、基礎となるすべての操作で TLAB スペースを最適化することです。このアプローチの一般的なアプローチは、アプリケーションに割り当て動作がある限り、アプリケーションを調整する必要があるというものです。これは、複雑な JVM アルゴリズムを通じて実現できます。もう 1 つの方法は、ヒープ パーティションを編成して、より効率的なメモリ割り当てを実現することです。たとえば、JVM は、特定のサイズの空きメモリ ブロックのリストとしてリンクされたフリー リストを実装できます。連続する空きメモリ ブロックは、同じサイズの別の連続するメモリ ブロックに接続されるため、それぞれが独自の境界を持つ少数のリンク リストが作成されます。場合によっては、フリーリストを使用するとメモリ割り当てが改善されることがあります。スレッドは同様のサイズのブロックにオブジェクトを割り当てることができるため、固定サイズの TLAB のみに依存する場合よりも断片化が少なくなる可能性があります。
GC トリビア
一部の初期のガベージ コレクターには複数の古い世代がありましたが、3 つ以上の古い世代があると、オーバーヘッドが値を上回ってしまいます。割り当てを最適化し、断片化を軽減するもう 1 つの方法は、いわゆる若い世代を作成することです。これは、新しいオブジェクトの割り当て専用のヒープ領域です。残りのヒープは、いわゆる古い世代になります。古い世代は、長期間存在すると想定されるオブジェクトには、ガベージ コレクションではないオブジェクトやラージ オブジェクトが含まれます。この割り当て方法をよりよく理解するには、ガベージ コレクションについての知識について説明する必要があります。
ガベージ コレクションとアプリケーションのパフォーマンス
ガベージ コレクションは、参照されていない占有ヒープ メモリを解放するための JVM のガベージ コレクターです。ガベージ コレクションが初めてトリガーされるとき、すべてのオブジェクト参照は引き続き保持され、以前の参照によって占められていた領域は解放または再割り当てされます。再利用可能なメモリがすべて収集された後、その領域が取得され、新しいオブジェクトに再び割り当てられるまで待機します。
ガベージ コレクターは参照オブジェクトを再宣言することはできません。再宣言すると、JVM 標準仕様に違反することになります。この規則の例外は、ガベージ コレクターのメモリが不足しそうな場合にキャッチされる可能性があるソフト参照または弱参照です。ただし、Java 仕様の曖昧さは誤解や使用上の誤りにつながるため、弱い参照は避けることを強くお勧めします。さらに、Java は動的メモリ管理向けに設計されているため、メモリをいつどこで解放するかを考える必要がありません。
ガベージ コレクターの課題の 1 つは、実行中のアプリケーションに影響を与えない方法でメモリを割り当てることです。ガベージ コレクションをできるだけ行わないと、アプリケーションはメモリを消費します。収集が多すぎると、スループットと応答時間が低下し、実行中のアプリケーションに悪影響を及ぼします。
GCアルゴリズム
ガベージ コレクション アルゴリズムにはさまざまなものがあります。いくつかの点については、このシリーズの後半で詳しく説明します。最も高いレベルでは、ガベージ コレクションの 2 つの主な方法は、参照カウントと追跡コレクターです。
参照カウント コレクターは、オブジェクトが指す参照の数を追跡します。オブジェクトの参照が 0 に達すると、メモリはすぐに再利用されます。これは、このアプローチの利点の 1 つです。参照カウントのアプローチの難しさは、循環データ構造とすべての参照をリアルタイムで更新し続けることにあります。
追跡コレクターは、まだ参照されているオブジェクトをマークし、マークされたオブジェクトを使用して、参照されているすべてのオブジェクトを繰り返し追跡し、マークします。まだ参照されているすべてのオブジェクトが「ライブ」としてマークされると、マークされていないすべてのスペースが再利用されます。このアプローチではリング データ構造が管理されますが、多くの場合、コレクターは、参照されていないメモリを再利用する前に、すべてのマーキングが完了するまで待機する必要があります。
上記の方法を実行するにはさまざまな方法があります。最も有名なアルゴリズムは、マーキングまたはコピー アルゴリズム、並列または同時実行アルゴリズムです。これらについては後の記事で説明します。
一般に、ガベージ コレクションの意味は、ヒープ内の新しいオブジェクトと古いオブジェクトにアドレス空間を割り当てることです。 「古いオブジェクト」とは、多くのガベージ コレクションを経て生き残ったオブジェクトです。新しい世代を使用して新しいオブジェクトを割り当て、古い世代を古いオブジェクトに割り当てます。これにより、メモリを占有する寿命の短いオブジェクトが迅速にリサイクルされ、それらが空間内の古い世代のアドレスに配置されます。これらすべてにより、存続期間の長いオブジェクト間の断片化が軽減され、断片化からヒープ メモリが節約されます。新しい世代のプラスの効果は、古い世代のオブジェクトのより高価なコレクションを遅らせ、一時的なオブジェクトに同じスペースを再利用できることです。 (存続期間の長いオブジェクトにはより多くの参照が含まれ、より多くの走査が必要になるため、古いスペースのコレクションにはコストがかかります。)
言及する価値のある最後のアルゴリズムはコンパクションです。これはメモリの断片化を管理する方法です。圧縮は基本的にオブジェクトを一緒に移動して、より大きな連続メモリ空間を解放します。ディスクの断片化とそれに対処するツールに精通している場合は、圧縮が Java ヒープ メモリで実行されることを除けば、圧縮がそれに非常に似ていることがわかるでしょう。圧縮については、このシリーズの後半で詳しく説明します。
概要: レビューとハイライト
JVM により、移植性 (一度プログラムすればどこでも実行できる) と動的なメモリ管理が可能になります。これらはすべて、Java プラットフォームの人気と生産性の向上に貢献する重要な機能です。
JVM パフォーマンス最適化システムに関する最初の記事では、コンパイラーがバイトコードをターゲット プラットフォームの命令言語に変換し、Java プログラムの実行を動的に最適化する方法を説明しました。アプリケーションごとに異なるコンパイラが必要になります。
また、メモリ割り当てとガベージ コレクション、およびそれらが Java アプリケーションのパフォーマンスにどのように関係するかについても簡単に説明しました。基本的に、ヒープを早くいっぱいにし、ガベージ コレクションをより頻繁にトリガーするほど、Java アプリケーションの使用率は高くなります。ガベージ コレクターにとっての 1 つの課題は、実行中のアプリケーションに影響を与えない方法で、アプリケーションがメモリ不足になる前にメモリを割り当てることです。今後の記事では、従来および新しいガベージ コレクションと JVM パフォーマンスの最適化について詳しく説明します。