JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

文章來源:https://blog.csdn.net/zwx900102/article/details/107876856

原文作者:雙子孤狼

來源平臺:CSDN

前言

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

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

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

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

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


什麼是JVM

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

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

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


Java程序執行流程

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

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

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


運行時數據區

運行時數據區:Run-Time Data Areas。Java虛擬機定義了在程序執行期間使用的各種運行時數據區域。其中一些數據區域是在Java虛擬機啟動時創建的,只在Java虛擬機退出時銷燬,這些區域是所有線程共享的,所以會有線程不安全的問題發生。而有一些數據區域為每個線程獨佔的,每個線程獨佔數據區域在線程創建時創建,在線程退出時銷燬,線程獨佔的數據區就不會有安全性問題。

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


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

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

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


Heap(堆)

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

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

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

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

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

模擬堆內OutOfMemoryError

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

<code>-Xms20m -Xmx20m/<code>

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

<code>package com.zwx.jvm.oom;

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

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

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

<code>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)
123/<code>

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

<code>Error occurred during initialization of VM
GC triggered before VM initialization completed. Try increasing NewSize, current value 153/<code>

Method Area(方法區)

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

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

方法區中可能出現如下異常:

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

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

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

字面量

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

String字符串去哪了

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

<code>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());
    }
}/<code>

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

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JDK1.7中輸出true:

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JDK1.8中也會輸出true:

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

intern()方法

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

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

new String(“lonely”)創建了幾個對象

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

  • 只執行一個new String(“lonely”)會產生2個對象,1個在堆,1個在字符串常量池
JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

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

  • 執行new String(“lonely”)+new String(“wolf”)會產生5個對象,3個在堆,2個在字符串常量池
JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

好了,這時候執行String.intern()方法會怎麼樣呢,如果在jdk1.7和jdk1.8會去檢查字符串常量池,發現沒有lonelywolf字符串,所以會創建一個指向堆中的字符串放到字符串常量池:

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

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

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

<code>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
1234/<code>

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


符號引用

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

jdk1.7和1.8的實現方法區的差異

方法區是Java虛擬機規範中的規範,但是具體如何實現並沒有規定,所以虛擬機廠商完全可以採用不同的方式實現方法區的。

在HotSpot虛擬機中:

  • jdk1.7及之前版本

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

  • jdk1.8版本

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

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

<code>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/<code>

模擬方法區OutOfMemoryError

jdk1.7及之前版本

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

<code>-XX:PermSize=2m -XX:MaxPermSize=2m/<code>
JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

然後執行如下代碼:

<code>package com.zwx;

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

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

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

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

jdk1.8

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

<code>
            asm
            asm
            3.3.1
        
/<code>

創建一個工具類去生成class文件:

<code>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> createClasses() {
        List> classes = new ArrayList>();
        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, "",
                    "()V", null, null);
            mw.visitVarInsn(Opcodes.ALOAD, 0);
            mw.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object",
                    "", "()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;
    }
}/<code>

設置元空間大小

<code>-XX:MetaspaceSize=5M -XX:MaxMetaspaceSize=5M 
1/<code>
JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

然後運行測試類模擬:

<code>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> list=new ArrayList>();
        while(true){
            list.addAll(MetaspaceUtil.createClasses());
        }
    }
}
1234567891011121314/<code>

拋出如下異常OOM:Metaspace:

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

Java Virtual Machine Stacks(Java虛擬機棧)

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

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

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

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

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

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

模擬棧內StackOverflowError

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

<code>package com.zwx.jvm.oom;

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

    static void test(){
        test();
    }
}
1234567891011/<code>

輸出結果為:

<code>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)
	.....
1234/<code>

Native Method Stacks(本地方法棧)

本地方發棧類似於Java虛擬機棧,區別就是本地方法棧存儲的是Native方法。本地方發棧和Java虛擬機棧在有的虛擬機中是合在一起的,並沒有分開,如:Hot Spot虛擬機。

本地方法棧可能出現如下異常:

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

總結

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

本文主要從總體上介紹運行時數據區,主要是有一個概念上的認識,下一篇,將會介紹類加載機制,以及雙親委派模式,介紹類加載模式的同時會對運行時數據區做更詳細的介紹。

目前在職Java開發,如果你現在也在學習Java,在入門學習Java的過程當中缺乏基礎入門的視頻教程, 可以關注並私信我:01。免費領取2020年最新Java基礎精講視頻教程,學習手冊,面試題,開發工具,PDF文檔書籍教程,以下資料截圖:

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

JVM系列1:深入分析Java虛擬機堆和棧及OutOfMemory異常產生原因

關注並私信我:01。即可領取以上學習資料。


分享到:


相關文章: