1. 程式人生 > 其它 >JVM--執行時資料區

JVM--執行時資料區

一、執行時資料區概述

(一)JVM執行時資料區規範

  JVM執行時資料區按照執行緒佔用的情況可以分為兩類:執行緒共享和執行緒獨享。執行緒共享的包括方法區和堆,執行緒獨享的包括棧、本地方法棧和程式計數器。

        

  JVM執行時資料區各個模組的使用順序:在JVM啟動的時候,為方法區和堆分配初始記憶體並設定最大記憶體(一般建議初始記憶體和最大記憶體保持一致,這樣可以減少擴容帶來的效能損耗),在程式執行的時候,會用到所有的模組。

  對於Hotspot執行時資料區,在JDK1.8之前,方法區的實現稱為永久代,在JDK1.8及以後,方法區的實現稱為元空間。

  方法區是JVM虛擬機器規範中的定義,而永久代和元空間是Hotspot的實現。

(二)分配JVM記憶體空間

  分配堆的大小:

    -Xms(初始堆大小)

    -Xmx(最大堆大小)

  分配方法區大小:

    -XX:PermSize(初始永久代大小)

    -XX:MaxPermSize(最大永久代大小)

    -XX:MetaspaceSize(初始元空間大小;達到該值就會觸發垃圾收集進行型別解除安裝,同時GC會對該值進行調整,如果釋放了大量的空間,則GC會向下調整該值的大小,如果釋放了很少的空間,則GC會調大該值,但是不會超過最大值)

    -XX:MaxMetaspaceSize(最大元空間大小)

    --XX:MinMetaspaceFreeRatio:在GC後,最小的元空間剩餘空間容量佔比,減少為了分配空間所導致的垃圾回收。

    -XX:MaxMetaspaceFreeRatio:在GC後,最大的元空間剩餘容器佔比,減少為了釋放空間所導致的垃圾回收。

    -Xss: 為JVM啟動的每個執行緒分配記憶體大小,jdk1.4是256k,1.5及以後是1M

二、方法區

(一)方法區儲存的內容

  方法區中儲存的內容包括型別資訊、方法資訊、欄位資訊、code區資訊、方法表、指向當前類和父類的引用、類常量池等資訊。

  型別資訊:型別的全限定名、父類的全限定名、介面的全限定名、型別標識(類或介面)、訪問許可權

  方法資訊:方法修飾符(訪問標識)、方法名、(方法的返回型別、方法引數個數、型別、集合)、方法位元組碼、運算元棧和該方法在棧幀中的區域性變數大小、異常表。

  欄位資訊:欄位修飾符(類似訪問標識)、欄位的型別、欄位的名稱

  code區:code區儲存的是方法執行對應的位元組碼指令

  方法表:為了提高訪問效率,JVM為會為每個非抽象類建立一個數組,陣列的每個元素都是例項可能被呼叫的方法,包括從父類繼承過來的方法。這個表在抽象類中是沒有的。

  類變數:靜態變數

  指向類載入器的引用:每一個被JVM載入的類,都儲存著對應的類載入器的引用,隨時會被用到。

  指向Class例項的引用:類載入過程中,虛擬機器會建立該類的例項,方法區中必須儲存例項的引用,通過Class.forName(String name)來獲取該類例項的引用,然後建立該類的物件。

  常量池:class檔案中除了儲存類、方法、欄位、介面等資訊外,還儲存了常量池。常量池用於儲存編譯器生成的各種字面量和符號引用,這部分內容在類被載入後,進入執行時常量池。

