【JVM系列1】深入分析Java虛擬機堆和棧及OutOfMemory異常產(chǎn)生原因

前言

JVM系列文章如無特殊說明,一些特性均是基于Hot Spot虛擬機和JDK1.8版本講述。

下面這張圖我想對于每個學習Java的人來說再熟悉不過了,這就是整個JDK的關系圖:


在這里插入圖片描述

從上圖我們可以看到,Java Virtual Machine位于最底層,所有的Java應用都是基于JVM來運行的,所以學習JVM對任何一個想要深入了解Java的人是必不可少的。

Java的口號是:Write once,run anywhere(一次編寫,到處運行)。之所以能實現(xiàn)這個口號的原因就是因為JVM的存在,JVM幫我們處理好了不同平臺的兼容性問題,只要我們安裝對應系統(tǒng)的JDK,就可以運行,而無需關心其他問題。

什么是JVM

JVM全稱Java Virtual Machine,即Java虛擬機,是一種抽象計算機。與真正的計算機一樣,它有一個指令集,并在運行時操作各種內存區(qū)域。虛擬機有很多種,不同的廠商提供了不同的實現(xiàn),只要遵循虛擬機規(guī)范即可。目前我們常說的虛擬機一般都指的是Hot Spot

JVM對Java編程語言一無所知,只知道一種特定的二進制格式,即類文件格式。類文件包含Java虛擬機指令(或字節(jié)碼)和符號表,以及其他輔助信息。也就是說,我們寫好的程序最終交給JVM執(zhí)行的時候會被編譯成為二進制格式

注意:Java虛擬機只認二進制格式文件,所以,任何語言,只要編譯之后的格式符合要求,都可以在Java虛擬機上運行,如Kotlin,Groovy等。

Java程序執(zhí)行流程

從我們寫好的.java文件到最終在JVM上運行時,大致是如下一個流程:


在這里插入圖片描述

一個java類在經(jīng)過編譯和類加載機制之后,會將加載后得到的數(shù)據(jù)放到運行時數(shù)據(jù)區(qū)內,這樣我們在運行程序的時候直接從JVM內存中讀取對應信息就可以了。

運行時數(shù)據(jù)區(qū)

運行時數(shù)據(jù)區(qū):Run-Time Data Areas。Java虛擬機定義了在程序執(zhí)行期間使用的各種運行時數(shù)據(jù)區(qū)域。其中一些數(shù)據(jù)區(qū)域是在Java虛擬機啟動時創(chuàng)建的,只在Java虛擬機退出時銷毀,這些區(qū)域是所有線程共享的,所以會有線程不安全的問題發(fā)生。而有一些數(shù)據(jù)區(qū)域為每個線程獨占的,每個線程獨占數(shù)據(jù)區(qū)域在線程創(chuàng)建時創(chuàng)建,在線程退出時銷毀,線程獨占的數(shù)據(jù)區(qū)就不會有安全性問題。

Run-Time Data Areas主要包括如下部分:pc寄存器,堆,方法區(qū),虛擬機棧,本地方法棧。

PC(program counter) Register(程序計數(shù)器)

PC Register是每個線程獨占的空間。
Java虛擬機可以支持同時執(zhí)行多個線程,而在任何一個確定的時刻,一個處理器只會執(zhí)行一個線程中的一個指令,又因為線程具有隨機性,操作系統(tǒng)會一直切換線程去執(zhí)行不同的指令,所以為了切換線程之后能回到原先執(zhí)行的位置,每個JVM線程都必須要有自己的pc(程序計數(shù)器)寄存器來獨立存儲執(zhí)行信息,這樣才能繼續(xù)之前的位置往后運行。

在任何時候,每個Java虛擬機線程都在執(zhí)行單個方法的代碼,即該線程的當前方法。如果該方法不是Native方法,則pc寄存器會記錄當前正在執(zhí)行的Java虛擬機指令的地址。如果線程當前執(zhí)行的方法是本地的,那么Java虛擬機的pc寄存器的值是Undefined。

