1. 程式人生 > 實用技巧 >JVM虛擬機器學習記錄

JVM虛擬機器學習記錄

Java虛擬機器-JVM

什麼是JVM

基本概念

JVM是一種抽象化的計算機,通過在實際的計算機上模擬模擬各種計算機功能來實現的,JVM有自己完善的硬體架構,如處理器,堆疊,暫存器等,還有一些具體的操作指令,JVM遮蔽了與JVM平臺具體作業系統相關的資訊,我們只需專注Java程式碼

JVM是一個記憶體中的虛擬機器,所以JVM的儲存即是記憶體,下面是JVM的結構,包括

  • Class Loader(類載入器):依據特定格式,載入class檔案到記憶體
  • Runtime Data Area(JVM記憶體空間結構模型):堆、棧、方法區、程式技術器
  • Execution Engine:對命令進行解析
  • Native interface(本地介面):融合不同開發語言的原生庫為Java所用,比如C++等語言中已經開發了的庫,Java就可以不用開發了,而是可以直接為Java所用

執行過程

Java原始碼首先被編譯成class位元組碼檔案,然後再有不同平臺的JVM進行解析,Java語言在不同平臺上執行時不需要再進行編譯,Java虛擬機器直接將位元組碼檔案轉換為對應平臺的機器碼

一個程式從開始執行,虛擬機器就例項化,多個程式存在多個虛擬機器例項。程式退出或者關閉,則虛擬機器例項消亡,多個虛擬機器例項之間資料不能共享

JVM記憶體區域

JVM記憶體區域主要分為執行緒私有區域(程式計數器,虛擬機器棧,本地方法棧)、執行緒共享區域(堆,方法區),直接記憶體三個部分。

各區域的生命週期

執行緒私有區域

執行緒私有區域的生命週期與執行緒相同,依賴使用者執行緒的啟動和結束,在JVM中建立和銷燬,每個執行緒都與作業系統的本地執行緒直接對映。

執行緒共享區

執行緒共享區隨虛擬機器的啟動/關閉而建立/銷燬

執行緒私有區

程式計數器

程式計數器是當前執行緒所執行的位元組碼行號指示器,通過改變計數器的值來選取下一條需要執行的位元組碼指令,他具有如下幾個特點

  • 正在執行java方法的話,計數器記錄的是虛擬機器位元組碼指令的地址,如果是Native方法,則為空
  • 這塊記憶體區域是虛擬機器規範中唯一沒有OutOfMemoryError的區域(只記錄行號,不會發生洩漏)
  • 通過改變計數器的值來選取下一條需要執行的位元組碼指令

為什麼要使用程式計數器:由於Java虛擬機器的多執行緒是通過執行緒輪流切換、分配處理器執行時間的方式來實現的,在任何一個時刻,一個處理器(對於多核處理器來說是一個核心)都只會執行一條執行緒中的指令,為了執行緒切換可以恢復到正確的執行位置

,每個執行緒都需要一個獨立的程式計數器,指向所執行的位元組碼指令地址,不同執行緒之間的程式計數器互不影響,獨立儲存。

虛擬機器棧

虛擬機器棧是描述Java方法執行的記憶體模型,每個方法執行時都會建立一個棧幀(Stack Frame)用於儲存區域性變量表,運算元棧、動態連結、方法出口等資訊。每個方法從呼叫直至執行完成的過程,就對應一個棧幀在虛擬機器中的入棧和出棧的過程。

棧幀是用來儲存資料和部分過程的資料結構,同時也被用來處理動態連結和方法返回值。棧幀隨著方法呼叫而建立,方法結束而銷燬--無論正常完成還是異常-----這也是棧的記憶體不需要GC回收的原因。

注意

  • 如果執行緒請求的棧深度大於虛擬機器所允許的深度,則會出現StackOverflowError,比如遞迴層數過多。
  • 如果虛擬機器棧可以動態擴充套件,擴充套件到無法申請足夠的記憶體,則出現OutOfMemoryError
  • 通常說棧是指虛擬機器棧的區域性變量表部分

本地方法棧

本地方法棧與虛擬機器棧類似,區別在於虛擬機器棧描述Java執行方法,而本地方法棧描述Native,比如呼叫c或c++

執行緒共享區

方法區/元空間

元空間用於儲存被虛擬機器載入的類資訊,常量,靜態變數、即使編譯器編譯後的程式碼快取等資料,JDK8以後把類的元資料放在本地記憶體中,在jdk7以前,這一塊屬於永久代

元空間和永久代的區別:元空間和永久代都是方法區的實現,但元空間使用本地記憶體,而永久代使用jvm記憶體

元空間替換永久代的優勢

  • 在jdk6時字串常量池存放在永久代中,容易出現效能問題和記憶體溢位
  • 類和方法的資訊大小難以確定,給永久代的大小指定帶來困難
  • 使用永久代實現方法區會為GC帶來不必要的複雜性
執行時常量池

執行時常量池是方法區的一部分,用於存放Class檔案的常量池表,常量池表用於存放編譯期生成的各種字面量與符號引用,符號引用翻譯出來的直接引用也會儲存在執行時常量池中

Java堆

堆用於儲存建立的物件例項和陣列,是垃圾收集器進行垃圾收集的主要區域。由於收集器採用分代演算法,因此堆從GC的角度可分為新生代(Eden去,From Survivor區和To Survivor區)和老年代

記憶體設定:-Xms 堆初始大小;-Xmx 堆最大擴容大小;-Xmn 新生代大小

根據虛擬機器規範,Java堆可以存在物理上不連續的記憶體空間,就像磁碟空間只要是邏輯連續的即可,他的記憶體大小可以設為固定,也可以擴充套件,當前主流的Hotpot虛擬機器等都能實現擴充套件,如果堆沒有記憶體完成例項分配,而且堆無法擴充套件將報OutOfMemoryError錯誤

若沒有預設的命令列指定初始化和最大的堆大小,則堆大小取決於計算機的實體記憶體大小, 預設最大堆在實體記憶體192MB以下時是實體記憶體的一半,並且在實體記憶體1GB以下時,為其四分之一

Java堆從GC的角度可細分為:新生代(Eden區,From Survivor區和To Survivor區)和老年代

JDK7以前

  • 新生代(Young Generation)
  • 老年代(Old Generation)
  • 永久代(Permanent Space)

JDK8及以後

  • 年輕代(Young Generation)
  • 老年代(Old Generation)

年輕代

用來存放新生的,生命週期較短的物件,一般佔用1/3的堆空間,由於頻繁建立物件,因此新生代會頻繁觸發MinorGC進行垃圾回收,新生代由分為Eden區,From Servivor區,To Servivor區

Eden區

Java新物件的出生地(如果新建立的物件佔用記憶體很大,則直接分配給老年代),當Eden區記憶體不夠時會觸發MinorGC,對新生代區進行一次垃圾回收

ServivorFrom
ServivorTo
老年代

主要用於存放生命週期長的物件

永久代(Jdk7及以前)

指記憶體的永久儲存區域,主要存放Class(現在存在元空間)和Meta(元資料---中介資料)的資訊,與存放例項的區域不同,GC不會在主程式執行期對永久區域進行清理。所以這會導致永久代的區域會隨著載入的Class的增多而擠滿,最終丟擲OOM異常

為什麼堆區域要分代?

分代的目的是為了提高GC的效率,將堆根據分代垃圾回收策略去劃分更利於記憶體的管理,因為堆中儲存的物件生命週期不盡相同,根據各物件的生命週期使用不同的分代收集策略能提高垃圾回收的效率,比如講生命週期較短的物件放在一起,那麼每次只關注如何保留少量存活的物件而不是去標記那些需要大量回收的物件,這樣以低代價回收大量空間。如果生命週期較長的物件放在一起,虛擬機器就可以以較低的頻率來回收這個區域,這樣同時兼顧了垃圾回收的時間開銷和記憶體空間的有效利用。

新生代為什麼要分割槽?

因為新生代儲存的物件是生命週期比較短的物件,因此每次垃圾回收需要回收大量物件,這樣存活的物件就很少了,所以垃圾回收使用複製Coping演算法,但是複製演算法需要至少兩塊記憶體區域去將一塊區域的物件複製到另一塊區域,如果將內容直接複製到老年代,老年代將會被很快填滿,觸發Full GC(Major GC伴隨Minor GC),而進行一次Full GC消耗的時間是很多的,所以新生代出現了Survivor的分割槽,因此Survivor分割槽的意義就是減少被送到老年代的物件,進而減少Full GC的發生,Survivor的預篩選保證只有經歷了一定的Minor GC次數後依然存活的物件,才會進入老年代

為什麼要設定兩個Survivor區

設定兩個Survivor區的最大好處就是解決記憶體碎片化

為什麼一塊Survivor區不行?假設現在只有一塊Survivor區,那麼模擬一下Minor GC的流程:

新建立的物件會被存放在Eden區,一旦Eden區滿了,就會觸發Minor GC,利用複製演算法將Eden中存活的物件移動到Survivor區。這樣迴圈下去,當下次Eden區又滿了是,問題來了,此時Eden區和Survivor都存在一些存活的物件,如果此時將Eden區的物件放到Survivor區,就會導致兩部分物件的記憶體區域不連續,也就導致了記憶體碎片化。因此需要兩塊Survivor去解決這個問題,當Eden和S0都有存活物件時,就將這兩個部分的物件按照連續的記憶體地址存放在S1中。接著S0和Eden清空,下一輪S0和S1互換角色,如此往復,知道物件的年齡到達一定次數被送入老年代。

直接記憶體

直接記憶體不是虛擬機器執行時資料區的一部分,這部分記憶體會被頻繁使用,並可能導致OutOfMemoryError異常的出現,直接記憶體的分配不會受到Java堆大小的限制,但是會受到本機總記憶體的限制,如果只考慮虛擬機器記憶體而忽視直接記憶體,則可能出現動態擴充套件虛擬機器記憶體導致超過實體記憶體限制出現OutOfMemory的情況

JVM記憶體溢位

如果堆沒有記憶體完成例項分配,而且堆無法擴充套件將報OutOfMemoryError錯誤,堆記憶體OOM異常是實際應用中最常見的記憶體溢位異常情況

異常資訊:"Java heap space"

堆記憶體大小設定:-Xms -Xmx

