Java仮想マシンについての深い理解

高洛峰
リリース: 2017-03-18 17:52:03
オリジナル
2095 人が閲覧しました

JVM メモリ領域

プログラムを作成するとき、OOM (メモリ不足) やメモリ リークなどの問題に遭遇することがよくあります。これらの問題を回避するには、まず JVM のメモリ分割を具体的に理解する必要があります。 JVM は主に、メモリをメソッド領域、仮想マシン スタック、ローカル メソッド スタック、ヒープ、プログラム カウンターに分割します。 JVM ランタイム データ領域は次のとおりです:

Java仮想マシンについての深い理解

プログラム カウンター

プログラム カウンターはスレッドのプライベート領域であり、理解しやすいです~ もちろん、各スレッドには現在どの命令が実行されているかを記録するカウンターが必要です。実行されました。これは小さなメモリ空間を占有し、現在のスレッドによって実行されるバイトコードの行番号インジケーターとみなすことができます。スレッドが Java メソッドを実行している場合、このカウンタは実行中の仮想マシンのバイトコード命令のアドレスを記録します。スレッドがネイティブ メソッドを実行している場合、このカウンタの値は空 (未定義) です。このメモリ領域は、Java 仮想マシン仕様で OutOfMemoryError 条件が指定されていない唯一の領域です。

Java 仮想マシン スタック

プログラム カウンタと同様に、Java 仮想マシン スタックもスレッドプライベートです。そのライフサイクルはスレッドのライフサイクルと同じです。仮想マシンのスタックを理解するにはどうすればよいですか?本質的にはスタックです。内部に格納される要素はスタック フレームと呼ばれます。スタック フレームは非常に複雑に見えますが、実際は非常に単純です。関数のコンテキストを保存し、具体的には実行された関数の一部のデータを保存します。実行される関数に必要なデータは、ローカル変数テーブル (関数内の変数を保存)、オペランド スタック (エンジン計算の実行時に必要)、メソッド出口などです。

実行エンジンは関数を呼び出すたびに、この関数のスタック フレームを作成し、仮想マシン スタックに追加します。別の観点から理解すると、各関数は実際には呼び出しから実行終了までのスタック フレームのプッシュとポップに対応します。

この領域で発生する可能性のある 2 つの例外に注意してください。1 つは StackOverflowError であり、この例外は、現在のスレッドによって要求されたスタックの深さが仮想マシンで許可されている深さよりも大きい場合にスローされます。この種の例外の作成は簡単です。関数自体を繰り返し再帰すると、最終的にはスタック オーバーフロー エラー (StackOverflowError) が発生します。もう 1 つの例外は OutOfMemoryError 例外です。仮想マシン スタックを動的に拡張できる場合 (現在はほとんどの仮想マシンが可能です)、十分なメモリを適用できない場合は OutOfMemoryError がスローされます。「仮想マシン スタックを OOM にする方法」を参照してください。 :

public void stackLeakByThread(){  
  while(true){       
  new Thread(){         
     public void run(){                while(true){
                }
            }
        }.start()
    }
}
ログイン後にコピー

このコードは危険であり、オペレーティング システムをフリーズさせる可能性があります。注意して使用してください~~~

ローカル メソッド スタック

ローカル メソッド スタックと仮想マシン スタックの役割は非常に似ています。スタックは Java コードを実行するためのメソッドを提供し、ネイティブ メソッド スタックはネイティブ メソッドを提供します。仮想マシン スタックと同様に、ネイティブ メソッド スタックも StackOverflowError 例外と OutOfMemoryError 例外をスローします。

Java ヒープ

Java ヒープは、仮想マシン内の最大のメモリ部分であると言えます。すべてのスレッドで共有されるメモリ領域であり、ほぼすべてのインスタンス オブジェクトがこの領域に格納されます。もちろん、JIT コンパイラの発展に伴い、ヒープ上のすべてのオブジェクトの割り当ては徐々に「絶対的」ではなくなります。

Java ヒープは、ガベージ コレクターによって管理される主要な領域です。現在のコレクターは基本的に世代別コレクション アルゴリズムを使用するため、すべての Java ヒープは新世代と旧世代に再分割できます。詳細には、新世代は Eden スペース、From Survivor スペース、To Survivor スペースに分かれています。ヒープを拡張できなくなると、OutOfMemoryError 例外がスローされます。

メソッド領域