Heap(堆)

堆是Java虛擬機所管理內存中最大的一塊,在虛擬機啟動時創(chuàng)建,被所有線程共享
堆在虛擬機啟動時創(chuàng)建,用于存儲所有的對象實例和數(shù)組(在某些特殊情況下不是)。

堆中的對象永遠不會顯式地釋放,必須由GC自動回收。所以GC也主要是回收堆中的對象實例,我們平常討論垃圾回收主要也是回收堆內存。

堆可以處于物理上不連續(xù)的內存空間,可以固定大小,也可以動態(tài)擴展,通過參數(shù)-Xms和Xmx兩個參數(shù)來控制堆內存的最小和最大值。

堆可能存在如下異常情況:

  • 如果計算需要的堆比自動存儲管理系統(tǒng)提供的堆多,將拋出OutOfMemoryError錯誤。

模擬堆內OutOfMemoryError

為了方便模擬,我們把堆固定一下大小,設置為:

-Xms20m -Xmx20m

然后新建一個測試類來測試一下:

package com.zwx.jvm.oom;

import java.util.ArrayList;
import java.util.List;

public class Heap {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
        while (true){
            list.add(99999);
        }
    }
}

輸出結果為(后面的Java heap space,表示堆空間溢出):

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3210)
    at java.util.Arrays.copyOf(Arrays.java:3181)

注意:堆不能設置的太小,太小的話會啟動失敗,如上我們把參數(shù)大小都修改為2m,會出現(xiàn)下面的錯誤:

Error occurred during initialization of VM
GC triggered before VM initialization completed. Try increasing NewSize, current value 1536K.

Method Area(方法區(qū))

方法區(qū)是各個線程共享的內存區(qū)域,在虛擬機啟動時創(chuàng)建。它存儲每個類的結構,比如:運行時常量池、屬性和方法數(shù)據(jù),以及方法和構造函數(shù)的代碼,包括在類和實例初始化以及接口初始化中使用的特殊方法。

方法區(qū)在邏輯上是堆的一部分,但是它卻又一個別名叫做Non-Heap(非堆),目的是與Java堆區(qū)分開來。
方法區(qū)域可以是固定大小,也可以根據(jù)計算的需要進行擴展,如果不需要更大的方法區(qū)域,則可以收縮。方法區(qū)域的內存不需要是連續(xù)的。

方法區(qū)中可能出現(xiàn)如下異常:

  • 如果方法區(qū)域中的內存無法滿足分配請求時,將拋出OutOfMemoryError錯誤。

Run-Time Constant Pool(運行時常量池)

運行時常量池是方法區(qū)中的一部分,用于存儲編譯生成的字面量符號引用。類或接口的運行時常量池是在Java虛擬機創(chuàng)建類或接口時構建的。

字面量

在計算機科學中,字面量(literal)是用于表達源代碼中一個固定值的表示法(notation)。幾乎所有計算機編程語言都具有對基本值的字面量表示,諸如:整數(shù)、浮點數(shù)以及字符串等。在Java中常用的字面量就是基本數(shù)據(jù)類型或者被final修飾的常量或者字符串等。

String字符串去哪了

字符串這里值得拿出來單獨解釋一下,在jdk1.6以及之前的版本,Java中的字符串就是放在方法區(qū)中的運行時常量池內,但是在jdk1.7和jdk1.8版本(jdk1.8之后本人沒有深入去了解過,所以不討論),將字符串常量池拿出來放到了堆(heap)里。
我們來通過一個例子來演示一下區(qū)別:

package com.zwx;

public class demo {
    public static void main(String[] args) {
        String str1 = new String("lonely") + new String("wolf");
        System.out.println(str1==str1.intern());
    }
}

