【JVM系列1】深刻分析Java虛擬機堆和棧及OutOfMemory異常產生緣由

前言

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

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

在這裏插入圖片描述
從上圖咱們能夠看到,Java Virtual Machine位於最底層,全部的Java應用都是基於JVM來運行的,因此學習JVM對任何一個想要深刻了解Java的人是必不可少的。編程


Java的口號是:Write once,run anywhere(一次編寫,處處運行)。之因此能實現這個口號的緣由就是由於JVM的存在,JVM幫咱們處理好了不一樣平臺的兼容性問題,只要咱們安裝對應系統的JDK,就能夠運行,而無需關心其餘問題。api

什麼是JVM

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

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

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

Java程序執行流程

從咱們寫好的.java文件到最終在JVM上運行時,大體是以下一個流程:
框架

在這裏插入圖片描述
一個java類在通過編譯和類加載機制以後,會將加載後獲得的數據放到運行時數據區內,這樣咱們在運行程序的時候直接從JVM內存中讀取對應信息就能夠了。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

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

-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)

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

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

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)裏。
咱們來經過一個例子來演示一下區別:

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()方法,會先去常量池檢查是否存在當前字符串,若是不存在,則會在方法區中建立一個字符串,而new String("")方法建立的字符串在堆裏面,兩個字符串的地址不相等,故而返回false。

  • 在jdk1.7及1.8版本中:
    字符串常量池從方法區中的運行時常量池移到了堆內存中,而intern()方法也隨之作了改變。調用String.intern()方法,首先仍是會去常量池中檢查是否存在,若是不存在,那麼就會建立一個常量,並將引用指向堆,也就是說不會再從新建立一個字符串對象了,二者都會指向堆中的對象,因此返回true。
    不過有一點仍是須要注意,咱們把上面的構造字符串的代碼改造一下:

    String str1 = new String("ja") + new String("va"); System.out.println(str1==str1.intern()); 12

這時候在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個在字符串常量池


在這裏插入圖片描述


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

  • 執行new String(「lonely」)+new String(「wolf」)會產生5個對象,3個在堆,2個在字符串常量池
    在這裏插入圖片描述

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

在這裏插入圖片描述


而若是是jdk1.6中,不會指向堆,會從新建立一個lonelywolf字符串放到字符串常量池,因此纔會產生不一樣的運行結果。

注意:+號的底層執行的是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的實現方法區的差別

方法區是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會給出警告:

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

模擬方法區OutOfMemoryError

jdk1.7及以前版本

由於jdk1.7及以前都是永久代來實現方法區,因此咱們能夠經過設置永久代參數來模擬內存溢出:
設置永久代最大爲2M:

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


在這裏插入圖片描述
而後執行以下代碼:


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字節碼框架依賴(前面介紹動態代理的時候提到cglib動態代理也是利用了asm框架來生成字節碼,因此也能夠直接cglib的api來生成):

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

建立一個工具類去生成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 
1


在這裏插入圖片描述


而後運行測試類模擬:

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

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

在這裏插入圖片描述
Java虛擬機棧中可能有下面兩種異常狀況:


  • 若是線程執行所需棧深度大於Java虛擬機棧深度,則會拋出StackOverflowError
    上圖能夠知道,其實方法的調用就是入棧和出棧的過程,若是一直入棧而不出棧就容易發生異常(如遞歸)。

  • 若是Java虛擬機棧能夠動態地擴展,可是擴展大小的時候沒法申請到足夠的內存,則會拋出一個OutOfMemoryError。
    大部分Java虛擬機棧都是支持動態擴展大小的,也容許設置固定大小(在Java虛擬機規範中兩種都是能夠的,具體要看虛擬機的實現)。

注:咱們常常說的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(本地方法棧)

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

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

  • 若是線程執行所需棧深度大於本地方法棧深度,則會拋出StackOverflowError。

  • 若是能夠動態擴展本地方法棧,可是擴展大小的時候沒法申請到足夠的內存,則會拋出OutOfMemoryError。

總結

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

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

**請關注我,一塊兒學習進步**

相關文章
相關標籤/搜索