メソッド領域にはクラス情報、定数、静的変数などが格納されます。メソッド領域は、Java コードを記述するときに、各スレッド レベルで同じクラスの静的変数オブジェクトにアクセスできることを理解するのは簡単です。リフレクション機構を使用しているため、どのクラス情報が使用されなくなったかを仮想マシンが推測することが難しく、この領域を再利用することが困難です。さらに、この領域は主に定数プールのリサイクルのためのものであり、JDK1.7 では定数プールがヒープに移動されたことに注意してください。同様に、メソッド領域がメモリ割り当て要件を満たせない場合、OutOfMemoryError がスローされます。
メソッド領域のメモリ オーバーフローの作成。メソッド領域のオーバーフローは JDK1.6 以前のバージョンが原因であることに注意してください。実行前に、仮想マシンのパラメータを設定することでメソッド領域のサイズを制限できます。 XXpermSize と -XX:MaxPermSize。

List list =new ArrayList();
int i =0;while(true){    list.add(String.valueOf(i).intern());
}
ログイン後にコピー

実行後に java.lang.OutOfMemoryError:PermGen スペース例外がスローされます。
String の intern() 関数は、現在の文字列が定数プールに存在しない場合にそれを定数プールに入れることであることを説明します。上記のコードは文字列を定数プールに継続的に追加するため、最終的にメモリ不足が発生し、メソッド領域で OOM がスローされます。

上記のコードを JDK1.6 より前に実行する必要がある理由を以下で説明します。 JDK 1.7 以降、定数プールがヒープ領域に配置されたことは前述しましたが、その結果、 intern() 関数の異なる関数が作成されました。具体的には、次のコードを見てみましょう。

String str1 =new StringBuilder("hua").append("chao").toString();
System.out.println(str1.intern()==str1);

String str2=new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern()==str2);
ログイン後にコピー

这段代码在JDK1.6和JDK1.7运行的结果不同。JDK1.6结果是:false,false ,JDK1.7结果是true, false。原因是:JDK1.6中,intern()方法会吧首次遇到的字符串实例复制到常量池中,返回的也是常量池中的字符串的引用,而StringBuilder创建的字符串实例是在堆上面,所以必然不是同一个引用,返回false。在JDK1.7中,intern不再复制实例,常量池中只保存首次出现的实例的引用,因此intern()返回的引用和由StringBuilder创建的字符串实例是同一个。为什么对str2比较返回的是false呢?这是因为,JVM中内部在加载类的时候,就已经有”java”这个字符串,不符合“首次出现”的原则,因此返回false。

垃圾回收(GC)

JVM的垃圾回收机制中,判断一个对象是否死亡,并不是根据是否还有对象对其有引用,而是通过可达性分析。对象之间的引用可以抽象成树形结构,通过树根(GC Roots)作为起点,从这些树根往下搜索,搜索走过的链称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明这个对象是不可用的,该对象会被判定为可回收的对象。

那么那些对象可作为GC Roots呢?主要有以下几种:

1.虚拟机栈(栈帧中的本地变量表)中引用的对象。
2.方法区中类静态属性引用的对象。
3.方法区中常量引用的对象
4.本地方法栈中JNI(即一般说的Native方法)引用的对象。

另外,Java还提供了软引用和弱引用,这两个引用是可以随时被虚拟机回收的对象,我们将一些比较占内存但是又可能后面用的对象,比如Bitmap对象,可以声明为软引用货弱引用。但是注意一点,每次使用这个对象时候,需要显示判断一下是否为null,以免出错。

三种常见的垃圾收集算法

1.标记-清除算法

首先,通过可达性分析将可回收的对象进行标记,标记后再统一回收所有被标记的对象,标记过程其实就是可达性分析的过程。这种方法有2个不足点:效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量的不连续的内存碎片。

2.复制算法

为了解决效率问题,复制算法是将内存分为大小相同的两块,每次只使用其中一块。当这块内存用完了,就将还存活的对象复制到另一块内存上面。然后再把已经使用过的内存一次清理掉。这使得每次只对半个区域进行垃圾回收,内存分配时也不用考虑内存碎片情况。

但是,这代价实在是让人无法接受,需要牺牲一般的内存空间。研究发现,大部分对象都是“朝生夕死”,所以不需要安装1:1比例划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和一块Survivor空间,默认比例为Eden:Survivor=8:1.新生代区域就是这么划分,每次实例在Eden和一块Survivor中分配,回收时,将存活的对象复制到剩下的另一块Survivor。这样只有10%的内存会被浪费,但是带来的效率却很高。当剩下的Survivor内存不足时,可以去老年代内存进行分配担保。如何理解分配担保呢,其实就是,内存不足时,去老年代内存空间分配,然后等新生代内存缓过来了之后,把内存归还给老年代,保持新生代中的Eden:Survivor=8:1.另外,两个Survivor分别有自己的名称:From Survivor、To Survivor。二者身份经常调换,即有时这块内存与Eden一起参与分配,有时是另一块。因为他们之间经常相互复制。