這個語句的運行結果在不同的JDK版本中輸出的結果會不一樣:
JDK1.6中會輸出false:


在這里插入圖片描述

JDK1.7中輸出true:


在這里插入圖片描述

JDK1.8中也會輸出true:


在這里插入圖片描述
intern()方法
  • jdk1.6及之前的版本中:
    調用String.intern()方法,會先去常量池檢查是否存在當前字符串,如果不存在,則會在方法區(qū)中創(chuàng)建一個字符串,而new String("")方法創(chuàng)建的字符串在堆里面,兩個字符串的地址不相等,故而返回false。
  • 在jdk1.7及1.8版本中:
    字符串常量池從方法區(qū)中的運行時常量池移到了堆內存中,而intern()方法也隨之做了改變。調用String.intern()方法,首先還是會去常量池中檢查是否存在,如果不存在,那么就會創(chuàng)建一個常量,并將引用指向堆,也就是說不會再重新創(chuàng)建一個字符串對象了,兩者都會指向堆中的對象,所以返回true。
    不過有一點還是需要注意,我們把上面的構造字符串的代碼改造一下:
String str1 = new String("ja") + new String("va");
        System.out.println(str1==str1.intern());

這時候在jdk1.7和jdk1.8中也會返回false。
這個差異在《深入理解Java虛擬機》一書中給出的解釋是java這個字符串已經(jīng)存在常量池了,所以我個人的推測是可能初始化的時候jdk本身需要使用到java字符串,所以常量池中就提前已經(jīng)創(chuàng)建好了,如果理解錯了,還請大家指正,感謝!

new String(“l(fā)onely”)創(chuàng)建了幾個對象

上面的例子中我用了兩個new String(“l(fā)onely”)和new String(“wolf”)相加,而如果去掉其中一個new String()語句的話,那么實際上jdk1.7和jdk1.8中返回的也會是false,而不是true。
這是為什么?看下面(我們假設一開始字符串常量池沒有任何字符串):

  • 只執(zhí)行一個new String(“l(fā)onely”)會產(chǎn)生2個對象,1個在堆,1個在字符串常量池
在這里插入圖片描述

這時候執(zhí)行了String.intern()方法,String.intern()會去檢查字符串常量池,發(fā)現(xiàn)字符串常量池存在longly字符串,所以會直接返回,不管是jdk1.6還是jdk1.7和jdk1.8都是檢查到字符串存在就會直接返回,所以str1==str1.intern()得到的結果就是都是false,因為一個在堆,一個在字符串常量池。

  • 執(zhí)行new String(“l(fā)onely”)+new String(“wolf”)會產(chǎn)生5個對象,3個在堆,2個在字符串常量池


    在這里插入圖片描述

好了,這時候執(zhí)行String.intern()方法會怎么樣呢,如果在jdk1.7和jdk1.8會去檢查字符串常量池,發(fā)現(xiàn)沒有l(wèi)onelywolf字符串,所以會創(chuàng)建一個指向堆中的字符串放到字符串常量池:


在這里插入圖片描述

而如果是jdk1.6中,不會指向堆,會重新創(chuàng)建一個lonelywolf字符串放到字符串常量池,所以才會產(chǎn)生不同的運行結果。

注意:+號的底層執(zhí)行的是new StringBuild().append()語句,所以我們再看下面一個例子:

String s1 = new StringBuilder("aa").toString();
System.out.println(s1==s1.intern());
String s2 = new StringBuilder("aa").append("bb").toString();
System.out.println(s2==s2.intern());//1.6返回false,1.7和1.8返回true

這個在jdk1.6版本全部返回false,而在jdk1.7和jdk1.8中一個返回false,一個返回true。多了一個append相當于上面的多了一個+號,原理是一樣的。

符號引用

符號引用在下篇講述類加載機制的時候會進行解釋,這里暫不做解釋,感興趣的可以關注我,留意我的JVM系列下一篇文章