記憶體溢位測試

在IDEA中設定VM Option引數為-Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError,限制Java堆的大小為20MB,不可擴容,引數-XX:+HeapDumpOnOutOfMemoryError是為了讓虛擬機器出現記憶體溢位時Dump出當前的記憶體堆轉儲快照以便時候分析

/**
 * @program: algorithmDemo
 * @description: -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
 * @author: sirelly
 * @create: 2020-10-15 14:52
 **/
public class HeapOOM {
    static class OOMobject{

    }

    public static void main(String[] args) {
        ArrayList<OOMobject> ooMobjects = new ArrayList<>();
        while (true){
            ooMobjects.add(new OOMobject());
            System.out.println(11);
        }
    }
}

執行結果

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid10100.hprof ...
Heap dump file created [19801166 bytes in 0.082 secs]

問題解決:通過記憶體映像分析工具對Dump出來的堆轉儲快照進行分析,確認記憶體中導致OOM的物件是否必要,分析出是記憶體洩露(Memory Leak)還是記憶體溢位(Memory Overflow)

  • 如果是記憶體洩露,則通過工具進一步檢查洩露物件到GC Roots的引用鏈,找出產生記憶體洩露的具體i位置
  • 如果不是記憶體洩露,則可以根據堆引數-Xms和-Xmx調整記憶體大小,並從程式碼上檢查是否有某些物件生命週期過長、持有狀態時間過長、儲存結構設計不合理等情況,儘量減少程式執行期的記憶體消耗。

方法區和執行時常量池

如果方法區無法滿足新的記憶體分配需求時,將丟擲OutOfMemoryError異常。

異常資訊:PermGen space(永久代空間,比如jdk6時的字串常量池被擠爆)

不同JDK版本之間的intern()方法的區別-JDK6 VS JDK6+

public class ConstantPoolOOM {
 public static void main(String[] args) {
     String s1 = new StringBuilder("計算機").append("軟體").toString();
     System.out.println(s1.intern() == s1);

     String s2 = new StringBuilder("ja").append("va").toString();
     System.out.println(s2.intern() == s2);
 }
}

這段程式碼在JDK 6中執行,會得到兩個false,而在JDK 7中執行,會得到一個true和一個false。

JDK6中:intern()方法會把首次遇到的字串例項複製到永久代的字串常量池中儲存,返回的也是永久代裡面這個字串例項的引用,而由StringBuilder建立的字串物件例項在Java堆上,所以必然不可能是同一個引用,結果將返回false。

JDK7中:當呼叫intern方法時,如果字串常量池先前已建立該字串物件,則返回池中的該字串引用。否則,如果該字串物件已經存在於java堆中,則將堆中對於此物件的引用新增到字串常量池中,並且返回該引用;如果堆中不存在,則在池中建立該字串並返回其引用

第一個為true是因為“計算機軟體”字串是首次出現,intern()方法就把這個字串在堆中的引用新增到字串常量池,然後返回引用,因此是同一個引用,第二個為false是因為"java"字串在字串常量池中已經存在了,因此intern返回的是字串常量池中的引用,與對中的物件引用不同

為什麼jdk7之後方法區中的常量池會移動到堆中

因為之前字串常量池存在於永久代中,而永久代的記憶體極為有限,如果頻繁呼叫intern,就會使字串常量池被擠爆,報出OutOfMemoryError

虛擬機器棧/本地方法棧

在《Java虛擬機器規範》中描述了兩種異常:

  • 如果執行緒請求的棧深度大於虛擬機器所允許的最大深度,將丟擲StackOverflowError異常。
  • 如果虛擬機器的棧記憶體允許動態擴充套件,當擴充套件棧容量無法申請到足夠的記憶體時,將丟擲OutOfMemoryError異常。

棧容量設定:-Xss

StackOverflowError異常測試

寫一個斐波那契遞迴

package com.esperdemo.reflect;

/**
 * @program: demo
 * @description:
 * @author: sirelly
 * @create: 2020-09-22 10:55
 **/
public class Feibonacci {
    //F(0)=0, F(1)=1,當n>=2時,F(n) = F(n-1)+F(n-2)
    public static int feibonacci(int n){
        if(n == 0){return 0;}
        if(n == 1){return 1;}
        return feibonacci(n-1) + feibonacci(n-2);
    }

    public static void main(String[] args) {
        System.out.println(feibonacci(0));
        System.out.println(feibonacci(1));
        System.out.println(feibonacci(2));
        System.out.println(feibonacci(5));
    }
}

這時值都比較小,可以打印出相應的值,當我們將值設為10000時,就會報出java.lang.StackOverflowError的異常

問題原因:當執行緒執行一個方法時,就會隨之建立一個棧幀,並將建立的棧幀壓入虛擬機器棧中,當方法執行完畢之後就會將方法出棧,因此可知當前執行方法的棧幀位於虛擬機器棧的頂部。而遞迴每執行一個方法就壓一個棧幀到虛擬機器棧,一旦超過一定深度就會報StackOverflowErrow

解決思路:限制遞迴次數或使用迴圈代替

本機直接記憶體

容量大小設定:-XX:MaxDirectMemorySize,如果不指定,則預設與Java堆最大值(-Xmx)大小一致

