Java を体系的に学び始めるとき、JVM は避けて通れない部分です。どの国に行ったことがあるか、JVM が作成したオブジェクトではないものはありますか?以前の学習で多くの JVM 関連の内容に出会いましたが、ただ理解しただけで、深く学ぶことはありませんでした。このブログでは、JVM の学習過程を記録します。小部分の内容はインターネットから引用したものです。大部分はさまざまな技術ブログや『Java 仮想機械の深い理解』を調べた後、私自身の理解をもとに述べたものです。
Java クラスのロードプロセス#
プログラムが実行されるときにクラスが必要な場合、メモリ内にそのクラスが見つからないと、JVM は 3 つのステップを通じてクラスをメモリにロードします。それは、クラスのロード、接続、初期化です。
ロード#
まずはクラスのロードです。仮想マシンは ClassLoader を使用してバイトコードファイル(.class)をメモリ形式の Class オブジェクトに変換します。ここでのバイトコードファイルは、ローカルの.class ファイルであったり、jar パッケージ内のファイルであったり、リモートサーバーが提供するバイトストリームであったりします。本質的には byte [] です。一般的な ClassLoader には、BootStrapClassLoader、ExtensionClassLoader、AppClassLoader の 3 つがあります。それぞれが異なる場所のクラスをロードする役割を持っています。
-
BootStrapClassLoader は JVM のコアクラスをロードし、JAVA_HOME/lib/rt.jar にあります。通常、java.* の下のクラスは彼がロードします。
-
ExtensionClassLoader は拡張クラスをロードし、JAVA_HOME/lib/ext/*.jar にあります。
-
AppClassLoader は私たちが自分で書いたクラスをロードします。彼は ClassPath 内の jar パッケージと私たちが書いてコンパイルした class ファイルをロードします。
-
カスタムクラスローダーは特定のクラスをロードするために使用されます。例えば、ネットワーク経由で送られた暗号化されたバイトコードファイルを解析してロードするためにカスタムクラスローダーが必要です。URLClassLoader クラスを直接継承できます。
親クラス委任メカニズム#
まずは Java ソースコードの ClassLoader クラスの loadclass () の一部を見てみましょう。私の拙い翻訳を添えて。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 最初に、クラスがすでにロードされているかどうかを確認します
//まず、チェックしてこのクラスがすでにロードされているかどうか
Class<?> c = findLoadedClass(name);
//もしロードされていなければ
if (c == null) {
long t0 = System.nanoTime();
try {
//もし親クラスローダーが空でなければ
if (parent != null) {
//親クラスローダーによってロードされます、
// 親クラス委任メカニズムのポイント
c = parent.loadClass(name, false);
} else {
//親クラスローダーが空であれば、終わりです。BootStrapClassLoaderを選択します
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// クラスが見つからない場合はClassNotFoundExceptionがスローされます
// nullでない親クラスローダーから
}
if (c == null) {
// まだ見つからない場合は、findClassを呼び出して
// クラスを見つけます。
long t1 = System.nanoTime();
//カスタムローダーに相当し、overrideされたメソッド
c = findClass(name);
// これは定義されたクラスローダーです; 統計を記録します
//クラスのロードを開始します sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
このコードを理解すれば、親クラス委任メカニズムが何であるかがわかります。クラスをロードする必要があるとき、最初にそれがすでにロードされているかどうかを判断し、重複ロードを防ぎます。ロードされていない場合は、親クラスローダーに委任します。これは再帰関数であり、親クラスローダーに調べてもらうと、すでにロードされているかどうかを判断します。未ロードの場合はさらに上に委任します。委任が行き詰まると、親クラスローダーがなくなり、BootStrapClass に処理を委ねます。BootStrapClass がロードされると戻りますが、まだロードされていない場合は、「子」に戻ってロードを行います。ロードが完了するまで続きます。最後に AppClassLoader またはカスタムローダーが成功しなかった場合は、ClassNotFound 例外がスローされます。注意すべき点は 2 つあります。実際には、各ローダーがロードできない場合は ClassNotFound を返しますが、catch されており、例外処理はありません。もう一つは、最後のカスタムクラスローダーの findClass は実装されていないことです。ExtensionClassLoader と AppClassLoader にとっては、ClassNotFound をスローします。
接続#
次のステップは接続です。接続は 3 つのステップに分かれます:検証、準備、解析;
検証#
検証は接続の第一歩であり、この段階では class ファイルのバイトストリームに含まれる情報が現在の仮想マシンの要求に適合していることを確認し、仮想マシン自身の安全を害さないことを保証します。特に、自分で書いていないクラス、ネットワークから送られてくるものに対しては、検証を行います。
- ファイル形式の検証
- メタデータの検証
- バイトコードの検証
- シンボル参照の検証
準備#
バイトコードファイルの検証が完了すると、JVM はクラス変数のメモリを割り当て、初期化を開始します。ここで注意すべき 2 つの重要な点があります:
-
メモリ割り当てのオブジェクト:Java の変数には「クラス変数」と「クラスメンバー変数」の 2 種類があります。「クラス変数」とは static で修飾された変数を指し、他のすべての種類の変数は「クラスメンバー変数」に属します。準備段階では、JVM は「クラス変数」のみのメモリを割り当て、「クラスメンバー変数」のメモリは初期化段階まで待つ必要があります。
-
初期化の種類:準備段階では、JVM はクラス変数にメモリを割り当て、初期化します。しかし、ここでの初期化は、変数に Java 言語でのそのデータ型のゼロ値を割り当てることを指し、ユーザーコード内で初期化された値ではありません。
public static int x = 666; //この時点ではintは0にしか割り当てられません;666は初期化まで待つ必要があります public final static int y = 666; //定数であればここで666に割り当てられます
解析#
解析段階は、仮想マシンが定数プール内のシンボル参照を直接参照に置き換えるプロセスです。解決アクションは、クラスまたはインターフェース、フィールド、クラスメソッド、インターフェースメソッド、メソッドタイプ、メソッドハンドル、呼び出し点修飾子の 7 種類のシンボル参照に対して行われ、これらはそれぞれ定数プールの CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_IntrfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_InvokeDynamic_info の 7 種類の定数タイプに対応します。解析段階での直接参照とシンボル参照の関係は以下の通りです:
-
シンボル参照 (Symbolic References): シンボル参照は、参照される対象を記述するために一組のシンボルを使用します。シンボルは任意の形式のリテラルであり、使用時に対象を明確に特定できる限り、シンボル参照はメモリに必ずしも存在する必要はありません。
-
直接参照 (Direct References): 直接参照は、対象を指すポインタ、相対オフセット、または対象を間接的に特定できるハンドルです。直接参照は仮想マシンのメモリレイアウトに関連しており、同じシンボル参照が異なる仮想マシンインスタンスで翻訳されると、一般的に直接参照は異なります。直接参照が得られた場合、参照の対象は必ずメモリ内に存在します。
初期化#
この段階に達すると、私たちが書いたコードが実際に実行され始めます。JVM は文の実行順序に従ってクラスオブジェクトを初期化します。コードの観点から見ると、初期化段階はクラスコンストラクタ() メソッドを実行するプロセスです。仮想マシンの仕様では、初期化をトリガーするのは 5 つの状況のみです:
- new、getstatic、putstatic、または invokestatic の 4 つのバイトコード命令に遭遇したとき(注意:newarray 命令がトリガーするのは配列タイプ自体の初期化のみであり、その関連タイプの初期化は引き起こしません。例えば、new String [] は String [] クラスの初期化を直接トリガーしますが、String クラスの初期化はトリガーしません)。クラスが初期化されていない場合は、まず初期化を行う必要があります。
一般的なシナリオは、オブジェクトを new すること、静的オブジェクトのフィールドまたはメソッドを読み取ることです。 - java.lang.reflect パッケージのメソッドを使用してクラスをリフレクション呼び出しする場合、クラスが初期化されていない場合は、まず初期化をトリガーする必要があります。
- クラスを初期化する際に、その親クラスがまだ初期化されていない場合は、まず親クラスの初期化をトリガーする必要があります。
- 仮想マシンが起動するとき、ユーザーは実行するメインクラス(main () メソッドを含むクラス)を指定する必要があります。仮想マシンはまずこのメインクラスを初期化します。
- jdk1.7 の動的言語サポートを使用する場合、java.lang.invoke.MethodHandle インスタンスの最終的な解析結果が REF_getstatic、REF_putstatic、REF_invokeStatic のメソッドハンドルであり、このメソッドハンドルに対応するクラスが初期化されていない場合は、まず初期化をトリガーする必要があります。
クラスの初期化段階は、クラスロードプロセスの最後のステップです。前のクラスロードプロセスでは、ロード段階でユーザーアプリケーションがカスタムクラスローダーを介して参加できる以外のすべてのアクションは、仮想マシンが主導し制御します。初期化段階に達すると、クラス内で定義された Java プログラムコード(バイトコード)が実際に実行され始めます。
準備段階では、変数はすでにシステムが要求する初期値(ゼロ値)を一度割り当てられていますが、初期化段階では、プログラマーがプログラムを通じて定めた主観的な計画に従ってクラス変数や他のリソースを初期化します。より直接的に言えば、初期化段階はクラスコンストラクタ() メソッドを実行するプロセスです。() メソッドは、コンパイラがクラス内のすべてのクラス変数の割り当てアクションと静的文を含む static {} ブロック内の文を収集して合成したもので、コンパイラが収集する順序はソースファイル内での文の出現順序によって決まります。静的文ブロックは、静的文ブロックの前に定義された変数にのみアクセスでき、後に定義された変数は、前の静的文ブロックで割り当てることはできますが、アクセスすることはできません。
public class Test{
static{
i=0;
System.out.println(i);//エラー:定義される前にフィールドを参照できません(不正な前方参照)
}
static int i=1;
}
クラスコンストラクタ () はインスタンスコンストラクタ () とは異なり、プログラマーが明示的に呼び出す必要はありません。仮想マシンは、サブクラスのクラスコンストラクタ () が実行される前に、親クラスのクラスコンストラクタ () が完了することを保証します。親クラスのコンストラクタ () が先に実行されるため、親クラスで定義された静的文ブロック / 静的変数の初期化は、サブクラスの静的文ブロック / 静的変数の初期化よりも優先されて実行されます。特に、クラスコンストラクタ () はクラスまたはインターフェースにとって必須ではありません。クラスに静的文ブロックがなく、クラス変数の割り当て操作もない場合、コンパイラはそのクラスのクラスコンストラクタ () を生成しないことができます。
仮想マシンは、クラスのクラスコンストラクタ () がマルチスレッド環境で正しくロックされ、同期されることを保証します。複数のスレッドが同時にクラスを初期化しようとすると、1 つのスレッドだけがそのクラスのクラスコンストラクタ () を実行し、他のスレッドはブロックされて待機する必要があります。特に注意すべき点は、この状況下で他のスレッドはブロックされますが、実行 () メソッドのスレッドが終了した後、他のスレッドが再び実行 () メソッドに入ることはないということです。同じクラスローダーの下では、1 つのタイプは一度だけ初期化されます。
私たちは、Java でオブジェクトを作成する際に、通常次のようなプロセスを経ることを知っています:親クラスのクラスコンストラクタ () -> サブクラスのクラスコンストラクタ () -> 親クラスのメンバー変数とインスタンスコードブロック -> 親クラスのコンストラクタ -> サブクラスのメンバー変数とインスタンスコードブロック -> サブクラスのコンストラクタ。
上記の部分は『Java 仮想機械の深い理解』周志明著から引用しています。
JVM 実行時のデータ領域#
JDK8 以前のメモリ領域図は以下の通りです:
JDK8 以降のメモリ領域:
以下では、上記の図に基づいて各データ領域を解析し、なぜ変更されたのかを説明します。
- プログラムカウンタ:本来は非常に小さな領域で、スレッド専用です。コンテキストスイッチを行う際に、スレッドが実行しているコードの位置を記録していると理解できます。複数のスレッドが指令を実行している場合、各スレッドにはプログラムカウンタがあり、スレッド専用です。任意の時点で、スレッドは 1 つのメソッドのコードのみを実行できます。Java メソッドの命令を実行するたびに、プログラムカウンタは現在実行中のバイトコードのアドレスを保存します。もし実行しているのがネイティブメソッドであれば、PC の値は未定義です。CPU 内のレジスタのプログラムカウンタに似ています。
- Java 仮想マシンスタック:これもスレッド専用です。Java メソッドの実行メモリモデルを記述します。各メソッドが実行されるときにスタックフレームが作成され、局所変数テーブル、オペランドスタック、動的リンク、メソッド出口などの情報が保存されます。各メソッドの実行プロセスは、スタックフレームのプッシュとポップのプロセスです。仮想マシンスタックは 2 つの異常状態を規定しています:スレッドが要求するスタックの深さが仮想マシンが許可する深さを超えると、StackOverflowError 例外がスローされます。仮想マシンスタックが動的に拡張できる場合(現在のほとんどの Java 仮想マシンは動的に拡張可能です)、拡張時に十分なメモリを確保できない場合、OutOfMemoryError 例外がスローされます。
- ネイティブメソッドスタック:仮想マシンスタックに似ており、仮想マシンが使用するネイティブメソッドにサービスを提供します。HotSpot JVM では、Java 仮想マシンスタックとネイティブメソッドスタックが一体化されています。
- Java ヒープ:スタックとは異なり、Java ヒープはすべてのスレッドが共有する領域で、ほとんどすべてのオブジェクトインスタンスがここでメモリを割り当てられます。したがって、ほとんどの状況では、ヒープは最大のメモリ領域でもあります。メモリ回収の観点から見ると、現在の収集器は基本的に世代収集アルゴリズムを採用しているため、Java ヒープはさらに細分化できます:新生代と老年代;さらに細かく分けると、Eden 空間、From Survivor 空間、To Survivor 空間などがあります。GC については後で詳しく説明します。
- メソッド領域(または永続領域):すべてのスレッドが共有し、特別なヒープ構造です。しかし、JVM 仕様では(Non-Heap)として記述されています。これは、仮想マシンがロードしたクラス情報、定数、静的変数、JIT コンパイラによってコンパイルされたコードなどのデータを保存するために使用されます。Java ヒープの論理部分と理解できます。JDK1.7 以前の HotSpot JVM では、メソッド領域は永続世代(PermGen)にありました。永続世代内でメモリリークやオーバーフローなどの問題が発生する可能性があるため、java.lang.OutOfMemoryError: PermGen が発生します。JEP チームは JDK1.7 から永続世代の削除を計画し、JDK1.7 では文字列定数、シンボル参照などを永続世代から移動しました。Java 8 では、永続世代は完全に JVM から削除され、メタスペース(Metaspace)に置き換えられました。
- 実行時定数プール:実行時定数プール(Runtime Constant Pool)はメソッド領域の一部です。Class ファイルには、クラスのバージョン、フィールド、メソッド、インターフェースなどの記述情報の他に、定数プール(Constant Pool Table)という情報があり、コンパイル時に生成されたさまざまなリテラルやシンボル参照を保存するために使用されます。この部分は、クラスがロードされた後にメソッド領域の実行時定数プールに保存されます。例えば、String クラスの intern () です。
https://www.cnblogs.com/czwbig/p/11127124.html
https://www.sczyh30.com/posts/Java/jvm-memory/
GC#
Garbage Collection、ガーベジコレクション。Java は C++ に比べて、主な違いの一つは Java にガーベジコレクションメカニズムがあることです。一般的には、プログラマーが手動でメモリを解放する必要はありません。
ガーベジコレクションの考え方には主に 2 つの方法があります:
-
参照カウント法:この考え方は、オブジェクトに参照カウンターを与え、参照されるたびに + 1 し、参照がなくなると - 1 します。カウンターが 0 になったとき、現在のオブジェクトは不要であり、GC されることができます。しかし、この考え方には重大な欠陥があり、循環参照の問題を解決できません。A が B を参照し、B が A を参照している場合、これらの 2 つのオブジェクトが不要なとき、GC は判断できません。そのため、メモリの浪費が発生します。
-
到達可能性分析:この考え方は、一組のオブジェクトをルートノードとして扱い、ルートノードから探索を開始します。探索が終了した後、到達できないオブジェクトがあれば、そのオブジェクトは使用されていないと見なされ、GC されることができます。JVM の GC はこの考え方を使用しています。
では、どのオブジェクトがルートノード(GC Root)になれるのでしょうか? -
生存しているスレッド
-
仮想マシンスタックの局所変数テーブルで参照されているオブジェクト。
-
メソッド領域の静的属性や定数が参照するオブジェクト。
-
ネイティブメソッドスタックの JNI が参照する変数。
Java 中の四種類の参照 | 強弱ソフト虚#
StrongReference
StrongReference(強参照)は最も一般的な参照タイプであり、強参照が存在する限り、GC はガーベジコレクションを行いません。
SoftReference
SoftReference(ソフト参照)は、有用だが必須ではないオブジェクトを記述するために使用されます。オブジェクトがソフト参照のみを持つ場合、メモリ空間が十分であれば、ガーベジコレクタはそのメモリを回収しません。メモリ空間が不足している場合、これらのオブジェクトのメモリが回収されます。ガーベジコレクタがそれを回収しない限り、そのオブジェクトはプログラムで使用できます。ソフト参照は、参照キュー(ReferenceQueue)と組み合わせて使用することができます。ソフト参照が参照するオブジェクトがガーベジコレクタによって回収されると、JVM はこのソフト参照を関連付けられた参照キューに追加します。ソフト参照は、メモリに敏感なキャッシュを実現するために使用できます。
WeakReference
WeakReference(弱参照)は、ソフト参照よりも短いライフサイクルを持つ参照です。GC スキャンが開始されると、弱参照のみを持つオブジェクトがスキャンされると、メモリが十分であっても GC が実行されますが、GC スレッドの優先度が低いため、これらの弱参照オブジェクトを迅速に発見できるとは限りません。弱参照も参照キューと組み合わせて使用できます。
WeakReference は Android でよく使用されます。
PhantomReference
PhantomReference(ファントム参照)は、他の 3 つの参照とは異なり、ファントム参照はオブジェクトのライフサイクルに影響を与えず、ファントム参照を通じてオブジェクトのインスタンスを取得することはできません。オブジェクトがファントム参照のみを持つ場合、それはまるで参照がないのと同じで、いつでもガーベジコレクタによって回収される可能性があります。ファントム参照は、オブジェクトがガーベジコレクタによって回収される活動を追跡するために主に使用され、参照キューと組み合わせて使用する必要があります。
~ 正直なところ、現在この設計の目的は理解していませんし、使用したこともありません~
GC アルゴリズム#
GC アルゴリズムには主に 3 つの種類があり、それぞれ MS(マーク&スイープ法)、MC(マーク&コンパクト法)、Copying(コピー法)です。
-
MS アルゴリズム:Marked-Sweep、マーク&スイープアルゴリズム。非常にシンプルで、到達可能性分析により到達できないオブジェクトをマークし、クリーンアップして削除します。主に老年代で使用されます。このアルゴリズムの主な欠点は 2 つあります。
- マークとクリーンアップの効率が低い
- メモリの断片化が発生しやすく、大きなオブジェクトが来たときに十分なメモリがないと GC がトリガーされます。
-
MC アルゴリズム:Marked-Compact、マーク&コンパクトアルゴリズム。到達可能性分析により到達できないオブジェクトをマークした後、生存しているオブジェクトを一方に移動(再配置)し、残りの部分を回収します。このプロセスではリマッピングが必要です。主に老年代で使用されます。
-
Copying(コピー)アルゴリズム:メモリを 2 つのブロックに分割し、毎回 1 つのブロックのみを使用します。回収時には、すべての生存オブジェクトを別のブロックに移動します。その後、以前のブロック全体をクリアします。新生代ではこのアルゴリズムが使用されます。
現在の JVM の GC は一般的に世代収集を行い、いくつかのガーベジコレクションアルゴリズムを組み合わせています。
ここで、JVM メモリの世代モデルを分析します:
図からわかるように、JVM はメモリ領域を新生代、老年代、永続代(すなわちメソッド領域、JDK1.8 以降は削除されました)に分けています。新生代は Eden 世代と 2 つのサバイバー世代に分かれています。オブジェクトが新生代でデフォルトで 15 回の GC を経て生存すると、老年代に移されます。大きなオブジェクトも直接老年代に移動できます。これらはすべて設定可能です。なぜ 1 つの E と 2 つの S が必要かというと、主にメモリの断片化を避けるためです。毎回 1 つの E と 1 つの S を使用します。GC を行うたびに、E と S0 の生存オブジェクトを S1 に移動し、その後 E と S0 をクリアします。次に、S1 と S0 は論理的に入れ替わります。これにより、メモリの断片化が発生するのを防ぎます。HotSpot JVM のデフォルト比率は 8:2 です。このようにして、デフォルトの空間利用率は 90% に達します。
PS:FULL GC が発生すると、JVM はすべての実行中のスレッドを安全に一時停止(Stop The World)してメモリ空間を回収します。この時間内、ガーベジコレクション以外のすべての Java プログラムやコードは静止し、システム上では大幅に応答が遅くなったり、フリーズしたりする状態が発生します。『Java 仮想機械の深い理解』- 周志明
GC の実装–ガーベジコレクタ#
- 新生代コレクタ:Serial、ParNew、Parallel Scavenage
- 老年代コレクタ:Serial old、Parallel Old、CMS
- 全ヒープ回収:G1
まず、JVM のモードには 2 つの種類があります。Server(重量)と Client(軽量)
Serial:単一スレッドで、シンプルで効率的。Stop the World。Client モードの JVM を使用します。
ParNew:Serial のマルチスレッドバージョンで、Server モードを使用します。並行マルチスレッド。
Parallel Scavenage:スループットに密接に関連しているため、スループット優先コレクタとも呼ばれます。このコレクタの目標は、制御可能なスループットを達成することです。並行マルチスレッド。
Serial Old:Serial の老年代バージョンで、M-C ガーベジコレクションアルゴリズムを使用します。
Parallel Old:Parallel Scavenage の老年代バージョン。マルチスレッドで、M-C ガーベジコレクションアルゴリズムを使用します。高スループットと CPU リソースに敏感な状況で使用されます。
CMS コレクタ:Concurrent-Marked-Sweep、最短回収停止時間を目指すコレクタ。CMS コレクタのメモリ回収プロセスは、ユーザースレッドと並行して実行されます。HotSpot 仮想マシンの最初の本当の意味での並行コレクタであり、ガーベジコレクションスレッドとユーザースレッドが(基本的に)同時に作業することを初めて実現しました。
G1 コレクタ:Garbage-First は、サーバーアプリケーション向けのガーベジコレクタです。詳細については、Jvm ガーベジコレクタ(最終章)を参照してください。