jdk1.7和1.8的實現(xiàn)方法區(qū)的差異

方法區(qū)是Java虛擬機規(guī)范中的規(guī)范,但是具體如何實現(xiàn)并沒有規(guī)定,所以虛擬機廠商完全可以采用不同的方式實現(xiàn)方法區(qū)的。

在HotSpot虛擬機中:

  • jdk1.7及之前版本

方法區(qū)采用永久代(Permanent Generation)的方式來實現(xiàn),方法區(qū)的大小我們可以通過參數(shù)-XX:PermSize和-XX:MaxPermSize來控制方法區(qū)的大小和所能允許最大值。

  • jdk1.8版本

移除了永久代,采用元空間(Metaspace)來實現(xiàn)方法區(qū),所以在jdk1.8中關于永久代的參數(shù)-XX:PermSize和-XX:MaxPermSize已經(jīng)被廢棄卻代之的是參數(shù)-XX:MetaspaceSize和-XX:MaxMetaspaceSize。元空間和永久代的一個很大的區(qū)別就是元空間已經(jīng)不在jvm內存在,而是直接存儲到了本地內存中。

如下,我們再jdk1.8中設置-XX:PermSize和-XX:MaxPermSize會給出警告:

Java HotSpot(TM) 64-Bit Server VM warning: ignoring option PermSize1m; support was removed in 8.0
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize1m; support was removed in 8.0

模擬方法區(qū)OutOfMemoryError

jdk1.7及之前版本

因為jdk1.7及之前都是永久代來實現(xiàn)方法區(qū),所以我們可以通過設置永久代參數(shù)來模擬內存溢出:
設置永久代最大為2M:

-XX:PermSize=2m -XX:MaxPermSize=2m

在這里插入圖片描述

然后執(zhí)行如下代碼:

package com.zwx;

import java.util.ArrayList;
import java.util.List;

public class demo {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        int i = 0;
        while (true){
            list.add(String.valueOf(i++).intern());
        }
    }
}

最后報錯OOM:PermGen space(永久代溢出)。

Error occurred during initialization of VM
java.lang.OutOfMemoryError: PermGen space
    at sun.misc.Launcher$ExtClassLoader.getExtClassLoader(Launcher.java:141)
    at sun.misc.Launcher.<init>(Launcher.java:71)
    at sun.misc.Launcher.<clinit>(Launcher.java:57)

jdk1.8

jdk1.8版本,因為永久代被取消了,所以模擬方式會不一樣。
首先引入asm字節(jié)碼框架依賴(前面介紹動態(tài)代理的時候提到cglib動態(tài)代理也是利用了asm框架來生成字節(jié)碼,所以也可以直接cglib的api來生成):

<dependency>
            <groupId>asm</groupId>
            <artifactId>asm</artifactId>
            <version>3.3.1</version>
        </dependency>

創(chuàng)建一個工具類去生成class文件:

package com.zwx.jvm.oom;

import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

import java.util.ArrayList;
import java.util.List;

public class MetaspaceUtil extends ClassLoader {

    public static List<Class<?>> createClasses() {
        List<Class<?>> classes = new ArrayList<Class<?>>();
        for (int i = 0; i < 10000000; ++i) {
            ClassWriter cw = new ClassWriter(0);
            cw.visit(Opcodes.V1_1, Opcodes.ACC_PUBLIC, "Class" + i, null,
                    "java/lang/Object", null);
            MethodVisitor mw = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>",
                    "()V", null, null);
            mw.visitVarInsn(Opcodes.ALOAD, 0);
            mw.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object",
                    "<init>", "()V");
            mw.visitInsn(Opcodes.RETURN);
            mw.visitMaxs(1, 1);
            mw.visitEnd();
            MetaspaceUtil test = new MetaspaceUtil();
            byte[] code = cw.toByteArray();
            Class<?> exampleClass = test.defineClass("Class" + i, code, 0, code.length);
            classes.add(exampleClass);
        }
        return classes;
    }
}