3.标记-整理算法

标记整理算法很简单,就是先标记需要回收的对象,然后把所有存活的对象移动到内存的一端。这样的好处是避免了内存碎片。

类加载机制

类从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。

其中加载、验证、准备、初始化、和卸载这5个阶段的顺序是确定的。而解析阶段不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java的运行时绑定。

关于初始化:JVM规范明确规定,有且只有5中情况必须执行对类的初始化(加载、验证、准备自然再此之前要发生):
1.遇到new、getstatic、putstatic、invokestatic,如果类没有初始化,则必须初始化,这几条指令分别是指:new新对象、读取静态变量、设置静态变量,调用静态函数。
2.使用java.lang.reflect包的方法对类进行反射调用时,如果类没初始化,则需要初始化
3.当初始化一个类时,如果发现父类没有初始化,则需要先触发父类初始化。
4.当虚拟机启动时,用户需要制定一个执行的主类(包含main函数的类),虚拟机会先初始化这个类。
5.但是用JDK1.7启的动态语言支持时,如果一个MethodHandle实例最后解析的结果是REF_getStatic、REF_putStatic、Ref_invokeStatic的方法句柄时,并且这个方法句柄所对应的类没有进行初始化,则要先触发其初始化。

另外要注意的是:通过子类来引用父类的静态字段,不会导致子类初始化:

public class SuperClass{    public static int value=123;    static{
        System.out.printLn("SuperClass init!");
    }
}public class SubClass extends SuperClass{    static{
        System.out.println("SubClass init!");
    }


}public class Test{    public static void main(String[] args){
        System.out.println(SubClass.value);
    }
}
ログイン後にコピー

最后只会打印:SuperClass init!
对应静态变量,只有直接定义这个字段的类才会被初始化,因此通过子类类引用父类中定义的静态变量只会触发父类初始化而不会触发子类初始化。

通过数组定义来引用类,不会触发此类的初始化:

public class Test{    public static void main(String[] args){
        SuperClass[] sca=new SuperClass[10];
    }
}
ログイン後にコピー

常量会在编译阶段存入调用者的常量池,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类初始化,示例代码如下:

public class ConstClass{    public static final String HELLO_WORLD="hello world";    static {
        System.out.println("ConstClass init!");
    }

}public class Test{    public static void main(String[] args){

        System.out.print(ConstClass.HELLO_WORLD);
    }


}
ログイン後にコピー

上面代码不会出现ConstClass init!

加载

加载过程主要做以下3件事
1.通过一个类的全限定名称来获取此类的二进制流
2.强这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。

验证

这个阶段主要是为了确保Class文件字节流中包含信息符合当前虚拟机的要求,并且不会出现危害虚拟机自身的安全。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都在方法区中分配。首先,这个时候分配内存仅仅包括类变量(被static修饰的变量),而不包括实例变量。实例变量会在对象实例化时随着对象一起分配在java堆中。其次这里所说的初始值“通常情况下”是数据类型的零值,假设一个类变量定义为

public static int value=123;

那变量value在准备阶段后的初始值是0,而不是123,因为还没有执行任何Java方法,而把value赋值为123是在程序编译后,存放在类构造函数< clinit >()方法中。
解析

解析阶段是把虚拟机中常量池的符号引用替换为直接引用的过程。

初始化

类初始化时类加载的最后一步,前面类加载过程中,除了加载阶段用户可以通过自定义类加载器参与以外,其余动作都是虚拟机主导和控制。到了初始化阶段,才是真正执行类中定义Java程序代码。

准备阶段中,变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划初始化类变量。初始化过程其实是执行类构造器< clinit >()方法的过程。

< clinit >()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的。收集的顺序是按照语句在源文件中出现的顺序。静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量可以赋值,但不能访问。如下所示:

public class Test{    static{
        i=0;//給变量赋值,可以通过编译
        System.out.print(i);//这句编译器会提示:“非法向前引用”
    }    static int i=1;

}
ログイン後にコピー

()方法与类构造函数(或者说实例构造器())不同,他不需要显式地调用父类构造器,虚拟机会保证子类的()方法执行之前,父类的()已经执行完毕。

相关文章:

Java虚拟机的具体详解

Java虚拟机学习 - 类加载机制

Java虚拟机学习 - 对象内存分配与回收

Java虚拟机学习 - 对象访问

関連ラベル:
ソース:php.cn
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
最新の問題
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート
私たちについて 免責事項 Sitemap
PHP中国語ウェブサイト:福祉オンライン PHP トレーニング,PHP 学習者の迅速な成長を支援します!