(二)方法區、永久代、元空間區別

  方法區是JVM規範中定義的區域,是抽象出來的概念,永久代是Hotspot在1.8之前對於方法區的實現方案,元空間是Hotspot在1.8及以後對於方法區的實現方案。

  永久代佔用記憶體區域是JVM程序所佔用的記憶體區域,因此永久代的大小受整個JVM記憶體大小的限制;而元空間佔用的記憶體是物理機的記憶體,因此元空間的記憶體大小受整個物理機記憶體大小的限制。

  永久代儲存的資訊基本上就是上述的資訊,但是元空間只儲存了類的元資訊,而類變數和執行時常量池則移到了堆中。

  那麼為什麼做這種轉化呢?

    1、字串存在永久代中,容易造成效能問題和永久代記憶體溢位

    2、類和方法的大小比較難預估,因此不太好設定永久代的大小,如果太大,容易造成老年代記憶體溢位,如果太小,容易造成永久代記憶體溢位。

    3、永久代記憶體GC帶來不必要的複雜度,且回收效率極低

    4、Oracle使用的JVM虛擬機器是JRockit,方法區的實現是元空間,Sun公司使用的是Hotspot,方法區的實現是永久代,而Oracle公司收購的Sun公司,收購之後,準備合二為一。

(三)方法區異常演示

  類載入導致的OOM:以JDK1.8為例,設定元空間大小為16M:-XX:MetaspaceSize=16m -XX:MaxMetaspaceSize=16m

  程式碼如下:

package com.lcl.service;
@SpringBootTest(classes = ProjectApplication.class)
@RunWith(value = SpringRunner.class)
@Slf4j
public class LclTest { 
    @Test
    public void test2(){
        URL url = null;
        List<ClassLoader> classLoaderList = new ArrayList<ClassLoader>();try {
            url = new File("/tmp").toURI().toURL();
            URL[] urls = {url};
            while (true){
                ClassLoader loader = new URLClassLoader(urls);
                classLoaderList.add(loader);
                loader.loadClass("com.lcl.service.LclTest ");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

  執行結果:

    

  字串OOM:

  程式碼:

    @Test
    public void test1(){
        String base = "String";
        List<String> list = new ArrayList<>();
        for (int i = 0; i < Integer.MAX_VALUE; i++){
            String s = base + base;
            base = s;
            list.add(s.intern());
        }
    }

  以1.8為例,執行結果:

         

(四)字串常量池

  類資訊的常量池是在編譯階段就產生的,儲存在class檔案中儲存的都是符號引用;

  執行時常量池儲存在JVM記憶體中,具有動態性,在類載入時,類資訊的常量池會進入執行時常量池,但同時也可以在執行期將新的常量加入到執行時常量池中(例如String的intern()方法))。

  字串常量池邏輯上屬於執行時常量池的一部分,它是用來儲存執行時常量池中的字串常量的,但是它和執行時常量池的區別在於,字串常量池是全域性唯一的,而執行時常量池是每個類一個。在JDK1.6中,字串常量池位於方法區中,在JDK1.7及以後,字串常量池位於堆中。

  為了提高字串的檢索速度,JVM提供了一個StringTable用來儲存字串常量資訊,其資料介面與HashMap類似,使用陣列+連結串列的方式儲存,裡面儲存了對於字串的引用。

  在JDK1.6中,字串常量池儲存結構StringTable的陣列長度為1009,在JDK1.7及以後可以使用下面引數進行設定,預設值改為99991.

-XX:StringTableSize=99991

  字串常量池的好處:節省空間:字串常量池是為節省記憶體空間而設定的一個記憶體區域,所有的類共享一個字串常量池。

  對於字串常量池的使用總結如下:

    1、單獨使用雙引號建立的字串都是常量,直接儲存在字串常量池中。

    2、使用new String("ab")建立的字串,會在字串常量池中儲存一個“ab”字串常量,同時會在堆中開闢一個空間,將字串常量池中常量的值(“ab”)複製到堆記憶體中,而最終返回的是堆中地址的引用。例如String s =new String("ab"),s指向的是堆中的地址。且如果再有一個String s1 =new String("ab"),那麼s和s1也不是指向同一個地址。

    3、使用字串常量池連線的字串,由於JIT有方法內斂的優化,可將其直接替換為對應的值,因此也是一個字串常量,例如String s = "a" + "b",就只會在字串常量池中新增一個“ab”字串,返回的s則是字串常量池中“ab”字串的引用。

    4、如果存在計算的情況,那麼則是在執行期才建立的,也是會存入堆中的,例如String s = k + "a",那麼s也是指向堆中的地址。

        /**
         * String str1 = "abc"  的步驟:
         *  1、棧中開闢一塊空間存放引用str1
         *  2、String常量池開闢一塊空間,存放String常量“abc”
         *  3、引用str1指向String常量“abc”
         * String str2 = new String("abc")  的步驟:
         *  1、棧中開闢一塊空間存放引用str2
         *  2、堆中開闢一塊空間存放一個新建的String物件“abc”
         *  3、引用str2指向堆中新建的物件“abc“
         */
        String str1 = "abc";
        System.out.println(str1 == "abc");//true; str1所指向的地址即是String常量“abc”的存放地址,因此輸出為true
        String str2 = new String("abc");
        System.out.println(str1 == str2);//false; str1指向的是字串常量池中的地址,str2指向的是堆中的地址,因此為false
        String str3 = new String("abc");
        System.out.println(str3 == str2);//false;  str2和str3指向的是堆中兩個不同的地址
        String str4 = "a" + "b";
        System.out.println(str4 == "ab");//true;  兩個常量相加,在JVM優化時,會使用方法內斂將其替換為”ab“,因此為true
        final String s = "a";
        String str5 = s + "b";
        System.out.println(str5 == "ab");//true;  由於s使用final修飾,並不會被修改,因此str5=a+b,也是用方法內斂優化將str5賦值為ab,因此為stru
        String s1 = "a";
        String s2 = "b";
        String str6 = s1 + s2;
        System.out.println(str6 == "ab");//false;  s1和s2都是變數且沒有final修飾,因此在執行期可能發生變化,因此是通過計算而來的,將其儲存在堆中,str6指向的是堆中的引用
        String str7 = "abc".substring(0, 2);
        System.out.println(str7 == "ab");//false;  同上,通過計算而來的存放在堆中,是堆中引用的地址,而”ab“是存在字串常量池中的。
        String str8 = "abc".toUpperCase();
        System.out.println(str8 == "ABC");//false;  同上

(五)intern方法

  在上面提到,使用intern方法可以將字串常量在執行期動態的新增到字串常量池中,確切的說是intern可以把new出來的字串引用新增到字串常量池StringTable中,並返回字串常量池中該字串常量的引用。

  intern方法執行的步驟:先計算字串的hashcode,通過hashcode在Stringtable查詢是否存在對應的引用,如果存在,則不進行任何處理,直接返回引用;如果不存在,則將該引用放入字串常量池Stringtable中,並返回引用。

  使用intern的好處:通過intern()方法顯式的將字串常量新增入字串常量池,避免大量的字串常量在堆中重複建立。在JDK1.6中,字串常量池位於永久代中,大小受限,不建議使用intern()方法,在JDK1.7中,字串常量池移動到了堆中,大小可控,可以重新考慮使用intern()方法,但是通過測試對比,使用intern()方法的效能耗時不容忽視,所以要慎重使用。

  案例:

        String s5 = "a";
        String s6 = "abc";
        String s7 = s5 + "bc";
        System.out.println(s6 == s7.intern());//true 雖然s7是經運算得來的,但是使用intern方法,返回的也是字串常量池的地址
        String c = "world";
        System.out.println(c.intern() == c); //true intern方法,返回的也是字串常量池的地址
        String d = new String("mike");
        System.out.println(d.intern() == d); //false d指向的是堆中的地址,而intern方法獲取的是字串常量池中的地址
        String e = new String("jo") + new String("hn");
        System.out.println(e.intern() == e); //true e的值為john,在字串常量池中不存在,因此呼叫intern方法時,將其動態的新增進字串常量池
        String f = new String("ja") + new String("va");
        System.out.println(f.intern() == f); //false java為關鍵字,在jvm啟動時,已經將其新增進字串常量池,因此呼叫intern後沒有做任何事情,只是返回了java的字串常量值地址,而f仍然指向堆中地址

三、Java堆

  Java堆被記憶體共享,在JVM虛擬機器啟動時建立,是虛擬機器管理最大的一塊記憶體區域。

  Java堆是垃圾回收的主要區域,而且主要採用分代回收演算法,使用分帶回收演算法主要是為了更好、更快的回收記憶體。

  Java堆記憶體的建立和回收都是由垃圾收集器處理的。

(一)堆記憶體

  堆記憶體儲內容:

    在JDK1.6及之前,Java堆中儲存的是物件和陣列,在JDK1.7及以後,Java堆中儲存的是物件、陣列、字串常量、靜態變數

  堆記憶體劃分:

    Java堆分為新生代、老年代和永久代,其中永久代在1.8之後變更為元資料區,預設新生代和老年代的比例為1:2,其中新生代又分為Eden區和S區,Eden區和S區的比例為8:2,S區又分為S0和S1兩個區域,比例為1:1。所以總體來說老年代:新生代(Eden:S0:S1)為1:2(8:1:1)。

  建立物件時記憶體分配的步驟:

    1、大物件直接進入老年代,大物件一般指很長的字串或陣列

    2、其餘物件首先分配到Eden區,如果Eden區記憶體不足,則觸發一次Minor GC

    3、長期存活的物件進入老年代,預設回收次數是15次。

    

  堆的記憶體分配方式:

    堆的記憶體分配方式有指標碰撞和空閒列表兩種方式:

    指標碰撞:記憶體是連續的,年輕代使用,使用該種分配方式的垃圾回收器:Serial和ParNew收集器

    空閒列表:記憶體地址不連續,老年代使用,使用該種分配方式的垃圾回收器:CMS和Mark-Sweep收集器

  記憶體分配安全:

    在進行記憶體分配時,存線上程安全的問題,JVM的解決方案是通過TLAB和CAS來解決的。

    TLAB(本地執行緒分配快取):為每一個執行緒預先分配一塊記憶體,JVM在給執行緒中的物件分配記憶體時,先在TLAB上分配,如果記憶體不夠,再使用CAS進行分配。

    CAS(比較和交換):CAS是樂觀鎖的一種實現方式,即每一次申請記憶體都不加鎖,如果出現衝突進行重試,知道成功為止。

(二)物件記憶體佈局及訪問方式    

  物件的記憶體佈局:

    物件在堆記憶體中的佈局可以分為物件頭、例項資料、對齊填充三個部分。

    物件頭:物件頭包含兩部分資料,一部分是儲存物件自身的執行資料,如hashcode、GC分代年齡、鎖狀態標識、執行緒持有的鎖、偏向執行緒id、偏向時間戳等內容;另一部分是型別指標,它指向類元資料,虛擬機器用這個指標來確定物件是哪個類的例項。如果物件是一個數組時,那麼物件頭還必須有一塊用於記錄陣列長度的資料,因此JVM可以通過物件的元資料判斷java物件的大小,但是陣列不可以。

    例項資料:儲存的是物件真正的資訊

    對齊填充:在JVM中物件的大小必須是8位元組的整數倍,物件頭已經確定了是8位元組的倍數,但是例項資料不一定是8個位元組的倍數,因此如果最終物件頭+例項資料的大小不是8位元組的倍數,則需要對齊填充來對其進行填充。

  物件的訪問方式:

    物件的訪問方式分為控制代碼訪問和直接指標訪問。

    控制代碼訪問:虛擬機器棧中本地變量表中儲存的是控制代碼池中控制代碼的指標,而控制代碼中有一個指向堆中物件例項資料的指標和一個指向方法區中物件型別的指標。控制代碼訪問的優點是穩定,因為如果物件發生移動,則只需要改變控制代碼中指向堆中例項資料的指標即可。

    直接指標訪問:虛擬機器棧中本地變量表儲存的是直接指向堆中物件的指標,物件中又包含例項資料和型別指標等資訊。直接指標訪問的優點是,訪問速度快,節省了一次指標定位的開銷。

    在Hotspott中,使用的是直接指標訪問的方式。

    

(三)陣列記憶體分析

  對於陣列,其在記憶體中的地址是連續的,變數對應的指標指向的是堆中連續空間的開始地址。

  一維陣列:

    int[] arr = new int[3]:這行程式碼首先會將arr壓入棧,然後在堆中開闢一個空間,然後將其賦上預設值,由於陣列型別是int,因此被賦上預設值0

    int[] arr1 = arr:這行程式碼會將arr中的地址賦值給arr2,此時arr和arr2指向了同一塊記憶體地址。

    arr[0] = 20:這行程式碼,將arr指標對應地址的第一個值更新為20

  二維陣列:

    int[][] arr = new int[3][]:這樣程式碼首先將arr壓入棧,然後再堆中開闢一個記憶體空間,並附上預設值,由於是二維陣列,因此其預設值為null,然後把該記憶體空間的地址賦值給arr

    int[0][] = new int[1]:這行程式碼將在對中開闢一個記憶體空間,然後賦上預設值(由於是int型別,預設值為0),並將該記憶體空間的地址賦值給一維陣列的第一個資料。

        

四、虛擬機器棧

(一)虛擬機器棧

  1、棧幀

  虛擬機器棧是執行緒私有的,且生命週期與執行緒也一樣,每個java方法在執行的時候都會建立一個棧幀。

    棧幀定義:

    棧幀是用於支援虛擬機器進行方法呼叫和方法執行的資料結構。

    棧幀儲存了局部變量表、運算元棧、動態連線和方法返回等資訊,每一個方法從呼叫到執行完成的過程,都對應一個棧幀從入棧到出棧的過程。

        

    當前棧幀:

    一個執行緒中方法的呼叫鏈可能會很長,所以會有很多棧幀,只有位於JVM虛擬機器棧棧頂的元素才是有效的,被稱為當前棧幀,這個棧幀對應的方法稱為當前方法,定義這個方法的類稱為當前類。

    執行引擎執行的所有位元組碼指令都是針對當前棧幀的操作,如果當前方法呼叫了其他方法,那麼被呼叫方法的棧幀就變為當前棧幀。

    棧幀的建立:

    呼叫新方法時,新的棧幀隨之被建立,並且隨著程式控制權轉移到新方法,新的棧幀也變為當前棧幀。在方法返回時,該棧幀會返回方法的執行結果給之前的棧幀,隨後虛擬機器會丟棄該棧幀。

  2、區域性變量表

    儲存內容:

    區域性變量表是一組變數值儲存空間,用於存放方法引數和方法內定義的區域性變數。

    一個區域性變數可以儲存的資料型別為:boolean、byte、char、short、int、float、reference、returnAddress,其中reference是對一個物件例項的引用。

    儲存容量:

    區域性變量表的容量以槽為最小的儲存單位,JVM虛擬機器並沒有規定一個槽應該佔用多少記憶體,但是規定了一個槽必須可以儲存一個32位以內的資料型別。

    在類編譯為class檔案時,就在方法的code屬性中的max_locals資料項中確定了該方法需要分配的最大槽數即最大容量。

    虛擬機器通過索引定位到區域性變量表的槽中,索引範圍是0到區域性變量表的最大槽數,如果槽是32位的,如果碰到64位的資料型別,則需要連續讀取兩個槽的資料。

  3、運算元棧

    定義及作用:

    運算元棧也可以被稱為操作棧,是一個先入後出的棧,當一個方法剛剛開始執行時,其運算元棧是空的,隨著方法和位元組碼指令的執行,會從區域性變量表或物件例項的欄位中賦值常量或變數到運算元棧中,在隨著計算的進行將棧中元素出棧到區域性變量表中或者返回給方法呼叫者,也就是出棧/入棧的操作。

    儲存內容:

    運算元棧的每一個元素可以是任意java資料型別,32位的資料型別佔一個棧容量,64位的資料型別佔兩個棧容量。

    儲存容量:

    與區域性變量表一樣,其資訊也在編譯的時候儲存在class檔案的code區,其儲存在code區中max_stacks屬性中,在方法執行時,運算元棧的深度在任何時候都不會超過該值。

  4、動態連線

    在一個class檔案中,一個方法要呼叫其他方法,需要將方法的符號引用替換為直接引用,而符號引用儲存在方法區的執行時常量池中。

    在JVM虛擬機器中,每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的符號引用,持有這個引用的目的是為了支援方法呼叫過程中的動態連線。

    這些符號引用有一部分會在類載入時或者第一次使用時就直接轉化為直接引用,這類轉化稱為靜態解析,另外一部分在每一次執行期間直接轉換為直接引用,這部分轉化稱為動態連線。

  5、方法返回

    當一個方法開始執行的時候,可能有正常退出和異常退出兩種情況。

    正常退出是指方法正常完成操作並推出,沒有丟擲任何異常,如果當前方法正常完成,則根據當前方法返回的位元組碼指令進行處理。該方法返回的位元組碼指令中有可能存在返回值,也可能不存在返回值。

    異常退出是指方法執行過程中遇到異常,並且這個異常在方法體內部沒有得到處理,導致方法退出。也就是說無論是Java虛擬機器丟擲的異常還是程式碼中使用throw產生的異常,只要在本方法的異常表中沒有找到對應的異常處理器,就會導致方法退出。

    無論方法採用何種方式退出,在方法退出後都需要返回到方法被呼叫的位置,程式才能繼續執行,方法返回時可能需要在當前棧幀中儲存一些資訊,用來幫它恢復其上層方法的執行狀態。方法退出過程實際上等同於把當前棧幀出棧,因此退出可以執行的操作有:恢復上層方法的區域性變量表和運算元棧,如果有返回值,需要將返回值壓入呼叫者的運算元棧中,同時調整PC計數器的值以指向方法呼叫指令後的下一條指令。

    一般來說,方法正常退出時,呼叫者的PC計數器值可以作為返回地址,棧幀中可能儲存此計數值,而方法異常退出時,返回地址是通過異常處理器表確定的,棧幀中一般不會儲存此部分資訊。

(二)棧異常

  JVM虛擬機器規範中,對該區域規定了兩種異常情況:

    1、如果執行緒請求的棧深度大於虛擬機器棧所允許的最大深度,則會丟擲StackOverflowError異常

    2、虛擬機器棧可以動態擴充套件,當擴充套件時無法申請到足夠的記憶體時,就會丟擲OutOfMemoryError異常

    @Test
    public void testMain(){
        int i = 0;
        this.call(i);
    }

    private void call(int i) {
        i++;
        log.info("======{}", i);
        call(i);
    }

      

五、本地方法棧

  什麼是本地方法棧:

    本地方法棧和虛擬機器棧類似,區別是虛擬機器棧用來為虛擬機器執行java服務,也就是執行位元組碼服務,而本地方法棧為虛擬機器提供native方法服務,例如C++程式碼。簡單地講,一個Native方法就類似於java程式碼的一個介面,但是實現類是用其他與語言實現的,例如C++。

  為什麼要用本地方法:

    java使用起來非常方便,但是有些層次的任務用java實現起來不容易,或者效率達不到要求。

    本地方法棧有效的擴充了jvm,例如在java併發場景中,執行緒的切換、等待、喚醒等操作,都是使用的本地方法與作業系統直接互動的。

  本地方法棧使用流程:

    當一個方法呼叫本地方法,本地方法執行後會回撥虛擬機器中的另一個java方法。一般情況下本地方法會有兩個以上的函式,java程式呼叫的是第一個C語言函式,C語言的第一個函式呼叫C語言的第二個函式,最後由C語言的第二個函式回撥虛擬機器中的另一個java方法。

六、程式計數器

  程式計數器也叫PC暫存器,是一塊較小的記憶體空間,他可以看作是當前執行緒所執行的位元組碼指令的行號指示器。位元組碼直譯器的工作就是通過改變計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等都需要依賴這個程式計數器。

  由於java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時間,一個處理器都只會執行一個執行緒的指令,因此為了執行緒切換後可以恢復到正確的執行位置,每個執行緒都需要一個獨立的程式計數器,各個執行緒之間互不影響,獨立儲存,因此程式計數器也是執行緒私有的。

  如果一個執行緒正在執行的是java方法,那麼該執行緒的程式計數器記錄的是虛擬機器位元組碼指令的地址,如果正在執行的是一個Native方法,這個計數器的值為空。

  程式計數器是JVM中唯一沒有任何OutOfMemoryError異常的區域