設置元空間大小

-XX:MetaspaceSize=5M -XX:MaxMetaspaceSize=5M 

在這里插入圖片描述

然后運行測試類模擬:

package com.zwx.jvm.oom;

import java.util.ArrayList;
import java.util.List;

public class MethodArea {
    public static void main(String[] args) {
        //jdk1.8
        List<Class<?>> list=new ArrayList<Class<?>>();
        while(true){
            list.addAll(MetaspaceUtil.createClasses());
        }
    }
}

拋出如下異常OOM:Metaspace:


在這里插入圖片描述

Java Virtual Machine Stacks(Java虛擬機棧)

每個Java虛擬機線程都有一個與線程同時創(chuàng)建的私有Java虛擬機棧。
Java虛擬機棧存儲棧幀(Frame)。每個被調用的方法就會產(chǎn)生一個棧幀,棧幀中保存了一個方法的狀態(tài)信息,如:局部變量,操作棧幀,方出出口等。

調用一個方法,就會產(chǎn)生一個棧幀,并壓入棧內;一個方法調用完成,就會把該棧幀從棧中彈出,大致調用過程如下圖所示:


在這里插入圖片描述

Java虛擬機棧中可能有下面兩種異常情況:

  • 如果線程執(zhí)行所需棧深度大于Java虛擬機棧深度,則會拋出StackOverflowError
    上圖可以知道,其實方法的調用就是入棧和出棧的過程,如果一直入棧而不出棧就容易發(fā)生異常(如遞歸)。
  • 如果Java虛擬機棧可以動態(tài)地擴展,但是擴展大小的時候無法申請到足夠的內存,則會拋出一個OutOfMemoryError。
    大部分Java虛擬機棧都是支持動態(tài)擴展大小的,也允許設置固定大小(在Java虛擬機規(guī)范中兩種都是可以的,具體要看虛擬機的實現(xiàn))。

注:我們經(jīng)常說的JVM中的棧,一般指的就是Java虛擬機棧。

模擬棧內StackOverflowError

下面是一個簡單的遞歸方法,沒有跳出遞歸條件:

package com.zwx.jvm.oom;

public class JMVStack {
    public static void main(String[] args) {
        test();
    }

    static void test(){
        test();
    }
}

輸出結果為:

Exception in thread "main" java.lang.StackOverflowError
    at com.zwx.jvm.oom.JMVStack.test(JMVStack.java:15)
    at com.zwx.jvm.oom.JMVStack.test(JMVStack.java:15)
    .....

Native Method Stacks(本地方法棧)

本地方發(fā)棧類似于Java虛擬機棧,區(qū)別就是本地方法棧存儲的是Native方法。本地方發(fā)棧和Java虛擬機棧在有的虛擬機中是合在一起的,并沒有分開,如:Hot Spot虛擬機。

本地方法棧可能出現(xiàn)如下異常:

  • 如果線程執(zhí)行所需棧深度大于本地方法棧深度,則會拋出StackOverflowError。
  • 如果可以動態(tài)擴展本地方法棧,但是擴展大小的時候無法申請到足夠的內存,則會拋出OutOfMemoryError。

總結

本文主要介紹了jvm運行時數(shù)據(jù)區(qū)的構造,以及每部分區(qū)域到底都存了哪些數(shù)據(jù),然后去模擬了一下常見異常的產(chǎn)生方式,當然,模擬異常的方式很多,關鍵要知道每個區(qū)域存了哪些東西,模擬的時候對應生成就可以。

本文主要從總體上介紹運行時數(shù)據(jù)區(qū),主要是有一個概念上的認識,下一篇,將會介紹類加載機制,以及雙親委派模式,介紹類加載模式的同時會對運行時數(shù)據(jù)區(qū)做更詳細的介紹。 請關注我,一起學習進步

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。