由直接記憶體導致的記憶體溢位,一個明顯的特徵是在Heap Dump檔案中不會看見有什麼明顯的異常情況,如果讀者發現記憶體溢位之後產生的Dump檔案很小,而程式中又直接或間接使用了DirectMemory(典型的間接使用就是NIO),那就可以考慮重點檢查一下直接記憶體方面的原因了。

垃圾回收與演算法

垃圾回收需要確定三件事

  • 哪些記憶體需要回收?
  • 什麼時候回收?
  • 如何回收?

問什麼有了垃圾回收機制還要關注垃圾回收?

當各種記憶體洩露,記憶體溢位等問題發生時,當垃圾收集稱為系統達到更高併發的瓶頸時,我們就需要對垃圾回收進行監控和調節

如何確定物件為垃圾

引用計數演算法

思想:判斷物件的引用數量,來決定物件是否需要被回收

過程:在Java中,引用和物件時相關聯的,如果要操作物件就需要引用。每個物件都有一個引用計數器,被引用+1,完成引用就-1。任何引用計數為0的物件例項都可以被當做垃圾回收。

當一個物件被建立時,例項分配給一個引用物件,該物件的引用計數器會被設定為1,被其他物件引用,又會加1,如果該物件的引用超過生命週期,如在某個方法內引用,方法結束後, 引用計數器就會減1。因為該引用變數是區域性變數,儲存在虛擬機器棧上,方法結束後棧幀出棧。

結果:任何引用計數為0的物件例項都可以被當做垃圾回收

優點:執行效率高(只過濾引用計數器為0的物件),程式執行受影響小

缺點:無法檢測出迴圈引用的情況,導致記憶體洩露(物件該被回收,但無法被回收,最終可能導致記憶體溢位)

迴圈引用的情況

package com.esperdemo.gc;

/**
 * @program: demo
 * @description:
 * @author: sirelly
 * @create: 2020-09-24 10:48
 **/
public class MyObject {
    public MyObject childNode;
}

package com.esperdemo.gc;

/**
 * @program: demo
 * @description:
 * @author: sirelly
 * @create: 2020-09-24 10:50
 **/
public class ReferenceCounterProblem {
    public static void main(String[] args) {
        MyObject myObject1 = new MyObject();
        MyObject myObject2 = new MyObject();

        myObject1.childNode = myObject2;
        myObject2.childNode = myObject1;
    }
}

可達性分析演算法

為了解決引用計數法的迴圈引用問題,Java使用了可達性分析演算法。

思想:通過判斷物件的引用鏈是否可達來判斷物件是否可以被回收

過程:通過GC Root作為起始點開始遍歷,如果在GC Root和一個物件之間沒有可達的路徑,則稱該物件是不可達的,這裡不可達物件不等於可回收物件,不可達物件變為可回收物件至少需要經歷兩次標記過程。兩次標記後就面臨回收

可作為GC Root的物件
  • 虛擬機器棧中引用的物件(棧幀中的本地變量表)
  • 方法區中常量引用物件(比如類中定義了一個常量,而該常量儲存的是某物件的引用地址)
  • 方法區中類靜態屬性引用的物件,比如Java類的引用型別靜態變數
  • 本地方法棧中JNI(通常說的Natve方法)引用物件
  • 所有被同步鎖(synchronized關鍵字)持有的物件

垃圾回收演算法

標記-清除演算法(Mark-Sweep)

這種回收演算法可分為標記和清除兩個過程

  • 標記:使用可達性演算法從GC ROOT開始掃描,對可回收的物件進行標記
  • 清除:對堆記憶體從頭到尾進行線性遍歷,回收不可達物件記憶體

缺點

  • 該演算法最大的問題就是會記憶體碎片化嚴重,後續可能大物件找不到利用空間的問題,如果大物件找不到足夠的連續空間,則會觸發另一次垃圾收集
  • 其次是執行效率不穩定,如果Java堆中包含大量物件,而且大部分是需要被回收的,則導致標記和清除兩個過程執行效率隨物件的增長而增長

複製演算法(Copying)

為了解決標記-清理演算法記憶體碎片化以及面對大量回收物件效率低而提出的演算法。按記憶體容量將記憶體劃分為兩塊,每次只使用其中一塊,當一塊記憶體存滿後,將尚存活的物件複製到另一個記憶體塊上,然後把已使用的記憶體清理掉

適用場景:適用於物件存活率低的場景(因為存活率高,複製效率就低了),比如年輕代,記憶體回收時不用考慮記憶體碎片化等複雜情況

優點

  • 解決了記憶體碎片化問題
  • 順序分配記憶體,簡單高效
  • 適用於物件存活率較低的場景(複製操作較少),比如您清代的回收

缺點記憶體被壓縮到了一半,且存活物件較多時,複製演算法效率會大大降低。

標記-整理演算法(Mark-Compact)

為了解決記憶體碎片化以及記憶體壓縮等問題,提出標記-整理演算法。該演算法的標記階段與Mark-Sweep相同,標記後不是清理物件,而是將存活的物件移向記憶體的一端,然後清除端邊界外的物件

  • 標記:從GC Root開始掃描,對存活的物件進行標記
  • 整理:移動所有存活物件,且按照記憶體地址次序依次排列存活物件,然後將末端記憶體地址以後的記憶體全部回收

優點

  • 解決了記憶體碎片化的問題
  • 解決了記憶體壓縮的問題,不用設定兩塊記憶體互換
  • 適用於存活率高的場景

缺點:需要移動元素,成本較高,尤其是老年代這種每次回收都有大量物件存活的區域,移動存活對 象並更新所有引用這些物件的地方是非常負重的操作,而且這種移動物件的操作必須暫停使用者程式,即stop the world

分代收集演算法(Generational Collector)

分代收集演算法是目前大部分JVM所採用的方法,其核心思想是根據物件存活的不同生命週期劃分記憶體區域,目的是提高JVM的回收效率

Java堆從GC的角度可細分為:新生代(Eden區,From Survivor區和To Survivor區)和老年代

JDK7以前

  • 新生代(Young Generation)
  • 老年代(Old Generation)
  • 永久代(Permanent Space)

JDK8及以後

  • 年輕代(Young Generation)
  • 老年代(Old Generation)

GC的分類
  • 部分收集(Partial GC):指目標不是完整收集整個Java堆的垃圾收集

    • 新生代收集(Minor GC/Young GC)
    • 老年代收集(Major GC/Old GC):Major GC可能在其他資料上指整堆收集
    • 混合收集(Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集,目前只有G1收集器有
  • 整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集

  • Minor GC:用於新生代GC

  • Full GC和 Major GC:用於老年代GC

年輕代與複製演算法

由於年輕代物件存活率較低,通常採用複製Copying演算法,因為新生代中每次垃圾回收都要回收大部分物件,需要複製的操作較少

MinorGC過程(複製-清空-互換)

每次使用Eden和其中一塊survivor區,當垃圾回收時,將存活的物件一次性複製到另一塊survivor空間上,最後清理掉用過的Eden區和survivor區

  1. Eden、ServivorFrom物件複製到ServivorTo,年齡+1

    首先將Eden和ServivorFrom區域中存貨的物件複製到ServivorTo區域(如果有物件的年齡達到了老年標準,則直接複製到老年區),同時把這些物件的年齡+1

  2. 清空Eden,ServivorFrom

    複製完成後,將Eden區和ServivorFrom區的物件清空

  3. ServivorTo和ServivorFrom互換

    最後將ServivorTo和ServivorFrom互換,原ServivorTo成為下一次GC的ServivorFrom區

年輕代垃圾回收過程

比如Eden區最多能儲存4個物件,survivor區最多能夠儲存3個物件

1.新生物件被放在Eden區,當Eden區被擠滿後,會觸發一次MinorGC,存活的物件會被複制到其中一塊Survivor區,並將其年齡加1,然後清除Eden區

2.當第二次Eden區被擠滿後,又會觸發一次Minor GC,將S0和Eden區中存活的物件複製到S1中,並將其年齡加1,然後清空Eden和S0

3.如果Eden又滿了,然後就需要將Eden區和S1的物件複製到S0,也即原來的SurvivorTo區變成了SurvivorFrom區

這樣周而復始,物件在survivor區每熬過一次其年齡就加1,如果年齡超過一定值,一般是15歲,那麼就進入老年區,也可以通過引數-XX:MaxTenuringThreshold來調整

什麼情況進入老年代
  • 經過一定MinorGC,年齡超過一定值,依然存活的物件
  • Survivor區存放不下的物件
  • 新生成的大物件(通過引數-XX:+PretenuerSizeThreshold來調整物件大小超過的限度)
常用調優引數
  • -XX:SurvivorRatio:Eden和Survivor區的比值,預設為8:1
  • -XX:NewRatio:新生代和老年代記憶體大小比例(他們的總大小取決於堆的大小,即-Xms,-Xmx)
  • -XX:MaxTenuringThreshold:物件從年輕代上升到老年代經歷的GC次數的最大閾值
老年代與標記演算法

由於老年代物件比較穩定,存活率較高,並且沒有額外空間對他進行擔保,因此採用標記-清理演算法標記-整理演算法

老年代一般伴隨Full GC和Major GC,由於老年代比較穩定,因此MajorGC不會頻繁執行。在進行MajorGC前一般會進行一次MinorGC,使得新生代的物件晉升入老年代,導致老年代空間不夠時觸發。當無法找到足夠大的連續空間分配給新建立的較大物件時頁會提前觸發一次MajorGC進行垃圾回收騰出空間。

MajorGC採用標記清除演算法:首先掃描一次所有老年代,標記出存活的物件,然後回收沒有標記的物件。MajorGC耗時較長,因為要掃描再回收,當老年代也裝不下時,就會丟擲OOM(Out Of Memory)異常。

觸發Full GC的條件
  • 老年代空間不足
  • JDK7以前,永久代空間不足
  • Minor GC晉升到老年代的平均大小大於老年代剩餘空間
  • 呼叫System.gc()
  • CMS GC時出現promotion failed,concurrent mode failure

GC垃圾收集器

垃圾回收演算法是記憶體回收的方法論,垃圾收集器是記憶體回收的實踐者

JDK1.6中HotSpot虛擬機器的垃圾收集器如下(連線代表能搭配使用):

年輕代常見的垃圾收集器

Serial收集器(-XX:+UseSerialGC,複製演算法)

是Java虛擬機器中最基本,歷史最悠久的垃圾收集器

特點

  • 單執行緒收集,採用複製演算法,進行垃圾收集時,必須暫停所有工作流程
  • 簡單的高效,Client模式下預設的年輕代收集器,他是所有收集器裡額外記憶體消耗最小的,沒有執行緒互動開銷

如何檢視JVM的執行模式

JVM的執行模式分為Server和Client,可以通過java -version檢視當前JVM的執行模式

ParNew收集器(-XX:+UseParNewGC,複製演算法)

他是Serial收集器的多執行緒版本,也使用複製演算法,除了使用多執行緒之外,其餘行為與Serial收集器一樣,在垃圾收集過程中同樣也要暫停所有其他工作的執行緒

特點

  • 多執行緒收集,其餘的行為,特點和Serial收集器一樣
  • 單核執行效率不如Serial(存線上程互動開銷),在多核下執行才有優勢
  • 是很多Java虛擬機器執行在Server模式的新生代預設垃圾收集器

垃圾收集器的並行和併發

  • 並行(Parallel):指同一時間有多條這樣的執行緒在協同工作,通常預設此時使用者執行緒是處於等待狀態。
  • 併發(Concurrent):指同一時間垃圾收集器執行緒與使用者執行緒都在執行(但不一定是並行的,可能會交替執行),使用者程式在繼續執行,而垃圾收集程式運行於另一個CPU上。
Parallel Scavenge收集器(-XX:+UseParallelGC,複製演算法)

系統吞吐量:吞吐量 = 執行使用者程式碼的時間/(執行使用者程式碼的時間 + 垃圾收集時間)

特點

  • 多執行緒收集器,比起關注使用者執行緒的停頓時間(良好的響應速度,提升使用者體驗,適合多互動任務的情況),更關注系統可控制的吞吐量(高吞吐量可以高效利用CPU,吞吐量越高,垃圾收集時間越少,適合互動任務較少,長時間在後臺執行的情況)
  • 在多核下執行才有優勢,Server模式下預設的年輕代收集器
  • 自適應調節策略

自適應調節策略:通過-XX:+UseAdaptiveSizePolicy開啟,開啟自適應調節後,我們通過-XX:MaxGCPauseMillis引數(關注最大停頓時間)或-XX:GCTimeRatio(更關注吞吐量)引數給虛擬機器設定一個優化目標,就可以不用人工指定新生代大小(-Xmn),Eden與Survivor區的比例(-XX:SurvivorRatio),晉升老年代物件大小(-XX:PretenureSizeThreshold)等細節引數了,虛擬機器會自動調節引數提供最合適的停頓時間或最大吞吐量

Parallel Scavenge收集器使用兩個引數控制吞吐量

  • XX:MaxGCPauseMillis 控制最大的垃圾收集停頓時間
  • XX:GCRatio 直接設定吞吐量的大小。

直觀上,只要最大的垃圾收集停頓時間越小,吞吐量是越高的,但是GC停頓時間的縮短是以犧牲吞吐量和新生代空間作為代價的。比如原來10秒收集一次,每次停頓100毫秒,現在變成5秒收集一次,每次停頓70毫秒。停頓時間下降的同時,吞吐量也下降了。

老年代常見的垃圾收集器

Serial Old收集器(-XX:+UseSerialOldGC,標記-整理演算法)

特點

  • 單執行緒收集,進行垃圾收集時,必須暫停所有工作執行緒
  • 簡單高效,Client模式下預設的老年代收集器

Parallel Old收集器(-XX:+UseParallelOldGC,標記-整理演算法)

Parallel Old收集器是Parallel Scavenge的老年代版本,在JDK1.6開始提供。如果系統對吞吐量的要求比較高,可以考慮新生代Parallel Scavenge和老年代Parallel Old收集器搭配的策略。

特點:多執行緒,吞吐量優先

CMS收集器(- XX:+UseConcMarkSweepGC,標記清除演算法)

CMS收集器即Concurrent mark sweep收集器,主要目的是獲取最短的垃圾回收停頓時間,與其他老年代使用標記-整理演算法不同,他使用多執行緒的標記-清除演算法

這款收集器是HotSpot虛擬機器中第一款真正意義上支援併發的垃圾收集器,它首次實現了讓垃圾收集執行緒與使用者執行緒(基本上)同時工作。

特點

  • 幾乎能與使用者執行緒做到同時工作
  • 最短垃圾回收停頓時間可以提高程式的互動性
  • 適合較多存活的物件

垃圾回收過程

  1. 初始標記:stop-the-world,暫停正在執行的業務,僅僅標記GC Roots能直接關聯到的物件
  2. 併發標記:併發追溯標記,程式不會停頓,從GC Roots的直接關聯物件開始遍歷整個物件圖的過程
  3. 重新標記:暫停虛擬機器,修正併發標記時間內,因使用者程式繼續運作而導致標記變動的一部分物件的標記記錄
  4. 併發清理:清理垃圾物件,程式不會停頓,因為不需要移動物件

Garbage First(G1)收集器(-XX:+UseG1GC,複製+標記-整理演算法)

Garbage First垃圾收集器開創了收集器面向區域性收集的設計思路和基於Region的記憶體佈局形式,相比於CMS收集器,G1收集器兩個最突出的改進是:

  • 基於標記整理演算法,不產生記憶體碎片
  • 可以非常精準的控制停頓時間,在不犧牲吞吐量的前提下,實現低停頓垃圾回收

G1是一個面向全堆的收集器,不再需要其他新生代收集器的配合工作

G1收集器G1不再堅持固定大小以及固定數量的分代區域劃分,它把堆記憶體劃分為大小固定的幾個獨立區域Region,並且跟蹤這些區域的垃圾收集進度,同時在後臺維護一個優先順序列表,每次根據所允許的收集時間,優先回收垃圾最多的區域區域劃分和優先順序區域回收機制,確保G1收集器可以在有限時間獲得最高的垃圾收集效率。

特點

  1. 可預測的停頓(將Region作為單次回收的最小單元,每次收集到的記憶體空間都是Region大小的整數倍)
  2. 將整個Java堆記憶體劃分為多個大小相等的Region
  3. 優先順序區域回收機制
  4. 不再堅持固定大小以及固定數量的分代區域劃分

缺點:垃圾收集產生的記憶體佔用(Footprint)和程式執行時的額外執行負載(Overload)較高

四種引用型別

JDK1.2之前的引用描述:reference型別的資料中儲存的數值代表另外一塊記憶體的起始地址,就稱該reference資料代表某塊記憶體,某個物件的引用。這種描述方式不能描述其他更精細化的物件,如當記憶體空間足夠時,能夠保留在記憶體中,當記憶體空間在垃圾回收後很緊張,則可以拋棄這些物件

JDK1.2之後,Java將引用分為下面4個部分

強引用

Java中最常見的就是強引用,他是傳統引用的定義,把一個物件賦值給一個引用變數,這個引用變數就是一個強引用,即類似“Object obj=new Object()”這種引用關係。無論任何情況下,只要強引用關係還存在,垃圾收集器就永遠不會回
收掉被引用的物件,即使該物件以後都不會被使用,JVM也不會回收,因此強引用是造成Java記憶體洩露的最要原因之一

軟引用

軟引用需要用SorftReference類來實現,對於只有軟引用的物件來說,當系統足夠時不會被回收,記憶體不足時被回收

弱引用

弱引用需要用WeakReference類來實現,它比軟引用的生命週期更短,對於只有弱引用的物件來說,只要垃圾回收機制一執行,不管JVM記憶體空間是否足夠,都會回收該物件佔用的記憶體。

虛引用

虛引用需要PhantomReference類來實現,他不能單獨使用,必須和引用佇列聯合使用,其唯一目的是為了能夠在物件被收集器回收時受到一個系統通知

JVM類載入機制

Class檔案中描述了各類資訊,最終都需要載入到虛擬機器中之後才能被執行和使用

Java虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗,轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這個過程被稱為虛擬機器的類加在機制。與其他在編譯時需要進行連線的語言不同,Java語言的類載入,連線和初始化過程都是在程式執行期間完成的

類載入過程

一個類從被載入到虛擬機器記憶體到卸載出記憶體,類的生命週期分為載入,連線,初始化,使用,解除安裝幾個部分。

載入

ClassLoader根據一個類的全限定類名載入Class檔案位元組碼到記憶體中,將靜態資料轉換為執行時型別資料,生成一個代表類的java.lang.Class物件,這個物件作為程式訪問方法區中型別資料的外部介面

連線

連線分為驗證,準備,解析三個部分

驗證

檢查載入的Class檔案的正確性和安全性,是否符合當前虛擬機器的要求,需保證這些資訊被當做程式碼後不會危害虛擬機器自身的安全。

準備

為類變數(static變數)分配儲存空間並設定類變數初始值,即在方法區中分配這些變數所使用的記憶體空間

這裡所說的初始值是變數初始值,不是賦值,比如定義一個類變數

public static int v = 300;

那麼這裡設定的初始值是0

此外,如果類欄位是常量final型別,則準備階段的變數將值直接設定為初始值如

public static final int v = 300;

此時在準備階段虛擬機器就會將v設定為300

解析

JVM將常量池中的符號引用轉換為直接引用

當第一次執行時,要根據符號引用即字串的內容,在該類方法表中找到這個方法,執行一次後,符號引用會被替換為直接引用,下次就不用再搜尋了,直接引用就是偏移量,通過偏移量虛擬機器可以直接在該類的記憶體區域中找到方法位元組碼的初始位置。

符號引用

符號引用是以符號來描述引用的目標,符號可以是任何形式的字面量,只要使用時能夠無歧義的定位到目標即可。例如,在Class檔案中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等型別的常量出現。符號引用與虛擬機器的記憶體佈局無關,引用的目標並不一定載入到記憶體中。 各種虛擬機器實現的記憶體佈局可能有所不同,但是它們能接受的符號引用都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機器規範的Class檔案格式中。

直接引用

直接引用是可以直接指向目標的指標、相對偏移量或者是一個能間接定位到目標的控制代碼。直接引用和虛擬機器的佈局是相關的,同一個符號引用在不同的虛擬機器例項上翻譯出來的直接引用一般不會相同,如果有了直接引用,那引用的目標必定已經被載入到了記憶體中了。

初始化

初始化是類載入的最後一個階段,他執行類變數賦值和靜態程式碼塊

初始化就是執行類構造器()方法的過程方法是由編譯器自動收集類中的類變數賦值操作和靜態語句塊中的語句合併而成。

類載入器

ClassLoader類載入器再Java中意義重大,他主要工作在Class的類裝載的載入階段,其主要作用是從外部獲得Class二進位制資料流,然後交給JVM進行連線和初始化等操作。

類載入器的種類

  1. BootStrap ClassLoader:C++編寫,是虛擬機器的一部分(其他類載入器在外部,並繼承ClassLoader),載入核心庫java.*,負責載入JAVA_HOME\lib目錄中的,或通過-Xbootclasspath引數指定路徑中的
  2. Extension ClassLoader:Java編寫,載入擴充套件庫 javax.*,負責載入JAVA_HOME\lib\ext目錄中的,或通過java.ext.dirs系統變數指定路徑中的類庫。
  3. Application ClassLoader:Java編寫,負責載入類路徑classpath路徑上所有類庫
  4. 自定義 ClassLoader:Java編寫,定製化類載入,通過繼承java.lang.ClassLoader實現自定義的類載入器

實現自定義ClassLoader的實現

兩個重要的函式

  1. findClass(String name) ----尋找class檔案,並將class的二進位制資料讀取出來,傳給下一步的defineClass
package com.esperdemo.reflect;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;

/**
 * @program: demo
 * @description:
 * @author: sirelly
 * @create: 2020-09-21 20:05
 **/
public class MyClassLoader extends ClassLoader{
    private String path;
    private String classLoaderName;

    public MyClassLoader(String path, String classLoaderName){
        this.path = path;
        this.classLoaderName = classLoaderName;
    }

    //用於尋找類檔案
    @Override
    public Class findClass(String name){
        byte[] b = loadClassDate(name);
        return defineClass(name,b,0,b.length);
     }
     //用於載入類檔案
    private byte[] loadClassDate(String name) {
        name = path + name + ".class";
        InputStream in = null;
        ByteArrayOutputStream out = null;
        try{
            in = new FileInputStream(new File(name));
            out = new ByteArrayOutputStream();
            int i = 0;
            while((i = in.read())!= -1){
                out.write(i);
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            try {
                out.close();
                in.close();
            }catch (Exception e){
                e.printStackTrace();
            }
        }
        return out.toByteArray();
    }
}

package com.esperdemo.reflect;

import java.sql.SQLOutput;

/**
 * @program: demo
 * @description:
 * @author: sirelly
 * @create: 2020-09-21 20:26
 **/
public class ClassLoaderCheck {
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        MyClassLoader myClassLoader = new MyClassLoader("C:\\Users\\Administrator\\Desktop\\", "myClassLoader");
        Class<?> robot = myClassLoader.loadClass("Robot");
        System.out.println(robot.getClassLoader());
        robot.newInstance();

    }
}

JVM通過雙親委派模型進行類的載入

雙親委派機制

雙親委派模型即各種類載入器之間的層次關係

當一個類載入器收到類載入的請求,他首先不會去嘗試載入這個類,而是遞迴地把這個請求委派給父類載入器完成。當父載入器找不到指定的類時,子載入器嘗試自己載入。

為什麼要雙親委派機制

因為不用的ClassLoader負責載入的路徑和方式有所不同,為了實現分工,各自負責各自的部分,使得邏輯更加明確,所以在類載入過程中,會出現一個機制去讓他們相互協作,形成一個整體,這個機制就是雙親委派機制,使用雙親委派機制能夠避免一個類被重複載入,防止記憶體中存在多分同樣的的位元組碼,也能夠避免系統類被修改。

雙親委派機制過程:先檢視載入的型別是否被載入過,若載入過則直接返回Class物件,否則遞迴呼叫父載入器的loadClass,如果直到最上層都沒載入過此類,則從最上層開始查詢路徑是否能夠載入此類,如果不能載入此類則委派給下一層去查詢是否能夠載入,最後直到最下層,如果都無法載入此類,則丟擲異常classNotFund

程式原始碼

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
// 首先,檢查請求的類是否已經被載入過了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
//若沒有載入,遞歸向上檢視父類是否載入
c = parent.loadClass(name, false);
} else {
//到達BootstrapClassLoader檢視是否載入此類
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父類載入器丟擲ClassNotFoundException
// 說明父類載入器無法完成載入請求
}
if (c == null) {
// 在父類載入器無法載入時
// 再呼叫本身的findClass方法來進行類載入
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}

為什麼說Custom ClassLoader的上層就是AppClassLoader

通過類的getClassLoader().getParent()可以檢視

雙親委派機制的好處

避免一個類被重複載入,也即防止記憶體中存在多份同樣的的位元組碼

比如 如果有人想替換系統級別的類String.class,篡改他的實現,但在這種機制下這些系統的類已經被BootStrap classLoader載入過了,所以不會再去載入,從一定程度上防止了危險程式碼的植入

記憶體效能優化