Java進階 JVM 記憶體與垃圾回收篇(一)

JVM

1. 引言

1.1 什麼是JVM?

定義

  • Java Vritual Machine – java 程式的運行環境(Java二進位位元組碼的運行環境)

好處

  • 一次編譯 ,到處運行
  • 自動記憶體管理,垃圾回收功能
  • 數據下標越界越界檢查
  • 多態

比較

Jvm vs Jre vs JDK

image-20220402212538978

1.2 學習路線

本文主要講解的是HotSpot VM

HotSpot VM 是目前市面上高性能虛擬機的代表作之一,採用解釋器與即時編譯器並存的架構

學習主要分為三個部分

此文為第一篇

  1. 記憶體與垃圾回收篇

    • JVM概述

    • 類載入過程

    • 運行時數據區

    • 執行引擎

    • 記憶體的分配與回收

  2. 位元組碼與類的載入篇

  3. 性能調優篇

image-20220408214120874
image-20220402213154596

1.3 Java程式碼執行流程

image-20220405143914929

1.4 JVM的架構模型

Java編譯器輸入的指令流基本上是一種基於棧的指令集架構,另外一種指令集架構則
基於暫存器的指令集架構

這兩種架構之間的區別:

  • 基於棧式架構的特點
    • 設計和實現更簡單,適用於資源受限的系統;
    • 避開了暫存器的分配難題:使用零地址指令方式分配。
    • 指令流中的指令大部分是零地址指令,其執行過程依賴於操作棧。指令集更小
      編譯器容易實現。
    • 不需要硬體支援,可移植性更好,更好實現跨平台
  • 基於暫存器架構的特點
    • 典型的應用是x86的二進位指令集:比如傳統的PC以及Android的Davlik虛
      擬機。
    • 指令集架構則完全依賴硬體,可移植性差
    • 性能優秀和執行更高效:
    • 花費更少的指令去完成一項操作。
    • 在大部分情況下,基於暫存器架構的指令集往往都以一地址指令、二地址指令
      和三地址指令為主,而基於棧式架構的指令集卻是以零地址指令為主。去由堂。

由於跨平台性的設計,Java的指令都是根據棧來設計的。不同平台CPU架構不同,所以不能設計為基於暫存器的。優點是跨平台, 指令集小,編譯器容易實現,缺點是性能下降,實現同樣的功能需要更多的指令。
棧: 跨平台性、指令集小、指令多;執行性能比暫存器差

1.5 JVM的生命周期

虛擬機的啟動

Java虛擬機的啟動是通過引導類載入器(bootstrap class loader) 創建一個初始類(initial class) 來完成的,這個類是由虛擬機的具體實現指定的。

虛擬機的執行

  • 一個運行中的Java虛擬機有著一個清晰的任務:執行Java程式。
  • 程式開始執行時他才運行,程式結束時他就停止。
  • 執行一個所謂的Java程式的時候,真真正正在執行的是一個叫做Java虛擬機的進程。

虛擬機的退出

有如下的幾種情況:

  • 程式正常執行結束
  • 程式在執行過程中遇到了異常或錯誤而異常終止
  • 由於作業系統出現錯誤而導致Java虛擬機進程終止
  • 某執行緒調用Runtime類或System類的exit方法,或Runtime類的halt方法,並且Java安全管理器也允許這次exi t或halt操作。
  • 除此之外,JNI ( Java Native Interface) 規範描述了用JNI Invocation API來載入或卸載Java虛 擬機時,Java虛擬機的退出情況。

1.6 JVM發展歷程

Sun Classic VM

早在1996年Java1.0版本的時候,Sun公司發布了一款名為Sun Classic VM的Java虛擬機,它同時也是世界上第一款商用Java虛擬機,JDK1.4時完全被淘汰。

這款虛擬機內部只提供解釋器。

如果使用JIT編譯器,就需要進行外掛。但是一旦使用了JIT編譯器,JIT就會接管虛擬機的執行系統。解釋器就不再工作。解釋器和編譯器不能配合工作。

現在hotspot內置了此虛擬機。

Exact VM

為了解決上一個虛擬機問題,jdk1.2時,sun提供了此虛擬機。

Exact Memory Management:準確式記憶體管理

  • 也可以叫Non-Conservative/Accurate Memory Management

  • 虛擬機可以知道記憶體中某個位置的數據具體是什麼類型。

具備現代高性能虛擬機的雛形

  • 熱點探測

  • 編譯器與解釋器混合工作模式

只在Solaris平台短暫使用,其他平台上還是classic vm

  • 英雄氣短,終被Hotspot虛擬機替換

HotSpot VM

HotSpot歷史

  • 最初由一家名為「Longview Technologies”的小公司設計
  • 1997年,此公司被Sun收購; 2009年,Sun公司被甲骨文收購。
  • JDK1.3時,HotSpot VM成為默認虛擬機

目前Hotspot佔有絕對的市場地位稱霸武林。

  • 不管是現在仍在廣泛使用的JDK6,還是使用比例較多的JDK8中,默認的虛擬機都是
    HotSpot
  • Sun/Oracle JDK和OpenJDK的默 認虛擬機
  • 因此本課程中默認介紹的虛擬機都是Hotspot,相關機制也主要是指HotSpot的GC機
    制。(比如其他兩個商用虛擬機都沒有方法區的概念)

從伺服器、桌面到移動端、嵌入式都有應用。

名稱中的HotSpot指的就是它的熱點程式碼探測技術。

  • 通過計數器找到最具編譯價值程式碼,觸發即時編譯或棧上替換
  • 通過編譯器與解釋器協同工作,在最優化的程式響應時間與最佳執行性能中取得平衡

BEA的JRockit

  • 專註於伺服器端應用

    • 它可以不太關注程式啟動速度,因此JRockit內部不包含解析器實現,全部程式碼
      都靠即時編譯器編譯後執行。
  • 大量的行業基準測試顯示,JRockit JVM是 世界上最快的JVM。

    • 使用JRockit產品,客戶已經體驗到了顯著的性能提高(一些超過了70%)和硬體成本的減少(達50%) 。
  • 優勢:全面的Java運行時解決方案組合

    • JHlockit面向延遲敏感型應用的解決方案JRockit Real Time提供以亳秒或
      微秒級的JVM響應時間,適合財務、軍事指揮、電信網路的需要

    • MissionContro1服務套件,它是一組以極低的開銷來監控、管理和分析生產
      環境中的應用程式的工具。

  • 2008年,BEA被Oracle收購。

  • Oracle表達了整合兩大優秀虛擬機的工作,大致在JDK 8中完成。整合的方式是在Hotspot的基礎上,移植JRockit的優秀特性。

  • 高斯林:目前就職於Google,研究人工智慧和水下機器人

IBM的 J9

  • 全稱: IBM Technology for Java Virtual Machine,簡稱IT4J,內部代號: J9

  • 市場定位與Hotspot接近,伺服器端、桌面應用、嵌入式等多用途VM

  • 廣泛用於IBM的各種Java產品。

  • 目前,有影響力的三大商用虛擬機之一,也號稱是世界上最快的Java虛擬機。

  • 2017年左右,IBM發布了開源J9 VM,命名為openJ9,交給Eclipse基金會管理,也稱為Eclipse OpenJ9

2. 類載入子系統

將描述類的數據從Class文件載入到記憶體,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這個過程稱為虛擬機的類載入機制。

image-20220405152932481
image-20220405153433382

類的生命周期

一個類型從被載入到虛擬機記憶體中開始,到卸載出記憶體為止,它的整個生命周期將會經歷載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)七個階段,其中驗證、準備、解析三個部分統稱為鏈接(Linking)

image-20220411111726350

類載入器子系統的作用

  • 類載入器子系統負責從文件系統或者網路中載入Class文件,class文件在文件開頭有特定的文件標識。

  • ClassLoader只負責class文件的載入,至於它是否可以運行,則由Execution Engine決定。

  • 載入的類資訊存放於一塊稱為方法區的記憶體空間。除了類的資訊外,方法區中還會存放運行時常量池資訊,可能還包括字元串字面量和數字常量(這部分常量資訊是Class文件中常量池部分的記憶體映射)

ClassLoader角色

  1. class file 存在於本地硬碟上,可以理解為設計師畫在紙上的模板,而最終這個模板在執行的時候是要載入到JVM當中來根據這個文件實例化出n個一模一樣的實例。
  2. class file 載入到JVM中,被稱為DNA元數據模板,放在方法區。
  3. 在.class文件—> JVM —>最終成為元數據模板,此過程就要一個運輸工具(類裝載器Class Loader), 扮演一個快遞員的角色。

2.1 類的載入過程

image-20220405153639661

載入

  1. 通過一個類的全限定名預取定義此類的二進位位元組流

  2. 將這個位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構

  3. 在記憶體中生成一個代表這個類的java. lang.Class對象,作為方法區這個類的各種數據的訪問入口

載入階段結束後,Java虛擬機外部的二進位位元組流就按照虛擬機所設定的格式存儲在方法區之中了,方法區中的數據存儲格式完全由虛擬機實現自行定義,《Java虛擬機規範》未規定此區域的具體 數據結構。類型數據妥善安置在方法區之後,會在Java堆記憶體中實例化一個java.lang.Class類的對象, 這個對象將作為程式訪問方法區中的類型數據的外部介面。

鏈接

  • 驗證(Verify)

    • 目的在於確保class文件的位元組流中包含資訊符合當前虛擬機要求,保證被載入類的正確性,不會危害虛擬機自身安全。

    • 主要包括四種驗證,文件格式驗證,元數據驗證,位元組碼驗證,符號引用驗證。

  • 準備(Prepare)

    • 為類變數(即靜態變數,static修飾的)分配記憶體並且設置該類變數的默認初始值,即零值。

    • 這裡不包含用final修飾的static, 因為final在編譯的時候就會分配了

    • 這裡不會為實例變數分配初始化(類還未載入),類變數會分配在方法區中,而實例變數是會隨著對象一起分配到Java堆中。

  • 解析(Resolve)

    • 將常量池內的符號引用轉換為直接引用的過程

      • 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。

      • 直接引用(Direct References):直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。

    • 事實上,解析操作往往會伴隨著JVM在執行完初始化之後再執行

    • 解析動作主要針對類或介面、欄位、類方法、介面方法、方法類型等。對應常量池中的CONSTANT Class info、 CONSTANT Fieldref info、 CONSTANT Methodref info等

初始化

直到初始化階段,Java虛擬機才真正開始執行類中編寫的Java程式程式碼,將主導權移交給應用程式。

  • 初始化階段就是執行類構造器方法<clinit>()的過程。

    • <clinit>()此方法不需定義,是javac編譯器自動收集類中的所有類變數的賦值動作靜態程式碼塊中的語句(static{}塊)合併而來。類變數指的是static修飾的變數,未用static修飾的是實例變數。

      編譯器收集的順序是由語句在源文件中出現的順序決定的

      public class Test{
      	static {
      		a = 10; // 可以賦值
              System.out.println(a); // 非法前向引用,不能訪問
      	}
          static int a = 9; // a初始化為9,因為9的賦值晚於10
      }
      
  • 此方法不是必需的,如果一個類中沒有靜態語句塊,也沒有對類變數的賦值操作,就不會生成

  • <clinit>()不同於類的構造器。(關聯: 構造器是虛擬機視角下的<init>())

  • 若該類具有父類,JVM會保證子類的<clinit>()執行前,父類的<clinit>()已經執行完畢。

  • 虛擬機必須保證一個類的<clinit>()方法在多執行緒下被同步加鎖。(只會被載入一次)

2.2 類載入器的分類

Java虛擬機設計團隊有意把類載入階段中的「通過一個類的全限定名來獲取描述該類的二進位位元組流」這個動作放到Java虛擬機外部去實現,以便讓應用程式自己決定如何去獲取所需的類。實現這個動作的程式碼被稱為「類載入器」(Class Loader)。

在Java虛擬機的角度來看,只存在兩種不同的類載入器:一種是啟動類載入器(Bootstrap ClassLoader),這個類載入器使用C++語言實現,是虛擬機自身的一部分;另外一種就是其他所有的類載入器,這些類載入器都由Java語言實現,獨立存在於虛擬機外部,並且全都繼承自抽象類 java.lang.ClassLoader。

無論類載入器的類型如何劃分,在程式中我們最常見的類載入器始終只有3個,如下所示:

image-20220411114908811

這四者之間的關係是包含關係,不是上下級關係,也不是繼承關係。

啟動類載入器

Bootstrap ClassLoader

  • 這個類載入使用C/C++語言實現的,嵌套在JVM內部。

  • 它用來載入Java的核心庫(JAVA HOME/jre/lib/rt.jar.resources. jar或sun. boot . class.path路徑下的內容) , 用於提供JVM自身需要的類

  • 並不繼承自java. lang. ClassLoader,沒有父載入器。

  • 載入擴展類和應用程式類載入器,並指定為他們的父類載入器。

  • 出於安全考慮,Bootstrap啟動類載入器只載入包名為java、javax、sun等開頭的類

擴展類載入器

Extension ClassLoader

  • Java語言編寫,由sun.misc.Launcher$ExtClassLoader實現。

  • 派生於ClassLoader類

  • 父類載入器為啟動類載入器

  • 從java .ext . dirs系統屬性所指定的目錄中載入類庫,或從JDK的安裝目錄的jre/]ib/ext子目錄(擴展目錄)下載入類庫。如果用戶創建的JAR放在此目錄下,也會自動由擴展類載入器載入。

應用程式類載入器

System ClassLoader

  • java語言編寫,由sun.misc. Launcher$AppClassLoader實現

  • 派生於ClassLoader類

  • 父類載入器為擴展類載入器

  • 它負責載入環境變數classpath或系統屬性java.class.path 指定路徑下的類庫

  • 該類載入是程式中默認的類載入器,一般來說,Java應用的類都是由它來完成載入

  • 通過ClasLoader#getSystemClassLoader()方法可以獲取到該類載入器

用戶自定義類載入器實現步驟

  1. 開發人員可以通過繼承抽象類java. lang.ClassLoader類的方式,實現自己的類載入器,以滿足一些特殊的需求
  2. 在JDK1.2之前,在自定義類載入器時,總會去繼承ClassLoader類並重寫loadClass ()方法,從而實現自定義的類載入類,但是在JDK1.2之後已不再建議用戶去覆蓋loadClass()方法,而是建議把自定義的類載入邏輯寫在findClass()方法中
  3. 在編寫自定義類載入器時,如果沒有太過於複雜的需求,可以直接繼承URLClassLoader類,這樣就可以避免自己去編寫findClass()方法及.其獲取位元組碼流的方式,使自定義類載入器編寫更加簡潔。

ClassLoader

是一個抽象類,其後所有的類載入器都繼承自ClassLoader(不包括啟動類載入器)

2.3 雙親委派機制

Java虛擬機對class文件採用的是按需載入的方式,也就是說當需要使用該類時才會將它的class文件載入到記憶體生成class對象。而且載入某個類的class文件時,Java虛擬機採用的是雙親委派模式,即把請求交由父類處理,它是一種任務委派模式。

工作原理

  1. 如果一個類載入器收到了類載入請求它並不會自己先去載入,而是把這個請求委託給父類的載入器去執行
  2. 如果父類載入器還存在其父類加,載器,則進一步向上委託,依次遞歸,請求最終將到達項層的啟動類載入器
  3. 如果父類載入器可以完成類載入任務,就成功返回,倘若父類載入器無法完成此載入任務,子載入器才會嘗試自己去載入,這就是雙親委派模式。

image-20220407212143233

優勢

  1. 避免類的重複載入
  2. 保護程式安全,防止核心API被篡改
    • 自定義 java.lang.String

沙箱安全機制

自定義String類,但是在載入自定義String類的時候會率先使用引導類載入器載入,而引導類載入器在載入的過程中會先載入jdk自帶的文件(rt.jar包中java\lang\String.class),報錯資訊說沒有main方法,就是因為載入的是rt.jar包中的String類。這樣可以保證對java核心源程式碼的保護,這就是沙箱安全機制。

JVM必須知道一個類型是由啟動載入器載入的還是由用戶類載入器載入的。如果一個類型是由用戶類載入器載入的,那麼JVM會將這個類載入器的一個引用作為類型資訊的一”部分保存在方法區中。當解析一個類型到另一個類型的引用的時候,JVM需要保證這兩個類型的類載入器是相同的。|

3. 運行時數據區

image-20220405165020849
image-20220405165150523

  • 紅色區域:執行緒共享
  • 灰色區域:執行緒私有

Class Runtime:一個Java程式只有一個Runtime實例

3.1 程式計數器

Program Counter Register(PC暫存器)

JVM中的PC暫存器是對物理PC暫存器的一種抽象模擬

介紹

  • 一塊很小的記憶體空間,幾乎可以忽略不記,運行速度最快的存儲區域
  • 它是程式控制流的指示器,分支、循環、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。
  • 位元組碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令。
  • 如果執行緒正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機位元組碼指令的地址;如果正在執行的是本地(Native)方法,這個計數器值則應為空(Undefined)。

作用

  • 用來存儲指向下一條JVM指令的地址

特點

  • 執行緒私有,與執行緒生命周期一致
  • 此記憶體區域是唯一一個在《Java虛擬機規範》中沒有規定任何OutOfMemoryError情況的區域。

問題

  1. PC暫存器有什麼用?

    因為CPU需要不停的切換各個執行緒,這時候切換回來以後,就得知道接著從哪開始繼續執行。

    JVM的位元組碼解釋器就需要通過改變PC暫存器的值來明確下一條應該執行什麼樣的位元組碼指令。

  2. 為什麼設定為執行緒私有?

    為了能夠準確地記錄各個執行緒正在執行的當前位元組碼指令地址,最好的辦法自然是為每一個執行緒都分配一個PC暫存器,這樣一來各個執行緒之間便可以進行獨立計算,從而不會出現相互干擾的情況。

    由於CPU時間片輪限制,眾多執行緒在並發執行過程中,任何一個確定的時刻,一個處理器或者多核處理器中的一個內核,只會執行某個執行緒中的一條指令。

這樣必然導致經常中斷或恢復,如何保證分毫無差呢?每個執行緒在創建後,都會產生自己的程式計數器和棧幀,程式計數器在各個執行緒之間互不影響。

3.2 虛擬機棧

Java Virtual Machine Stacks (Java 虛擬機棧)

棧是運行時的單位,而堆是存儲的單位

定義

  • 每個執行緒在創建時都會創建一個虛擬機棧,其內部保存一個個的棧幀(Stack Frame),對應一次次的Java方法調用

  • 是執行緒私有的,生命周期與執行緒一致

  • 每個執行緒只能有一個活動棧幀,對應著當前正在執行的那個方法(棧的頂部)

  • 主管Java程式的運行,保存方法的局部變數(基本數據類型,引用類型的地址)、部分結果,並參與方法的調用和返回

  • 注意

    • 棧溢出(StackOverflowError, OutOfMemoryError)
    • 垃圾回收不涉及棧
    • 棧記憶體分配不是越大越好
    • 方法內的局部變數是否執行緒安全?
      • 如果只有一個執行緒才可以操作此數據,則是執行緒安全的
      • 如果多個執行緒操作此數據,則此數據是共享數據,如果不考慮同步機制,會存在執行緒安全問題
      • 如果方法內局部變數沒有逃離方法的作用訪問,則安全
      • 外部傳入或者返回到外部,則不安全

特點

  • 棧是一種快速有效的分配存儲方式,訪問速度僅次於程式計數器
  • 只存在兩種操作:入棧和出
  • 不存在垃圾回收問題

異常

  • Java虛擬機規範允許Java棧的大小是動態的或者固定不變的

    • 如果採用固定大小的棧,可能會出現StackOverflowError異常

      棧幀過多導致棧記憶體溢出、棧幀過大

      • 遞歸循環調用
    • 如果採用動態擴展的棧,可能會出現OutOfMemoryError異常

棧的存儲單位

  • 棧中的數據以棧幀的格式存在,每個方法對應一個棧幀
  • 棧幀是一個記憶體區塊,是一個數據集
  • 內部結構
    • 局部變數表(Local Variables)
    • 操作數棧(Operand Stack)
    • 動態鏈接(Dynamic Linking)(指向運行時常量池的方法引用)
    • 方法返回地址(Return Address)(或方法正常退出或者異常退出的定義)
    • 一些附件資訊

局部變數表

  • 定義為一個數字數組,主要用於存儲方法參數和定義在方法體內的局部變數,這些數據類型包括各類基本數據類型、對象引用,以及returnAddress類型
  • 局部變數所需的容量大小是在編譯期間確定下來的
  • 最基本的存儲單元是Slot(變數槽)32位的類型佔一個slot,64位的類型佔用兩個slot
  • 如果當前幀是由構造方法或者實例方法創建的,那麼該對象引用this將會存放在index為0的slot處,其餘的參數按照參數表順序繼續排列。
  • 棧幀中的局部變數表中的槽位是可以重用的,如果一個局部變數過了其作用域,那麼在其作用域之後申明的新的局部變數就很有可能會復用過期局部變數的槽位,從而達到節省資源的目的。

操作數棧

  • 在方法執行過程中,根據位元組碼指令,往棧中寫入數據或提取數據,即入棧、出棧
  • 主要用於保存計算過程的中間結果,同時作為計算過程中變數臨時的存儲空間
  • 如果被調用的方法帶有返回值的話,其返回值將會被壓入當前棧幀的操作數棧,並更新PC暫存器中下一條需要執行的位元組碼指令
  • Java虛擬機的解釋引擎是基於棧的執行引擎,棧就是操作數棧
  • 由於操作數是存儲在記憶體中,因此頻繁地執行記憶體讀寫會影響執行速度。為了解決這個問題,HotSpot JVM的設計者提出了棧頂快取(ToS,Top-of-stack Cashing)技術,將棧頂元素全部快取在物理CPU的暫存器中,以此降低對記憶體的讀寫次數,提升執行引擎的執行效率。

程式碼實例

public class operandTest {
    public void test() {
        byte i = 15;
        int j = 8;
        int k = i + j;
    }
}

image-20220406175204135
image-20220406175217291
image-20220406175339245
image-20220406175352815

動態鏈接

  • 每一個棧幀內部都包含一個指向運行時常量池中該棧幀所屬方法的引用。包含這個引用的目的就是為了支援當前方法的程式碼能夠實現動態鏈接(Dynamic Linking) 。比如: invokedynamic指令
  • 在Java源文件被編譯到位元組碼文件中時,所有的變數和方法引用都作為符號引用(Symbolic Reference)保存在class文件的常量池裡。比如:描述一個方法調用了另外的其他方法時,就是通過常量池中指向方法的符號引用來表示的,那麼動態鏈接的作用就是為了將這些符號引用轉換為調用方法的直接引用。

方法的調用

在JVM中,將符號引用轉換為調用方法的直接引用與方法的綁定機制相關。

  • 靜態鏈接: 當一個位元組碼文件被裝載進JVM內部時,如果被調用的目標方法在編譯期可知,且運行期保持不變時。這種情況下將調用方法的符號引用轉換為直接引用的過程稱之為靜態鏈接。

  • 動態鏈接: 如果被調用的方法在編譯期無法被確定下來,也就是說,只能夠在程式運行
    期將調用方法的符號引用轉換為直接引用,由於這種引用轉換過程具備動態性,因此也就被稱之為動態鏈接。

虛擬機中提供了以下幾條方法調用指令:

  • 普通調用指令:

    1. invokestatic: 調用靜態方法,解析階段確定唯一方法版本

    2. invokespecial: 調用<init>方法、私有及父類方法,解析階段確定唯一方法版本

    3. invokevirtual: 調用所有虛方法

    4. invokeinterface: 調用介面方法

  • 動態調用指令:

    1. invokedynamic: 動態解析出需要調用的方法,然後執行

前四條指令固化在虛擬機內部,方法的調用執行不可人為干預,而invokedynamic指令則支援由用戶確定方法版本。其中invokestatic指令和invokespecial指令調用的方法稱為非虛方法,其餘的(final修飾的除外)稱為虛方法。

動態類型語言靜態類型語言兩者的區別:

就在於對類型的檢查是在編譯期還是在運行期,滿足前者就是靜態類型語言,反之是動態類型語言。說的再直白一點就是,靜態類型語言是判斷變數自身的類型資訊;動態類型語言是判斷變數值的類型資訊,變數沒有類型資訊,變數值才有類型資訊,這是動態語言的一個重要特徵。

Lambda的引入使得Java具備了動態類型語言的特性。總體來說還是靜態。

方法返回地址

  • 存放調用該方法的pc暫存器的值
  • 無論通過哪種方式退出,在方法退出後都返回到該方法被調用的位置。方法正常退出時,調用者的pc計數器的值作為返回地址,即調用該方法的指令的下一條指令的地址。而通過異常退出的,返回地址是要通過異常表來確定,棧幀中一般不會保存這部分資訊。

執行緒運行診斷

  1. CPU佔用過高

    • 用top定位哪個進程對cpu的佔用過高
    • ps H -eo pid,tid,%CPU | grep pid
    • jstack pid
      • 可以根據執行緒id找到有問題的執行緒,進一步定位到問題程式碼的源碼行號
  2. 程式運行很長時間沒有結果

    使用jstack pid查看進程的運行情況,可發現死鎖

    程式發生了死鎖

    image-20220405162524813

3.3 本地方法棧

Native Method Stacks

Java虛擬機棧用於管理Java方法的調用,而本地方法棧用於管理本地方法的調用。也是執行緒私有的。

image-20220407103932282

Native Method

簡單地講,一個Native Method就是一個Java調用非Java程式碼的介面。一個Native Method是這樣一個Java方法: 該方法的實現由非Java語言實現,比如C。這個特徵並非Java所特有,很多其它的程式語言都有這一機制,比如在C++中,你可以用extern “C”告 知C+ +編譯器去調用一個C的函數。

“A native method is a Java method whose implementation is provided by non-java code.”

在定義一個native method時,並不提供實現體(有些像定義一個Java interface,因為其實現體是由非java語言在外面實現的。本地介面的作用是融合不同的程式語言為Java所用,它的初衷是融合C/C++程式 。

為什麼要使用Native Method?

Java使用起來非常方便,然而有些層次的任務用Java實現起來不容易,或者我們對程式的效率很在意時,問題就來了。

  1. 與Java環境外交互
    有時Java應用需要與Java外面的環境交互,這是本地方法存在的主要原因。你可以想想Java需要與一些底層系統,如作業系統或某些硬體交換資訊時的情況。本地方法正是這樣一種交流機制:它為我們提供了一個非常簡潔的介面,而且我們無需去了解Java應用之外的繁瑣的細節。

  2. 與作業系統交互
    JVM支援著Java語言本身和運行時庫,它是Java程式賴以生存的平台,它由一個解釋器(解釋位元組碼)和一些連接到本地程式碼的庫組成。然而不管怎樣,它畢竟不是一個完整的系統,它經常依賴於一些底層系統的支援。這些底層系統常常是強大的作業系統。通過使用本地方法,我們得以用Java實現了jre的與底層系統的交互,甚至JVM**的一些部分就是用c寫的。還有,如果我們要使用一些Java語言本身沒有提供封裝的作業系統的特性時,我們也需要使用本地方法。

  3. Sun’s Java
    Sun的解釋器是用c實現的,這使得它能像一些普通的C一樣與外部交互。jre大部分是用Java實現的,它也通過一些本地方法與外界交互。例如:類java. lang.Thread的setPriority() 方法是用Java實現的,但是它實現調用的是該類里的本地方法setPriority0()。這個本地方法是用C實現的,並被植入JVM內部,在Windows 95的平台上,這個本地方法最終將調用win32 SetPriority() API. 這是一個本地方法的具體實現由JVM直接提供,更多的情況是本地方法由外部的動態鏈接庫(external dynamic link library) 提供,然後被JVM調用。

  4. 現狀

    目前該方法使用的越來越少了,除非是與硬體有關的應用,比如通過Java程式驅動印表機或者Java系統管理生產設備,在企業級應用中已經比較少見。因為現在的異構領域間的通訊很發達,比如可以使用Socket通訊,也可以使用web Service等等,不多做介紹。

概述

  • 當某個執行緒調用一個本地方法時,它就進入了一個全新的並且不再受虛擬機限制的世界。它和虛擬機擁有同樣的許可權。

    • 本地方法可以通過本地方法介面來訪問虛擬機內部的運行時數據區

    • 它甚至可以直接使用本地處理器中的暫存器

    • 直接從本地記憶體的堆中分配任意數量的記憶體。

  • 並不是所有的JVM都支援本地方法。因為Java虛擬機規範並沒有明確要求本地方法棧的使用語言、具體實現方式、數據結構等。如果JVM產品不打算支援native方法,也可以無需實現本地方法棧。

    • 在Hotspot JVM中, 直接將本地方法棧和虛擬機棧合二為一 。

3.4 堆

heap 執行緒共享

概述

  • 一個JVM實例只存在一個堆記憶體,堆也是Java記憶體管理的核心區域。

  • Java堆區在JVM啟動的時候即被創建,其空間大小也就確定了。是JVM管理的最大一塊記憶體空間。

    • 堆記憶體的大小是可以調節的。
  • 《Java虛擬機規範》規定,堆可以處於物理上不連續的記憶體空間中,但在邏輯上它應該被視為連續的。

  • 所有的執行緒共享Java堆,在這裡還可以劃分執行緒私有的緩衝區(Thread Local Allocation Buffer, TLAB) 。

  • 《Java虛擬機規範》中對Java堆的描述是:所有的對象實例以及數組都應當在運行時分配在堆上。(The heap is the run-time data area from which memory for a1ll class instances and arrays is allocated )

    • 我要說的是:「幾乎」 所有的對象實例都在這裡分配記憶體。從實際使用角度看的。
  • 數組和對象可能永遠不會存儲在棧上,因為棧幀中保存引用,這個引用指向對象或者數組在堆中的位置。

  • 在方法結束後,堆中的對象不會馬上被移除,僅僅在垃圾收集的時候才會被移除。

  • 堆,是GC ( Garbage Collection,垃圾收集器)執行垃圾回收的重點區域。

image-20220407113547839

通過new關鍵字,創建對象都會使用堆記憶體

記憶體細分

現代垃圾收集器大部分都基於分代收集理論設計,堆空間細分為:

image-20220407114212304
image-20220407114342044

設置參數

  1. Java堆區用於存儲Java對象實例,那麼堆的大小在JVM啟動時就已經設定好了,大家可以通過選項”-Xmx“和” -Xms“來進行設置。

    -Xms“用於表示堆區的起始記憶體,等價於-XX: InitialHeapSize
    -Xmx“則用於表示堆區 的最大記憶體,等價於-XX :MaxHeapSize

    一旦堆區中的記憶體大小超過「-Xmx」所指定的最大記憶體時,將會拋出OutOfMemoryError異常。

  2. 通常會將-Xms-Xmx兩個參數配置相同的值,其目的是為了能夠在java垃圾回收機制清理完堆區後不需要重新分隔計算堆區的大小,從而提高性能。

  3. 默認情況下,初始記憶體大小:物理電腦記憶體大小/64

    最大記憶體大小:物理電腦記憶體大小/4

  4. 查看設置的參數:

    • 方式一:jps / jstat -gc 進程id
    • 方式二:-XX:+PrintGCDetails

年輕代與老年代

  • 存儲在JVM中的Jaya對象可以被劃分為兩類:

    • 一類是生命周期較短的瞬時對象,這類對象的創建和消亡都非常迅速

    • 另外一類對象的生命周期卻非常長,在某些極端的情況下還能夠與JVM的生命周期
      保持一致。

  • Java堆區進一步細分的話, 可以劃分為年輕代(YoungGen) 和老年代(0ldGen)

  • 其中年輕代又可以劃分為Eden空間、Survivor0空間和Survivor1空間(有時也叫做
    from區、to區)

  • 新生代與老年代的比例:NewRatio 默認是1:2

  • Eden與survivor區的比例:SurvivorRatio默認是8:1:1

  • 幾乎所有的對象都是在Eden區被new出來

  • 絕大部分的Java對象的銷毀都在新生代進行了

image-20220407140220066

圖解對象分配過程

為新對象分配記憶體是一件非常嚴謹和複雜的任務,JVM的設計者們不僅需要考慮記憶體如何分
配、在哪裡分配等問題,並且由於記憶體分配演算法與記憶體回收演算法密切相關,所以還需要考
慮GC執行完記憶體回收後是否會在記憶體空間中停生記憶體碎片。

  1. new的對象先放伊甸園區。此區有大小限制。
  2. 當伊甸園的空間填滿時,程式又需要創建對象,JVM的垃圾回收器將對伊甸園區進行垃
    圾回收(Minor GC),將伊甸園區中的不再被其他對象所引用的對象進行銷毀。
  3. 再載入新的對象放到伊甸園區然後將伊甸園中的剩餘對象移動到倖存者0區。
  4. 如果再次觸發垃圾回收,此時上次倖存下來的放到倖存者0區的,如果沒有回收,就會
    放到倖存者1區.
  5. 如果再次經歷垃圾回收,此時會重新放回倖存者0區,接著再去倖存者1區。
  6. 啥時候能去養老區呢?可以設置次數。默認是15次。
    可以設置參數: -XX :MaxTenudingThreshold=<N>進行設置。
  7. 關於垃圾回收:頻繁在新生區收集,很少在養老區收集,幾乎不在永久區\元空間收集

image-20220407142008557
image-20220407142511120

Minor GC Major GC Full GC

JM在進行GC時,並非每次都對上面三個記憶體區域(新生代老年代、方法區)一起回收的,大部分時候回收的都是指新生代。

針對HotSpot VM的實現,它裡面的GC按照回收區域又分為兩大種類型:一種是部分收集
(Partial GC), 一種是整堆收集(Full GC)

  • 部分收集:不是完整收集整個Jva堆的垃圾收集。其中又分為:

    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集

    • 老年代收集(Major GC/old GC):只是老年代的垃圾收集。

      • 目前,只有CMS GC會有單獨收集老年代的行為。
      • 注意,很多時候Major GC會和Full GC混淆使用,需要具體分辨是老年代回收還是整堆回收。
    • 混合收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集。

      • 目前,只有G1 GC會有這種行為
  • 整堆收集(Full GC):收集整個iava堆和方法區的垃圾收集。

年輕代GC(Minor GC )觸發機制

  • 當年輕代空間不足時,就會觸發Minor GC,這裡的年輕代滿指的是Eden代滿,Survivor滿不會引發GC。(每次 Minor GC會清理年輕代的記憶體。)

  • 因為Java對象大多都具備朝生夕滅的特性,所以MinorGC非常頻繁,一般回收速度也比較快。這一定義既清晰又易於理解。

  • Minor GC會引發STW,暫停其它用戶的執行緒,等垃圾回收結束,用戶執行緒才恢復運行。

老年代GC (Major GC/Full GC)觸發機制

  • 指發生在老年代的GC,對象從老年代消失時,我們說「Major GC」或「Full GC」發生了。

  • 出現了Major GC,經常會伴隨至少一次的Minor GC (但非絕對的,在Parallel Scavenge收集器的收集策略里就有直接進行Major GC的策略選擇過程)。

    • 也就是在老年代空間不足時,會先嘗試觸發Minor GC。如果之後空間還不足,則觸發Major GC
  • Major GC的速度一般會比Minor GC慢10倍以上,STW的時間更長。

  • 如果Major GC後,記憶體還不足,就報OOM了。

  • Major GC的速度一般會比Minor GC慢10倍以上。

Full GC觸發機制: (後面細講)

觸發Full GC執行的情況有如下五種:

  1. 調用System. gc()時,系統建議執行Full GC,但是不必然執行

  2. 老年代空間不足

  3. 方法區空間不足

  4. 通過Minor GC後進入老年代的平均大小大於老年代的可用記憶體

  5. 由Eden區、survivor space0 (From Space)區向survivor space1 (To Space)區複製時,對象大小大於To Space可用記憶體,則把該對象轉存到老年代,且老年代的可用記憶體小於該對象大小

說明: full gc是開發或調優中盡量要避免的。這樣暫時時間會短一些。

堆空間分代思想:分代的唯一理由是優化GC性能,避免每次GC都要掃描每個對象

記憶體分配策略

針對不同年齡段的對象分配原則如下所示:

  • 優先分配到Eden

  • 大對象直接分配到老年代

    • 盡量避免程式中出現過多的大對象
  • 長期存活的對象分配到老年代

  • 動態對象年齡判斷

    • 如果Survivor 區中相同年齡的所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。
  • 空間分配擔保

    • -XX: HandlePromotionFailure

TLAB(Thread Local Allocation Buffer)

為對象分配記憶體

為什麼需要TLAB?

  • 堆區是執行緒共享區域,任何執行緒都可以訪問到堆區中的共享數據

  • 由於對象實例的創建在JVM中非常頻繁,因此在並發環境下從堆區中劃分記憶體空間是執行緒不安全的

  • 為避免多個執行緒操作同一地址,需要使用加鎖等機制,進而影響分配速度。

定義

  • 從記憶體模型而不是垃圾收集的角度,對Eden區域繼續進行劃分,JVM為每個執行緒分配了一個私有快取區域,它包含在Eden空間內。

  • 多執行緒同時分配記憶體時,使用TLAB可以避免一系列的非執行緒安全問題,同時還能夠提升記憶體分配的吞吐量,因此我們可以將這種記憶體分配方式稱之為快速分配策略

  • 所有OpenJDK衍生出來的JVM都提供了TLAB的設計。

image-20220407150606644

說明

  • 儘管不是所有的對象實例都能夠在TLAB中成功分配記憶體,但JVM確實是將TLAB作為
    記憶體分配的首選

  • 在程式中,開發人員可以通過選項「-XX:UseTLAB」設置是否開啟TLAB空間。

  • 默認情況下,TLAB空間的記憶體非常小,僅佔有整個Eden空間的1%,當然我們可以通
    過選項「-XX :TLABWasteTargetPercent”設置TLAB空間所佔用Eden空間的百分比大小。

  • 一旦對象在TLAB空間分配記憶體失敗時,JVM就會嘗試著通過使用加鎖機制確保數據操
    作的原子性,從而直接在Eden空間中分配記憶體。

image-20220407150938551

堆空間的參數設置

官網說明書

  • -XX:+PrintFlagsInitial :查看所有的參數的默認初始值

  • -XX: +PrintFlagsFinal :查看所有的參數的最終值(可能會存在修改,不再是初始值)

    • 具體查看某個參數的指令:
      • jps 查看運行進程
      • jinfo -flag SurvivorRatio 進程id
  • -Xms: 初始堆空間記憶體 (默認為物理記憶體的1/64)

  • -Xmx: 最大堆空間記憶體(默認為物理記憶體的1/4)

  • -Xmn: 設置新生代的大小。(初始值及最大值)

  • -xx:NewRatio: 配置新生代與老年代在堆結構的佔比

  • -XX:SurvivorRatio: 設置新生代中Eden和S0/S1空間的比例

  • -XX:MaxTenuringThreshold: 設置新生代垃圾的最大年齡

  • -Xx:+PrintGCDetails: 輸出詳細的GC處理日誌

    列印gc簡要資訊: -XX:+PrintGC 或者 -verbose:gc

  • -XX:HandlePromotionFailure: 是否設置空間分配擔保

在發生Minor GC之前,虛擬機會檢查老年代最大可用的連續空間是否大於新生代所有對象的總空間。

  • 如果大於, 則此次Minor GC是安全的

  • 如果小於,則虛擬機會查看-Xx: HandlePromot ionFailure設置值是否允許擔保失敗。

    • 如果HandlePromotionFailure=true, 那麼會繼續檢查老年代最大可用連續空間是否大於歷次晉陞到老年代的對象的平均大小。

      • 如果大於,則嘗試進行一次Minor GC,但這次Minor GC依然是有風險的;

      • 如果小於,則改為進行一次Full GC。

    • 如果HandlePromotionFailure=false, 則改為進行一次Full GC。

在JDK6 Update24之 後,HandlePromotionFailure參數不會再影響到虛擬機的空間分配擔保策略,觀察OpenJDK中的源碼變化,雖然源碼中還定義了HandlePromotionFailure參數,但是在程式碼中已經不會再使用它。JDK6 Update24之後的規則變為只要老年代的連續空間大於新生代對象總大小或者歷次晉陞的平均大小就會進行Minor GC,否則將進行Full GC。

堆是分配對象存儲的唯一選擇嗎?

在《深入理解Java虛擬機》中關於Java堆記憶體有這樣一段描述:

隨著JIT編譯期的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化,所有的對象都分配到堆上也漸漸變得不那麼「絕對」了。

在Java虛擬機中,對象是在Java堆中分配記憶體的,這是一個普遍的常識。但是,有一種特殊情況,那就是如果經過逃逸分析(Escape Analysis) 後發現,一個對象並沒有逃逸出方法的話,那麼就可能被優化成棧上分配。這樣就無需在堆上分配記憶體,也無須進行垃圾回收了。這也是最常見的堆外存儲技術。

此外,前面提到的基於openJDK深度訂製的TaoBaoVM,其中創新的GCIH (GC invisible heap)技術實現off-heap,將生命周期較長的Java對象從heap中移至heap外,並且GC不能管理GCIH內部的Java對象,以此達到降低Gc的回收頻率和提升GC的回收效率的目的。

逃逸分析

如何將堆上的對象分配到棧,需要使用逃逸分析手段。

  • 這是一種可以有效減少Java程式中同步負載和記憶體堆分配壓力的跨函數全局數據流分析演算法。

  • 通過逃逸分析,Java Hotspot編譯器能夠分析出一個新的對象的引用的使用範圍從而決定是否要將這個對象分配到堆上。

逃逸分析的基本行為就是分析對象動態作用域

  • 當一個對象在方法中被定義後,對象只在方法內部使用,則認為沒有發生逃逸。

  • 當一個對象在方法中被定義後,它被外部方法所引用,則認為發生逃逸。例如作為調用參數傳遞到其他地方中。

能使用局部變數的,就不要使用在方法外定義

使用逃逸分析,編譯器可以對程式碼做如下優化:

  1. 棧上分配。將堆分配轉化為棧分配。如果一個對象在子程式中被分配,要使指向該對象的指針永遠不會逃逸,對象可能是棧分配的候選,而不是堆分配。

  2. 同步省略。如果一個對象被發現只能從一個執行緒被訪問到,那麼對於這個對象的操作可以不考慮同步。

    執行緒同步的代價是相當高的,同步的後果是降低並發性和性能。在動態編譯同步塊的時候,JIT編譯器可以藉助逃逸分析來判斷同步塊所使用的鎖對象是否只能夠被一個執行緒訪問而沒有被發布到其他執行緒。如果沒有,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分程式碼的同步。這樣就能大大提高並發性和性能。這個取消同步的過程就叫同步省略,也叫鎖消除

  3. 分離對象或標量替換。有的對象可能不需要作為一個連續的記憶體結構存在也可以被訪問到,那麼對象的部分(或全部)可以不存儲在記憶體,而是存儲在CPU暫存器中。

    標量(Scalar)是指一個無法再分解成更小的數據的數據。Java中的原始數據類型就是標量。

    相對的,那些還可以分解的數據叫做聚合量(Aggregate),Java中的對象就是聚合量,因為他可以分解成其他聚合量和標量。

    在JIT階段,如果經過逃逸分析,發現一個對象不會被外界訪問的話,那麼經過JIT優化,就
    會把這個對象拆解成若干個其中包含的若干個成員變數來代替。這個過程就是標量替換。

3.5 方法區

image-20220407155839861

棧、堆、方法區的交互關係

image-20220407160144488
image-20220407160359718

概述

方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域,它用於存儲已被虛擬機載入的類型資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等數據。《Java虛擬機規範》中明確說明:「儘管所有的方法區在邏輯上是屬於堆的一部分,但一些簡單的實現可能不會選擇去進行垃圾收集或者進行壓縮。」但對於HotSpotJVM而言,方法區還有一個別名叫做Non-Heap (非堆),目的就是要和堆分開。所以,方法區看作是一塊獨立於Java堆的記憶體空間

  • 方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域。

  • 方法區在JVM啟動的時候被創建,並且它的實際的物理記憶體空間中和Java堆區一樣都可以是不連續的。

  • 方法區的大小,跟堆空間一樣,可以選擇固定大小或者可擴展。

  • 方法區的大小決定了系統可以保存多少個類,如果系統定義了太多的類,導致方法區溢出,虛擬機同樣會拋出記憶體溢出錯誤: java.lang.OutOfMemoryError: PermGen space或者java.lang.OutOfMemoryError: Metaspace

  • 關閉JVM就會釋放這個區域的記憶體。

image-20220407162225735

  • 到了JDK 8,終於完全廢棄了永久代的概念,改用與JRorkit、J9一樣在本地記憶體中實現的元空間(Metaspace)來代替

  • 元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代最大的區別在於:元空間不在虛擬機設置的記憶體中,而是使用本地記憶體

  • 永久代、元空間二者並不只是名字變了,內部結構也調整了。根據《Java虛擬機規範》的規定,如果方法區無法滿足新的記憶體分配需求時,將拋出OOM異常。

設置方法區大小與OOM

jdk8及以後:

  • 元數據區大小可以使用參數-XX : MetaspaceSize-XX :MaxMetaspaceSize指定,替代上述原有的兩個參數。

  • 默認值依賴於平台。windows下,-XX :MetaspaceSize是21M, -XX:MaxMetaspaceSize的值是-1,即沒有限制。

  • 與永久代不同,如果不指定大小,默認情況下,虛擬機會耗盡所有的可用系統記憶體。
    如果元數據區發生溢出,虛擬機一樣會拋出異常OutOfMemoryError: Metaspace

  • -XX:MetaspaceSize:設置初始的元空間大小。對於一個64位的伺服器端JVM來說,其默認的-XX:MetaspaceSize值為21MB。這就是初始的高水位線,一旦觸及這個水位線,Full GC將會被觸發並卸載沒用的類(即這些類對應的類載入器不再存活) ,然後這個高水位線將會重置。新的高水位線的值取決於GC後釋放了多少元空間。如果釋放的空間不足,那麼在不超過MaxMetaspaceSize時,適當提高該值。如果釋放空間過多,則適當降低該值。

  • 如果初始化的高水位線設置過低,上述高水位線調整情況會 發生很多次。通過垃圾回收器的日誌可以觀察到Full GC多次調用。為了避免頻繁地GC,建議將-XX :MetaspaceSize設置為一個相對較高的值。

如何解決這些OOM?

  1. 要解決OOM異常或heap space的異常,一般的手段是首先通過記憶體映像分析工具(如Eclipse Memory Analyzer)對dump出來的堆轉儲快照進行分析,重點是確認記憶體中的對象是否是必要的,也就是要先分清楚到底是出現了記憶體泄漏(MemoryLeak)還是記憶體溢出(Memory Overflow)
  2. 如果是記憶體泄漏,可進一步通過工具查看泄漏對象到GC Roots 的引用鏈。於是就能找到泄漏對象是通過怎樣的路徑與GCRoots相關聯並導致垃圾收集器無法自動回收它們的。掌握了泄漏對象的類型資訊,以及GC Roots 引用鏈的資訊,就可以比較準確地定位出泄漏程式碼的位置。
  3. 如果不存在記憶體泄漏,換句話說就是記憶體中的對象確實都還必須存活著,那就應當檢查虛擬機的堆參數(-Xmx與-Xms) ,與機器物理記憶體對比看是否還可以調大,從程式碼上檢查是否存在某些對象生命周期過長、持有狀態時間過長的情況,嘗試減少程式運行期的記憶體消耗。

方法區存儲資訊

《深入理解Java虛擬機》書中對方法區(Method Area)存儲內容描述如下: 它用於存儲已被虛擬機載入的類型資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等。

image-20220407163831208
image-20220407163957677

  1. 類型資訊

    對每個載入的類型(類class、介面interface、枚舉enum、註解annotation),JVM必須在方法區中存儲以下類型資訊:
    ①這個類型的完整有效名稱(全名=包名.類名)
    ②這個類型直接父類的完整有效名(對於interface或是java. lang .0bject,都沒有父類)
    ③這個類型的修飾符(public, abstract, final的某個子集)
    ④這個類型直接介面的一個有序列表

  2. 域(Filed)資訊

    JVM必須在方法區中保存類型的所有域的相關資訊以及域的聲明順序。

    域的相關資訊包括:

    • 域名稱、域類型、域修飾符(public, private,
    • protected, static, final, volatile, transient的某個子集)
  3. 方法資訊

    JVM必須保存所有方法的以下資訊,同域資訊一樣包括聲明順序:

    • 方法名稱

    • 方法的返回類型(或volid)

    • 方法參數的數量和類型(按順序)

    • 方法的修飾符(public, private, protected, static, final,synchronized, native, abstract的一個子集)

    • 方法的位元組碼(bytecodes)、操作數棧、局部變數表及大小(abstract和native方法除外)

    • 異常表( abstract和native方法除外)

      每個異常處理的開始位置、結束位置、程式碼處理在程式計數器中的偏移地址、被捕獲的異常類的常量池索引

運行時常量池

運行時常量池(Runtime Constant Pool)是方法區的一部分。Class文件中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池表(Constant Pool Table),用於存放編譯期生成的各種字面量與符號引用,這部分內容將在類載入後存放到方法區的運行時常量池中。

一個java源文件中的類、介面,編譯後產生一個位元組碼文件。而Java中的位元組碼需要數據支援,通常這種數據會很大以至於不能直接存到位元組碼里,換另一種方式,可以存到常量池,這個位元組碼包含了指向常量池的引用。在動態鏈接的時候會用到運行時常量池,之前有介紹。

比如下列程式碼:

public class SimpleClass {
	public void sayHello() {
		System.out.println("hello");
	}
}

雖然只有194位元組,但是裡面卻使用了string、System、 PrintStream及Object等結構。這裡程式碼量其實已經很小了。如果程式碼多,引用到的結構會更多! 這裡就需要常量池了!

幾種在常量池記憶體儲的數據類型包括:

  • 數量值

  • 字元串值

  • 類引用

  • 欄位引用

  • 方法引用

例如下面這段程式碼:

public class MethodAreaTest2 {
	public static void main(String[] args) {
		object obj = new object();
}

object foo = new object () ;

將會被編譯成如下位元組碼:

0:new #2           // Class java/ lang/ object
1:dup
2:invokespecial #3 // Method java/ lang/object "<init>"( ) v

概述

  • 運行時常量池( Runtime Constant Pool) 是方法區的一部分。

  • 常量池表(Constant Pool Table)是Class文件的一 部分,用於存放編譯期生成的各種字面量與符號引用,這部分內容將在類載入後存放到方法區的運行時常量池中

  • 運行時常量池,在載入類和介面到虛擬機後,就會創建對應的運行時常量池。

  • JVM為每個已載入的類型(類或介面)都維護一個常量池。池中的數據項像數組項一樣,是通過索引訪問的。

  • 運行時常量池中包含多種不同的常量,包括編譯期就已經明確的數值字面量,也包括到運行期解析後才能夠獲得的方法或者欄位引用。此時不再是常量池中的符號地址了,這裡換為真實地址。

    • 運行時常量池,相對於Class文件常量池的另一重要特徵是: 具備動態性
  • 運行時常量池類似於傳統程式語言中的符號表(symbol table) ,但是它所包含的數據卻比符號表要更加豐富一些。

  • 當創建類或介面的運行時常量池時,如果構造運行時常量池所需的記憶體空間超過了方法區所能提供的最大值,則JVM會拋outOfMemoryError異常。

方法區的演進細節

image-20220407172301308
image-20220407172741287
image-20220407172753421
image-20220407172813073

為什麼要使用元空間?

隨著Java8的到來,HotSpot VM中再也見不到永久代了。但是這並不意味著類的元數據資訊也消失了。這些數據被移到了一個與堆不相連的本地記憶體區域,這個區域叫做元空間( Metaspace )。

由於類的元數據分配在本地記憶體中,元空間的最大可分配空間就是系統可用記憶體空間。

這項改動是很有必要的,原因有:

  1. 為永久代設置空間大小是很難確定的。

    在某些場景下,如果動態載入類過多,容易產生Perm區的OOM。比如某個實際Web工程中,因為功能點比較多,在運行過程中,要不斷動態載入很多類,經常出現致命錯誤。

    Exception in thread dubbo client x.x connector’ java.lang.OutOfMemoryError: PermGenspace

    而元空間和永久代之間最大的區別在於:元空間並不在虛擬機中,而是使用本地記憶體。因此,默認情況下,元空間的大小僅受本地記憶體限制。

  2. 對永久代進行調優是很困難的。

StringTable為什麼要調整?

jdk7中將stringTable放到了堆空間中。因為永久代的回收效率很低,在full gc的時候才會觸發。而full gc是老年代的空間不足、永久代不足時才會觸發。這就導致StringTable回收效率不高。而我們開發中會有大量的字元串被創建,回收效率低,導致永久代記憶體不足。放到堆里,能及時回收記憶體。

垃圾回收

有些人認為方法區(如HotSpot虛擬機中的元空間或者永久代)是沒有垃圾收集行為的,其實不然。《Java虛擬機規範》 對方法區的約束是非常寬鬆的,提到過可以不要求虛擬機在方法區中實現垃圾收集。事實上也確實有未實現或未能完整實現方法區類型卸載的收集器存在(如JDK 11時 期的zGC收集器就不支援類卸載)。

般來說這個區域的回收效果比較難令人滿意,尤其是類型的卸載,條件相當苛刻。但是這部分區域的回收有時又確實是必要的。以前Sun公司的Bug列表中,曾出現過的若干個嚴重的Bug就是由於低版本的Hotspot虛擬機對此區域未完全回收而導致記憶體泄漏。

方法區的垃圾收集主要回收兩部分內容:常量池中廢棄的常量不再使用的類型

先來說說方法區內常量池之中主要存放的兩大類常量:字面量符號引用。字面量比較接近Java語言層次的常量概念,如文本字元串、被聲明為final的常量值等。而符號引用則屬於編譯原理方面的概念,包括下面三類常量:

  1. 類和介面的全限定名
  2. 欄位的名稱和描述符
  3. 方法的名稱和描述符

HotSpot虛擬機對常量池的回收策略是很明確的,只要常量池中的常量沒有被任何地方引用,就可以被回收。回收廢棄常量與回收Java堆中的對象非常類似。

判定一個常量是否「廢棄」還是相對簡單,而要判定一個類型是否屬於「不再被使用的類」的條件就比較苛刻了。需要同時滿足下面三個條件:

  1. 該類所有的實例都已經被回收,也就是Java堆中不存在該類及其任何派生子類的實例。

  2. 載入該類的類載入器已經被回收,這個條件除非是經過精心設計的可替換類載入器的場景,如OSGi、 JSP的重載入等,否則通常是很難達成的。

  3. 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

Java虛擬機被允許對滿足上述三個條件的無用類進行回收,這裡說的僅僅是「被允許」,而並不是和對象-一樣,沒有引用了就必然會回收。關於是否要對類型進行回收, HotSpot虛擬機提供了-Xnoclassgc參數進行控制,還可以使用-verbose: class以及-XX:+TraceClass-Loading-X:+TraceClassUnLoading查看類載入和卸載資訊

在大量使用反射、動態代理、CGLib等位元組碼框架,動態生成JSP以及0SGi這類頻繁自定義類載入器的場景中,通常都需要Java虛擬機具備類型卸載的能力,以保證不會對方法區造成過大的記憶體壓力。

3.6 小結

image-20220407160048630

image-20220407205905853

3.7 面試題

百度

三面:說一下JVM記憶體模型吧,有哪些區?分別幹什麼的?

螞蟻金服:

Java8的記憶體分代改進

JVM記憶體分哪幾個區,每個區的作用是什麼?

一面: JVM記憶體分布/記憶體結構?棧和堆的區別?堆的結構?為什麼兩個survivor區?

二面: Eden和Survior的比例分配

小米: .

jvm記憶體分區,為什麼要有新生代和老年代

字節跳動: .

二面: Java的記憶體分區

二面:講講jvm運行時資料庫區

什麼時候對象會進入老年代?

京東:

JVM的記憶體結構,Eden 和Survivor比例。

JVM記憶體為什麼要分成新生代,老年代,持久代。新生代中為什麼要分為Eden和Survivor.

天貓:

一面: Jvm記憶體模型以及分區,需要詳細到每個區放什麼。

一面: JVM的記憶體模型,Java8做了什麼修改

拼多多:

JVM記憶體分哪幾個區,每個區的作用是什麼?

美團:

java記憶體分配

jvm的永久代中會發生垃圾回收嗎?

一面: jvm記憶體分區,為什麼要有新生代和老年代?

4. 對象的實例化

image-20220408150103779

4.1 創建對象的步驟

image-20220408150442276

  1. 載入類元資訊

    虛擬機遇到一條new指令,首先去檢查這個指令的參數能否在Metaspace的常量池中定位到一一個類的符號引用,並且檢查這個符號引用代表的類是否已經被載入、解析和初始化。( 即判斷類元資訊是否存在)。如果沒有,那麼在雙親委派模式下,使用當前類載入器以ClassLoader+包名+類名為Key進行查找對應的.class文件。如果沒有找到文件,則拋出ClassNotFoundException異常,如果找到,則進行類載入,並生成對應的Class類對象

  2. 為對象分配記憶體

    首先計算對象佔用空間大小,接著在堆中劃分一塊記憶體給新對象。如果實例成員變數是引用變數,僅分配引用變數空間即可,即4個位元組大小。

    指針碰撞:如果記憶體是規整的,那麼虛擬機將採用的是指針碰撞法( Bump The Pointer )來為對象分配記憶體。意思是所有用過的記憶體在一邊,空閑的記憶體在另外一邊,中間放著一個指針作為分界點的指示器,分配記憶體就僅僅是把指針向空閑那邊挪動一段與對象大小相等的距離罷了。如果垃圾收集器選擇的是Serial、ParNew這種基於壓縮演算法的,虛擬機採用這種分配方式。一股使用帶有compact (整理)過程的收集器時,使用指針碰撞。

    如果記憶體不是規整的,已使用的記憶體和未使用的記憶體相互交錯,那麼虛擬機將採用的是空閑列表法
    來為對象分配記憶體。意思是虛擬機維護了一個列表,記錄上哪些記憶體塊是可用的,再分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的內容。這種分配方式成為”空閑列表(Free List)。

    選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有
    壓縮整理功能決定。

  3. 處理並發安全問題

  4. 屬性的默認初始化

  5. 設置對象頭的資訊

    將對象的所屬類(即類的元數據資訊)、對象的HashCode和對象的GC資訊、 鎖資訊等數據存儲
    在對象的對象頭中。這個過程的具體設置方式取決於JVM實現。

  6. 屬性的顯式初始化、程式碼塊初始化、構造器初始化

    在Java程式的視角看來,初始化才正式開始。初始化成員變數,執行實例化程式碼塊,調用類的構造方法,並把堆內對象的首地址賦值給引用變數。因此一般來說(由位元組碼中是否跟隨有invokespecial指令所決定), new指令之後會接著就是執行方法,把對象按照程式設計師的意願進行初始化,這樣一個真正可用的對象才算完全創建出來。

4.2 對象的記憶體布局

image-20220408151923768

記憶體布局

  1. 對象頭(Header)

    • 運行時元數據

      • hashcode
      • GC分代年齡
      • 鎖狀態標誌
      • 執行緒持有的鎖
    • 類型指針

      • 指向類元數據,確定該對象的類型
  2. 實例數據(Instance Data)

    • 欄位資訊
  3. 對齊填充(Padding)

實例

public class CustomerTest {
	public static main(String[] args) {
        Customer cust = new Customer();
    }
}

image-20220408152315936

4.3 對象訪問定位

JVM是如何通過棧幀中的對象引用訪問到其內部的對象實例的呢?

image-20220408153026814

對象訪問的兩種方式

  1. 句柄訪問

    好處:reference中存儲穩定句柄地址,對象被移動(垃圾收集時移動對象很普遍)時只會改變句柄中實例數據指針即可,reference本身不需要被修改。

    image-20220408153230370

  2. 直接指針(hotspot JVM採用)

    image-20220408153345499

5. 直接記憶體

  • 不是虛擬機運行時數據區的一部分,也不是《Java虛擬機規範》中定義的記憶體區域。

  • 直接記憶體是在Java堆外的、直接向系統申請的記憶體區間。

  • 來源於NIO(New IO/ Non-Blocking IO),通過存在堆中的DirectByteBuffer操作Native記憶體

  • 通常,訪問直接記憶體的速度會優於Java堆。即讀寫性能高。

    • 因此出於性能考慮,讀寫頻繁的場合可能會考慮使用直接記憶體。

    • Java的NIO庫允許Java程式使用直接記憶體,用於數據緩衝區

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER)
// 直接分配本地記憶體空間

使用IO讀取文件

image-20220408154216728

使用NIO讀取文件

image-20220408154602339

概述

  • 也可能導致OutOfMemoryError異常:Direct Buffer memory

  • 由於直接記憶體在Java堆外,因此它的大小不會直接受限於-Xmx指定的最大堆大小,但是系統記憶體是有限的,Java堆和直接記憶體的總和依然受限於作業系統能給出的最大記憶體。

  • 缺點

    • 分配回收成本較高
    • 不受JVM記憶體回收管理
  • 直接記憶體大小可以通過MaxDirectMemorySize設置

  • 如果不指定,默認與堆的最大值-Xmx參數值一致

6. 執行引擎

image-20220408160831831

6.1 概述

  • 執行引擎是Java虛擬機核心的組成部分之一

  • 「虛擬機」是一個相對於「物理機」的概念,這兩種機器都有程式碼執行能力,其區別是物理機的執行引擎是直接建立在處理器、快取、指令集和作業系統層面上的,而虛擬機的執行引擎則是由軟體自行實現的,因此可以不受物理條件制約地訂製指令集與執行引擎的結構體系,能夠執行那些不被硬體直接支援的指令集格式

  • JVM的主要任務是負責裝載位元組碼到其內部,但位元組碼並不能夠直接運行在作業系統之上,因為位元組碼指令並非等價於本地機器指令,它內部包含的僅僅只是一些能夠被JVM所識別的位元組碼指令、符號表,以及其他輔助資訊。

  • 那麼,如果想要讓一個Java程式運行起來,執行引擎(Execution Engine)的任務就是將位元組碼指令解釋/編譯為對應平台上的本地機器指令才可以。簡單來說,JVM中的執行引擎充當了將高級語言翻譯為機器語言的譯者。(JIT編譯稱為後端編譯,生成位元組碼稱為前端編譯)

執行引擎的工作過程

image-20220408161602942

從外觀上來看,所有的Java虛擬機的執行引擎輸入、輸出都是一致的: 輸入的是位元組碼二進位流,處理過程是位元組碼解析執行的等效過程,輸出的是執行結果。

6.2 Java程式碼編譯和執行

image-20220408161755415

黃色對應Javac、綠色是解釋器、藍色是編譯器

image-20220408161943202

什麼是解釋器(Interpreter) ,什麼是JIT編譯器?

  • 解釋器: 當Java 虛擬機啟動時會根據預定義的規範對位元組碼採用逐行解釋的方式執行,將每條位元組碼文件中的內容「翻譯」為對應平台的本地機器指令執行。

  • JIT (Just In Time Compiler) 編譯器: 就是虛擬機將源程式碼直接編譯成和本地機器平台相關的機器語言

為什麼說Java是半編譯半解釋型語言?

  • JDK1.0時代,將Java語言定位為「解釋執行」還是比較準確的。再後來,Java也發展出可以直接生成本地程式碼的編譯器。

  • 現在JVM在執行Java程式碼的時候,通常都會將解釋執行與編譯執行二者結合起來進行。

image-20220408162438016

拓展

  • 機器碼

    • 各種用二進位編碼方式表示的指令,叫做機器指令碼。開始,人們就用它采編寫程式,這就是機器語言。
    • 機器語言雖然能夠被電腦理解和接受,但和人們的語言差別太大,不易被人們理解和記憶,並且用它編程容易出差錯。
    • 用它編寫的程式一經輸入電腦,CPU直接讀取運行,因此和其他語言編的程式相比,執行速度最快。
    • 機器指令與CPU緊密相關,所以不同種類的CPU所對應的機器指令也就不同。
  • 指令

    • 由於機器碼是有0和1組成的二進位序列,可讀性實在太差,於是人們發明了指令。
    • 指令就是把機器碼中特定的0和1序列,簡化成對應的指令(一般為英文簡寫, 如mov, inc等),可讀性稍好
    • 由於不同的硬體平台,執行同一個操作,對應的機器碼可能不同,所以不同的硬體平台的同一種指令(比如mov),對應的機器碼也可能不同。
  • 指令集

    • 不同的硬體平台,各自支援的指令,是有差別的。因此每個平台所支援的指令,稱之為對應平台的指令集。
    • 如常見的
      • x86指令集,對應的是x86架構的平台
      • ARM指令集,對應的是ARM架構的平台
  • 彙編語言

    • 由於指令的可讀性還是太差,於是人們又發明了彙編語言。
    • 在彙編語言中,用助記符(Mnemonics)代替機器指令的操作碼,用地址符號(Symbol) 或標號(Label) 代替指令或操作數的地址。
    • 在不同的硬體平台,彙編語言對應著不同的機器語言指令集,通過彙編過程轉換成機器指令。
      • 由於電腦只認識指令碼,所以用彙編語言編寫的程式還必須翻譯成機器指令碼,電腦才能識別和執行。
  • 高級語言

    • 為了使電腦用戶編程式更容易些,後來就出現了各種高級電腦語言。高級語言比機器語言、彙編語言更接近人的語言
    • 當電腦執行高級語言編寫的程式時,仍然需要把程式解釋和編譯成機器的指令碼。完成這個過程的程式就叫做解釋程式編譯程式

image-20220408163120675

  • 位元組碼
    • 位元組碼是一種中間狀態(中間碼)的二進位程式碼(文件) ,它比機器碼更抽象,需要直譯器轉譯後才能成為機器碼
    • 位元組碼主要為了實現特定軟體運行和軟體環境、與硬體環境無關
    • 位元組碼的實現方式是通過編譯器和虛擬機器。編譯器將源碼編譯成位元組碼,特定平台上的虛擬機器將位元組碼轉譯為可以直接執行的指令。
      • 位元組碼的典型應用為Java bytecode

6.3 解釋器

image-20220408163535401

在Java的發展歷史裡,一共有兩套解釋執行器,即古老的位元組碼解釋器、現在普遍使用的模板解釋器

  • 位元組碼解釋器在執行時通過純軟體程式碼模擬位元組碼的執行,效率非常低下。

  • 而模板解釋器將每一條位元組碼和一個模板函數相關聯,模板函數中直接產生這條位元組碼執行時的機器碼,從而很大程度上提高了解釋器的性能。

    • 在HotSpot VM中,解釋器主要由Interpreter模組和Code模組構成。

      • Interpreter模組: 實現了解釋器的核心功能

      • Code模組:用於管理HotSpot VM在運行時生成的本地機器指令

現狀

  • 由於解釋器在設計和實現上非常簡單,因此除了Java語言之外,還有許多高級語言同樣也是基於解釋器執行的,比如Python、Perl、 Ruby等。但是在今天,基於解釋器執行已經淪落為低效的代名詞,並且時常被一些C/C++程式設計師所調侃。
  • 為了解決這個問題,JVM平台支援一種叫作即時編譯的技術。即時編譯的目的是避免函數被解釋執行,而是將整個函數體編譯成為機器碼,每次函數執行時,只執行編譯後的機器碼即可,這種方式可以使執行效率大幅度提升。
  • 不過無論如何,基於解釋器的執行模式仍然為中間語言的發展做出了不可磨滅的貢獻。

6.4 JIT編譯器

Hotspot IVM是目前市面上高性能虛擬機的代表作之一。 它採用解釋器與即時編譯器並存的架構。在Java 虛擬機運行時,解釋器和即時編譯器能夠相互協作,各自取長補短,儘力去選擇最合適的方式來權衡編譯本地程式碼的時間和直接解釋執行程式碼的時間。

既然HotSpot VM中已經內置JIT編譯器了,那麼為什麼還需要再使用解釋器來「拖累」程式的執行性能呢?

比如JRockit VM內部就不包含解釋器,位元組碼全部都依靠即時編譯器編譯後執行。

  • 當程式啟動後,解釋器可以馬上發揮作用,省去編譯的時間,立即執行。編譯器要想發揮作用,把程式碼編譯成本地程式碼,需要一定的執行時間。 但編譯為本地程式碼後,執行效率高。

  • 儘管JRockit VM中程式的執行性能會非常高效,但程式在啟動時必然需要花費更長的時間來進行編譯。對於服務端應用來說,啟動時間並非是關注重點,但對於那些看中啟動時間的應用場景而言,或許就需要採用解釋器與即時編譯器並存的架構來換取一個平衡點。在此模式下,當Java虛擬器啟動時,解釋器可以首先發揮作用,而不必等待即時編譯器全部編譯完成後再執行,這樣可以省去許多不必要的編譯時間。隨著時間的推移,編譯器發揮作用,把越來越多的程式碼編譯成本地程式碼,獲得更高的執行效率。

  • 同時,解釋執行在編譯器進行激進優化不成立的時候,作為編譯器的「逃生門」

當虛擬機啟動的時候,解釋器可以首先發揮作用,而不必等待即時編譯器全部編譯完成再執行,這樣可以省去許多不必要的編譯時間。並且隨著程式運行時間的推移,即時編譯器逐漸發揮作用,根據熱點探測功能,將有價值的位元組碼編譯為本地機器指令,以換取更高的程式執行效率。

概念

  • Java 語言的「編譯期」 其實是一段「不確定」的操作過程,因為它可能是指一個前端編譯器(其實叫「 編譯器的前端」更準確一些)把.java文件轉變成.class文件的過程;

  • 也可能是指虛擬機的後端運行期編譯器(JIT編譯器,Just In Time Compiler)把位元組碼轉變成機器碼的過程。

  • 還可能是指使用靜態提前編譯器(AOT編譯器,Ahead Of Time Compiler) 直接把.java文件編譯成本地機器程式碼的過程。

image-20220408170648641

熱點程式碼及探測方式

當然是否需要啟動JIT編譯器將位元組碼直接編譯為對應平台的本地機器指令,則需要根據程式碼被調用執行的頻率而定。關於那些需要被編譯為本地程式碼的位元組碼,也被稱之為「熱點程式碼」,JIT編譯器在運行時會針對那些頻繁被調用。的「熱點程式碼」做出深度優化,將其直接編譯為對應平台的本地機器指令,以此提升Java程式的執行性能。

  • 一個被多次調用的方法,或者是一個方法體內部循環次數較多的循環體都可以被稱之為「熱點程式碼」,因此都可以通過JIT編譯器編譯為本地機器指令。由於這種編譯方式發生在方法的執行過程中,因此也被稱之為棧上替換,或簡稱為OSR (On Stack Replacement)編譯。

  • 一個方法究竟要被調用多少次,或者一個循環體究竟需要執行多少次循環才可以達到這個標準?必然需要一個明確的閾值, JIT編譯器才會將這些「熱點程式碼」編譯為本地機器指令執行。這裡主要依靠熱點探測功能

  • 目前HotSpot VM所採用的熱點探測方式是基於計數器的熱點探測。

  • 採用基於計數器的熱點探測,HotSpot VM將會為每一個 方法都建立2個不同類型的計數器,分別為方法調用計數器(Invocation Counter) 和回邊計數器(Back Edge Counter) 。

    • 方法調用計數器用於統計方法的調用次數

    • 回邊計數器則用於統計循環體執行的循環次數

方法調用計數器

  • 這個計數器就用於統計方法被調用的次數,它的默認閾值在Client模式下是1500次,在Server 模式下是10000 次。超過這個閾值,就會觸發JIT編譯。

  • 這個閾值可以通過虛擬機參數-XX :CompileThreshold來人為設定。

  • 當一個方法被調用時,會先檢查該方法是否存在被JIT 編譯過的版本,如果存在,則優先使用編譯後的本地程式碼來執行。如果不存在已被編譯過的版本,則將此方法的調用計數器值加1,然後判斷方法調用計數器與回邊計數器值之和是否超過方法調用計數器的閾值。如果已超過閾值,那麼將會向即時編譯器提交一個該方法的程式碼編譯請求。

image-20220408171348256

  • 熱度衰減

    • 如果不做任何設置,方法調用計數器統計的並不是方法被調用的絕對次數,而是一個相對的執行頻率,即一段時間之內方法被調用的次數。當超過一定的時間限度, 如果方法的調用次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的調用計數器就會被減少一半,這個過程稱為方法調用計數器熱度的衰減(Counter Decay) ,而這段時間就稱為此方法統計的半衰周期(Counter Half Life Time)

    • 進行熱度衰減的動作是在虛擬機進行垃圾收集時順便進行的,可以使用虛擬機參數-Xx:-UseCounterDecay來關閉熱度衰減,讓方法計數器統計方法調用的絕對次數,這樣,只要系統運行時間足夠長,絕大部分方法都會被編譯成本地程式碼。

    • 另外,可以使用-xx:CounterHalfLifeTime參數設置半衰周期的時間,單位是秒。

回邊計數器

它的作用是統計一個方法中循環體程式碼執行的次數,在位元組碼中遇到控制流向後跳轉的指令稱為「回邊”Back Edge)。顯然,建立回邊計數器統計的目的就是為了觸發OSR 編譯。

image-20220408172110568

設置程式執行方式

預設情況下HotSpot VM是採用解釋器與即時編譯器並存的架構,當然開發人員可以根據具體的應用場景,通過命令顯式地為Java虛擬機指定在運行時到底是完全採用解釋器執行,還是完全採用即時編譯器執行。如下所示:

  • -Xint: 完全採用解釋器模式執行程式;
  • -Xcomp: 完全採用即時編譯器模式執行程式。如果即時編譯出現問題,解釋器會介入執行。
  • -Xmixed: 採用解釋器+即時編譯器的混合模式共同執行程式。

JIT分類

在HotSpot VM中內嵌有兩個JIT編譯器,分別為Client Compiler和Server Compiler,但大多數情況下我們簡稱為C1編譯器和C2編譯器。開發人員可以通過如下命令顯式指定Java虛擬機在運行時到底使用哪-種即時編譯器,如下所示:

  • -client: 指定Java虛擬機運行在Client模式下,並使用C1編譯器;

    C1編譯器會對位元組碼進行簡單和可靠的優化,耗時短。以達到更快的編譯速度。

  • -server: 指定Java 虛擬機運行在Server模式下,並使用C2編譯器。

    C2進行耗時較長的優化,以及激進優化。但優化的程式碼執行效率更高。

不同的優化策略

在不同的編譯器上有不同的優化策略,C1編譯器上主要有方法內聯,去虛擬化、冗餘消除。

  • 方法內聯: 將引用的函數程式碼編譯到引用點處,這樣可以減少棧幀的生成,減少參數傳遞以及跳轉過程

  • 去虛擬化: 對唯一的實現類進行內聯

  • 冗餘消除: 在運行期間把一些不會執行的程式碼摺疊掉

C2的優化主要是在全局層面,逃逸分析是優化的基礎。基於逃逸分析在C2上有如下幾種優化:

  • 標量替換: 用標量值代替聚合對象的屬性值

  • 棧上分配: 對於未逃逸的對象分配對象在棧而不是堆

  • 同步消除: 清除同步操作,通常指synchronized

分層編譯(Tiered Compilation) 策略: 程式解釋執行(不開啟性能監控)可以觸發C1編譯,將位元組碼編譯成機器碼,可以進行簡單優化,也可以加上性能監控,C2編譯會根據性能監控資訊進行激進優化。

不過在Java7版本之後,一旦開發人員在程式中顯式指定命令「-server」 時,默認將會開啟分層編譯策略,由C1編譯器和C2編譯器相互協作共同來執行編譯任務。

總結

  • 一般來講,JIT編譯出來的機器碼性能比解釋器高。
  • C2編譯器啟動時長比C1編譯器慢,系統穩定執行以後,C2編譯器執行速度遠遠快於C1編譯器。

7. StringTable

7.1 String的基本特性

  • String: 字元串,使用一對””引起來表示

  • String聲明為final的,不可被繼承

  • String實現了Serializable介面:表示字元串是支援序列化的。

  • 實現了Comparable介面:表示String可以比較大小。

  • String在jdk8及以前內部定義了final char[] value用於存儲字元串數據。jdk9時改為byte[]

  • String:代表不可變的字元序列。簡稱:不可變性。

    • 當對字元串重新賦值時,需要重寫指定記憶體區域賦值,不能使用原有的value進行賦值。

    • 當對現有的字元串進行連接操作時,也需要重新指定記憶體區域賦值,不能使用原有的value進行賦值。

    • 當調用String的replace()方法修改指定字元或字元串時,也需要重新指定記憶體區域賦值,不能使用原有的value進行賦值。

  • 通過字面量的方式(區別於new)給一個字元串賦值,此時的字元串值聲明在字元串常量池中。

  • 字元串常量池中是不會存儲相同內容的字元串的

    • String的String Pool是一個固定大小的Hashtable,默認值大小長度是1009。如果放進string Pool的String非常多, 就會造成Hash衝突嚴重,從而導致鏈表會很長,而鏈表長了後直接會造成的影響就是當調用String.intern時性能會大幅下降。

    • 使用-XX: StringTableSi ze可設置StringTable的長度

    • 在jdk6中StringTable是固定的,就是1009的長度,所以如果常量池中的字元串過多就會導致效率下降很快。StringTableSize設置沒有要求

    • 在jdk7中,StringTable的長度默認值是60013,1009是可設置的最小值。

7.2 String的記憶體分配

  • 在Java語言中有8種基本數據類型和一種比較特殊的類型string。這些類型為了使它們在運行過程中速度更快、更節省記憶體,都提供了一種常量池的概念。

  • 常量池就類似一個Java系統級別提供的快取。8種基本數據類型的常量池都是系統協調的,String類型的常量池比較特殊。它的主要使用方法有兩種。

  • 直接使用雙引號聲明出來的String對象會直接存儲在常量池中。

    • 比如: String info = “atguigu.com” ;
  • 如果不是用雙引號聲明的String對象,可以使用String提供的

    • intern()方法
  • Java 6及以前,字元串常量池存放在永久代。

  • Java 7中Oracle的工程師對字元串池的邏輯做了很大的改變,即字元串常量池的位置調整到Java堆內

    • 所有的字元串都保存在堆(Heap)中,和其他普通對象一樣,這樣可以讓你在進行調優應用時僅需要調整堆大小就可以了。
    • 字元串常量池概念原本使用得比較多,但是這個改動使得我們有足夠的理由讓我們重新考慮在Java 7中使用String.intern()。
  • Java8元空間,字元串常量在堆

StringTable為什麼要調整?

  1. permSize默認比較小
  2. 永久代垃圾回收頻率低

7.3 字元串的拼接操作

  1. 常量與常量的拼接結果在常量池,原理是編譯期優化

  2. 常量池中不會存在相同內容的常量。

  3. 只要其中有一個是變數,結果就在堆中。變數拼接的原理是StringBuilder

  4. 如果拼接的結果調用intern()方法,則主動將常量池中還沒有的字元串對象放入池中,並返回此對象地址。

    • intern():判斷字元串常量池中是否存在這個值,如果存在,則返回常量池中的地址,如果不存在,則再常量池中載入一個新的,並返回對象的地址
  5. 字元串拼接操作不一定使用的是StringBuilder,如果拼接符號左右兩邊都是字元串常量或常量引用,則仍然使用編譯器優化,即非StringBuilder的方式。

    public void test4(){
    	final String s1 = "a";
    	final String s2 = "b";
    	String s3 = "ab";
    	String s4 = S1 + s2;
    	System. out.println(s3 == s4);//true
    }
    

    針對於final修飾類、方法、基本數據類型、引用數據類型的量的結構時,能使用上final的時候建議使用上。

  6. 注意:雖然編譯器會將+號的拼接操作轉換為StringBuilder,但是多次拼接會多次轉換為StringBuilder,並不是一次,因此會很浪費時間和空間。

7.4 intern()的使用

如果不是用雙引號聲明的String對象,可以使用String提供的intern方法: intern方法會從字元串常量池中查詢當前字元串是否存在,若不存在就會將當前字元串放入常量池中。

String myInfo = new String("I love atguigu").intern();

也就是說,如果在任意字元串上調用String. intern方法,那麼其返回結果所指向的那個類實例,必須和直接以常量形式出現的字元串實例完全相同。因此,下列表達式的值必定是true:

("a" + "b" + "c").intern() == "abc" 

通俗點講,Interned String就是確保字元串在記憶體里只有一份拷貝,這樣可以節約記憶體空間,加快字元串操作任務的執行速度。注意,這個值會被存放在字元串內部池(String Intern Pool) 。

new String(“ab”)會創建幾個對象?

  1. new關鍵字在堆空間創建的
  2. 字元串常量池中的對象。位元組碼指令:ldc

new String(“a”) + new String(“b”)呢?

  1. new Stringbuilder
  2. new String(“a”)
  3. 常量池中的 “a”
  4. new String(“b”)
  5. 常量池中的 “b”
  6. toString(): new String(“ab”) 但是不在字元串常量池中生成 “ab”

分析以下程式碼:

public class StringIntern {
    public static void main(String[] args) {
        String s = new String("1");
        s.intern(); // 調用前常量池中已有1
        String s2 = "1";
        System.out.println(s == s2); // jdk6、7、8 false
        
        String s3 = new String("1") + new String("1"); // s3變數的記錄地址為 new String("11")
        // 調用前常量池中無11
        // 在常量池中生成11
        // jdk6 創建了一個新的對象11,也就有新的地址了
        // jdk7 此時常量中並沒有創建11 而是創建了一個指向堆空間new String("11")的地址 
        // 為了節省空間的做法
        s3.intern();
        String s4 = "11"; // 使用的是常量池中11的地址 
        System.out.println(s3 == s4); // jdk6 fasle, jdk7/8 true
    }
}

image-20220408205729957
image-20220408205754111

總結String的intern()的使用

jdk1.6中, 將這個字元串對象嘗試放入串池。

  • 如果串池中有,則並不會放入。返回已有的串池中的對象的地址

  • 如果沒有,會把此對象複製一份,放入串池,並返回串池中的對象地址

Jdk1.7起,將這個字元串對象嘗試放入串池。

  • 如果串池中有,則並不會放入。返回已有的串池中的對象的地址

  • 如果沒有,則會把對象的引用地址複製一份, 放入串池,並返回串池中的引用地址

intern()的空間效率測試

for (int i = 0; i < 10000; i++) {
	// 會不斷創建new String對象
    arr[i] = new String(String.valueof(i % 10));
    // 雖然也創建了new對象,但是會被GC清理掉,更優
    arr[i] = new String(String.valueof(i % 10)).intern();
}

對於程式中大量存在的字元串,尤其其中存在的很多重複字元串,使用intern()可以節省記憶體空間

7.5 G1的String去重操作

背景:對許多Java應用(有大的也有小的)做的測試得出以下結果:

  • 堆存活數據集合裡面String對象佔了25%

  • 堆存活數據集合裡面重複的string對象有13.5%

  • String對象的平均長度是45

許多大規模的Java應用的瓶頸在於記憶體,測試表明,在這些類型的應用裡面,Java堆中存活的數據集合差不多25%是String對象。更進一步,這裡面差不多一半string對象是重複的,重複的意思是說:
string1.equals(string2)=true。堆上存在重複的String對象必然是一種記憶體的浪費。這個項目將在G1垃圾收集器中實現自動持續對重複的strinq對象進行去重,這樣就能避免浪費記憶體。

實現

  • 當垃圾收集器工作的時候,會訪問堆上存活的對象。對每一個訪問的對象都會檢查是否是候選的要去重的String對象

  • 如果是,把這個對象的一個引用插入到隊列中等待後續的處理。一個去重的執行緒在後台運行,處理這個隊列。處理隊列的一個元素意味著從隊列刪除這個元素,然後嘗試去重它引用的String對象。

  • 使用一個hashtable來 記錄所有的被String對象使用的不重複的char數組。當去重的時候,會查這個hashtable,來看堆上是否已經存在一個一模一樣的char數組。

  • 如果存在,String對象會被調整引用那個數組,釋放對原來的數組的引用,最終會被垃圾收集器回收掉。

  • 如果查找失敗,char數組會被插入到hashtable,這樣以後的時候就可以共享這個數組了。

8. 記憶體的分配與回收

大廠的一些面試題

螞蟻金服

  • 你知道哪幾種垃圾回收器,各自的優缺點,重點講一下cms和g1

  • JVM GC演算法有哪些,目前的JDK版本採用什麼回收演算法

  • G1回收器講下回收過程

  • GC是什麼?為什麼要有GC?

  • GC的兩種判定方法? CMS收集器與G1收集器的特點。

百度

  • 說一下GC演算法,分代回收說下

  • 垃圾收集策略和演算法

天貓

  • jvm GC原理,JVM怎麼回收記憶體

  • CMS特點,垃圾回收演算法有哪些?各自的優缺點,他們共同的缺點是什麼?

滴滴

  • java的垃圾回收器都有哪些,說下g1的應用場景,平時你是如何搭配使用垃圾回收器的

京東

  • 你知道哪幾種垃圾收集器,各自的優缺點,重點講下cms和G1,包括原理,流程,優缺點。

  • 垃圾回收演算法的實現原理。

阿里

  • 講一講垃圾回收演算法。

  • 什麼情況下觸發垃圾回收?

  • 如何選擇合適的垃圾收集演算法?

  • JVM有哪三種垃圾回收器?

字節跳動

  • 常見的垃圾回收器演算法有哪些,各有什麼優劣?

  • system.gc()和runtime.gc()會做什麼事情?

  • Java GC機制? GC Roots有哪些?

  • Java對象的回收方式,回收演算法。

  • CMS和G1了解么,CMS解決什麼問題,說一下回收的過程。

  • CMS回收停頓了幾次,為什麼要停頓兩次。

8.1 什麼是垃圾?

什麼是垃圾( Garbage) 呢?

  • 垃圾是指在運行程式中沒有任何指針指向的對象,這個對象就是需要被回收的垃圾。

  • An object is considered garbage when it can no longer be reached from any pointer in the running program.

如果不及時對記憶體中的垃圾進行清理,那麼,這些垃圾對象所佔的記憶體空間會一直保留到應用程式結束,被保留的空間無法被其他對象使用。甚至可能導致記憶體溢出。

8.2 為什麼需要GC?

對於高級語言來說,一個基本認知是如果不進行垃圾回收,記憶體遲早都會被消耗完,因為不斷地分配記憶體空間而不進行回收,就好像不停地生產生活垃圾而從來不打掃一樣。

除了釋放沒用的對象,垃圾回收也可以清除記憶體里的記錄碎片。碎片整理將所佔用的堆記憶體移到堆的一端,以便JVM將整理出的記憶體分配給新的對象

隨著應用程式所應付的業務越來越龐大、複雜,用戶越來越多,沒有GC就不能保證應用程式的正常進行。而經常造成STW的GC又跟不上實際的需求,所以才會不斷地嘗試對GC進行優化。

自動記憶體管理無需開發人員手動參與記憶體的分配與回收,這樣降低記憶體泄漏和記憶體溢出的風險

  • 沒有垃圾回收器,java也會和cpp一樣,各種懸垂指針,野指針,泄露問題讓你頭疼不已。

自動記憶體管理機制,將程式設計師從繁重的記憶體管理中釋放出來,可以更專心地專註於業務開發

8.3 垃圾標記演算法

對象存活判斷

在堆里存放著幾乎所有的Java對象實例,在GC執行垃圾回收之前,首先需要區分出記憶體中哪些是存活對象,哪些是已經死亡的對象。只有被標記為己經死亡的對象,GC才會在執行垃圾回收時,釋放掉其所佔用的記憶體空間,因此這個過程我們可以稱為垃圾標記階段

那麼在JVM中究竟是如何標記一個死亡對象呢? 簡單來說,當一個對象已經不再被任何的存活對象繼續引用時,就可以宣判為已經死亡。

判斷對象存活一般有兩種方式: 引用計數演算法可達性分析演算法

8.3.1 引用計數演算法

  • 引用計數演算法(Reference Counting) 比較簡單,對每個對象保存一個 整型的引用計數器屬性。用於記錄對象被引用的情況。

  • 對於一個對象A,只要有任何一個對象引用了A,則A的引用計數器就加1;當引用失效時,引用計數器就減1。只要對象A的引用計數器的值為0,即表示對象A不可能再被使用,可進行回收。

  • 優點

    • 實現簡單,垃圾對象便於辨識;判定效率高,回收沒有延遲性。
  • 缺點

    • 它需要單獨的欄位存儲計數器,這樣的做法增加了存儲空間的開銷

    • 每次賦值都需要更新計數器,伴隨著加法和減法操作,這增加了時間開銷

    • 引用計數器有一個嚴重的問題,即無法處理循環引用的情況。這是一條致命缺陷,導致在Java的垃圾回收器中沒有使用這類演算法。

      循環引用

      image-20220408222115049

小結

  • 引用計數演算法,是很多語言的資源回收選擇,例如因人工智慧而更加火熱的Python,它更是同時支援引用計數和垃圾收集機制。

  • 具體哪種最優是要看場景的,業界有大規模實踐中僅保留引用計數機制,以提高吞吐量的嘗試。

  • Java並沒有選擇引用計數,是因為其存在一個基本的難題,也就是很難處理循環引用關係。

  • Python如何解決循環引用?

    • 手動解除: 很好理解,就是在合適的時機,解除引用關係。

    • 使用弱引用weakref,weakref是 Python提供的標準庫,旨在解決循環引用。

8.3.2 可達性分析演算法

相對於引用計數演算法而言,可達性分析演算法不僅同樣具備實現簡單和執行高效等特點,更重要的是該演算法可以有效地解決在引用計數演算法中循環引用的問題,防止記憶體泄漏的發生。

相較於引用計數演算法,這裡的可達性分析就是Java、C#選擇的。這種類型的垃圾收集通常也叫作追蹤性垃圾收集(Tracing Garbage Collection)。

所謂”GC Roots”根集合就是一組必須活躍的引用。

基本思路

  • 可達性分析演算法是以根對象集合(GC Roots) 為起始點,按照從上至下的方式搜索被根對象集合所連接的目標對象是否可達

  • 使用可達性分析演算法後,記憶體中的存活對象都會被根對象集合直接或間接連接著,搜索所走過的路徑稱為引用鏈(Reference Chain)

  • 如果目標對象沒有任何引用鏈相連,則是不可達的,就意味著該對象己經死亡,可以標記為垃圾對象。

  • 在可達性分析演算法中,只有能夠被根對象集合直接或者間接連接的對象才是存活對象

image-20220408222910620

GC Roots

由於Root採用棧方式存放變數和指針,所以如果一個指針,它保存了堆記憶體裡面的對象,但是自己又不存放在堆記憶體裡面,那它就是一個Root

在Java語言中,GC Roots包括以下幾類元素:

  • 虛擬機棧中引用的對象

    • 比如:各個執行緒被調用的方法中使用到的參數、局部變數等。
  • 本地方法棧內JNI(通常說的本地方法)引用的對象

  • 方法區中類靜態屬性引用的對象

    • 比如: Java類的引用類型靜態變數
  • 方法區中常量引用的對象

    • 比如:字元串常量池(String Table)里的引用
  • 所有被同步鎖synchronized持有的對象

  • Java虛 擬機內部的引用。

    • 基本數據類型對應的Class對象,一些常駐的異常對象(如: NullPointerException、OutOfMemoryError) ,系統類載入器。
  • 反映java虛擬機內部情況的JMXBean、JVMTI中註冊的回調、本地程式碼快取等。

除了這些固定的GC Roots集合以外,根據用戶所選用的垃圾收集器以及當前回收的記憶體區域不同,還可以有其他對象「臨時性」地加入,共同構成完整GC Roots集合。比如:分代收集和局部回收(Partial GC)。

  • 如果只針對Java堆中的某一塊區 域進行垃圾回收(比如:典型的只針對新生代),必須考慮到記憶體區域是虛擬機自己的實現細節,更不是孤立封閉的,這個區域的對象完全有可能被其他區域的對象所引用,這時候就需要一併將關聯的區域對象也加入GC Roots集合中去考慮,才能保證可達性分析的準確性。

注意

如果要使用可達性分析演算法來判斷記憶體是否可回收,那麼分析工作必須在一個能保障一致性的快照中進行。這點不滿足的話分析結果的準確性就無法保證。

這點也是導致GC進行時必須川stop The World” 的一個重要原因。

  • 即使是號稱(幾乎)不會發生停頓的CMS收集器中,枚舉根節點時也是必須要停頓的。

8.4 對象的finalization機制

概述

  • Java語言提供了對象終止(finalization)機制來允許開發人員提供對象被銷毀之前的自定義處理邏輯

  • 當垃圾回收器發現沒有引用指向一個對象,即垃圾回收此對象之前,總會先調用這個對象的finalize()方法。

  • finalize()方法允許在子類中被重寫,用於在對象被回收時進行資源釋放。通常在這個方法中進行一些資源釋放和清理的工作,比如關閉文件、套接字和資料庫連接等。

  • 應該交給垃圾回收機制調用。理由包括下面三點:永遠不要主動調用某個對象的finalize()方法

    • 在finalize() 時可能會導致對象復活。
    • finalize()方法的執行時間是沒有保障的,它完全由GC執行緒決定,極端情況下,若不發生GC,則finalize()方法將沒有執行機會。
    • 一個糟糕的finalize()會嚴重影響GC的性能。
  • 從功能上來說,finalize() 方法與C++中的析構函數比較相似,但是Java採用的是基於垃圾回收器的自動記憶體管理機制,所以finalize ()方法在本質上不同於C++中的析構函數。

  • 由於finalize()方法的存在,虛擬機中的對象一般處於三種可能的狀態。

如果從所有的根節點都無法訪問到某個對象,說明對象已經不再使用了。一般來說,此對象需要被回收。但事實上,也並非是「非死不可」的,這時候它們暫時處於「緩刑」階段。一個無法觸及的對象有可能在某一個條件下「復活」自己,如果這樣,那麼對它的回收就是不合理的,為此,定義虛擬機中的對象可能的三種狀態。如下:

  • 可觸及的: 從根節點開始,可以到達這個對象。
  • 可復活的: 對象的所有引用都被釋放,但是對象有可能在finalize()中復活。
  • 不可觸及的: 對象的finalize()被調用,並且沒有復活,那麼就會進入不可觸及狀態。不可觸及的對 象不可能被複活,因為finalize()只會被調用一次

以上3種狀態中,是由於finalize()方法的存在,進行的區分。只有在對象不可觸及時才可以被回收。

判斷可回收的具體過程

判定一個對象objA是否可回收,至少要經歷兩次標記過程:

  1. 如果對象objA到GC Roots沒有引用鏈,則進行第一次標記。

  2. 進行篩選,判斷此對象是否有必要執行finalize()方法

    • 如果對象objA沒有重寫finalize()方法,或者finalize ()方法已經被虛擬機調用過,則虛擬機視為「沒有必要執行」,objA被判定為不可觸及的。

    • 如果對象objA重寫了finalize()方法,且還未執行過,那麼objA會被插入到r-Queue隊列中,由一個虛擬機自動創建的、低優先順序的Finalizer執行緒觸發其finalize()方法執行。

    • finalize()方法是對象逃脫死亡的最後機會,稍後GC會對F-Queue隊列中的對象進行第二次標記。如果objA在finalize()方法中與引用鏈上的任何一個對象建立了聯繫,那麼在第二次標記時,objA會被移出「即將回收」集合。之後,對象會再次出現沒有引用存在的情況。在這個情況下,finalize方法不會被再次調用,對象會直接變成不可觸及的狀態,也就是說,一個對象的finalize方法只會被調用一次。

8.5 垃圾清除演算法

當成功區分出記憶體中存活對象和死亡對象後,GC接下來的任務就是執行垃圾回收,釋放掉無用對象所佔用的記憶體空間,以便有足夠的可用記憶體空間為新對象分配記憶體。

目前在JVM中比較常見的三種垃圾收集演算法是

  • 標記—清除演算法( Mark-Sweep)
  • 複製演算法(Copying)(標記—複製演算法)
  • 標記一壓縮演算法(Mark-Compact)

8.5.1 標記—清除演算法

執行過程

當堆中的有效記憶體空間(available memory)被耗盡的時候,就會停止整個程式(也被稱為stop the world),然後進行兩項工作,第一項則是標記,第二項則是清除。

  • 標記: Collector從引用根節點開始遍歷,標記所有被引用的對象。一般是在對象的Header中記錄為可達對象。
  • 清除:Collector對堆記憶體從頭到尾進行線性的遍歷,如果發現某個對象在其Header中沒有標記為可達對象,則將其回收。

image-20220409191332470

缺點

  • 效率不算高

  • 在進行GC的時候,需要停止整個應用程式,導致用戶體驗差

  • 這種方式清理出來的空閑記憶體是不連續的,產生記憶體碎片。需要維護一個空閑列表

注意: 何為清除?

這裡所謂的清除並不是真的置空,而是把需要清除的對象地址保存在空閑的地址列表裡。下次有新對象需要載入時,判斷垃圾的位置空間是否夠,如果夠,就存放。

8.5.2 複製演算法

核心思想

將活著的記憶體空間分為兩塊,每次只使用其中一塊,在垃圾回收時將正在使用的記憶體中的存活對象複製到未被使用的記憶體塊中,之後清除正在使用的記憶體塊中的所有對象,交換兩個記憶體的角色,最後完成垃圾回收。

有點類似於from to區

image-20220409192012092

優點

  • 沒有標記和清除過程,實現簡單,運行高效

  • 複製過去以後保證空間的連續性,不會出現「碎片」問題。

缺點

  • 此演算法的缺點也是很明顯的,就是需要兩倍的記憶體空間。

  • 對於G1這種分拆成為大量region的GC,複製而不是移動,意味著GC需要維護region之間對象引用關係,不管是記憶體佔用或者時間開銷也不小。

特別的

  • 如果系統中的垃圾對象很多,複製演算法需要複製的存活對象數量並不會太大,或者說非常低才行。

8.5.3 標記—壓縮演算法

基於老年代垃圾回收的特性,需要使用其他的演算法。

執行過程

  • 第一階段和標記—清除演算法一樣,從根節點開始標記所有被引用對象

  • 第二階段將所有的存活對象壓縮到記憶體的一端,按順序排放

  • 之後,清理邊界外所有的空間。

image-20220409192814473

標記—壓縮演算法的最終效果等同於標記—清除演算法執行完成後,再進行一次記憶體碎片整理,因此,也可以把它稱為標記—清除—壓縮(Mark- Sweep-Compact)演算法。

二者的本質差異在於標記—清除演算法是一種非移動式的回收演算法,標記—壓縮是移動式的。是否移動回收後的存活對象是一項優缺點並存的風險決策。

可以看到,標記的存活對象將會被整理,按照記憶體地址依次排列,而未被標記的記憶體會被清理掉。如此一來,當我們需要給新對象分配記憶體時,JVM只需要持有一個記憶體的起始地址即可,這比維護一個空閑列表顯然少了許多開銷。

優點

  • 消除了標記—清除演算法當中,記憶體區域分散的缺點,我們需要給新對象分配記憶體時,JVM只需要持有一個記憶體的起始地址即可。

  • 消除了複製演算法當中,記憶體減半的高額代價

缺點

  • 從效率上來說,標記—整理演算法要低於複製演算法。
  • 移動對象的同時,如果對象被其他對象引用,則還需要調整引用的地址。
  • 移動過程中,需要全程暫停用戶應用程式。即: STW

8.5.4 小結

Mark-Weep Mark-Compact Copying
速度 中等 最慢 最快
空間開銷 少(碎片) 少(不堆積碎片) 通常需要活對象的2倍大小(不堆積碎片)
移動對象

效率上來說,複製演算法是當之無愧的老大,但是卻浪費了太多記憶體。

而為了盡量兼顧上面提到的三個指標,標記-整理演算法相對來說更平滑一些,但是效率上不盡如人意,它比複製演算法多了一個標記的階段(這裡好像不對?),比標記—清除多了一個整理記憶體的階段。

8.5.5 分代收集演算法

不同生命周期的對象可以採取不同的收集方式,以便提高回收效率

目前幾乎所有的GC都是採用分代收集( Generational Collecting) 演算法執行垃圾回收的。

在Hotspot中,基於分代的概念,GC所使用的記憶體回收演算法必須結合年輕代和老年代各自的特點。

年輕代(Young Gen)

  • 年輕代特點:區域相對老年代較小,對象生命周期短、存活率低,回收頻繁。

  • 這種情況複製演算法的回收整理,速度是最快的。複製演算法的效率只和當前存活對象大小有關,因此很適用於年輕代的回收。而複製演算法記憶體利用率不高的問題,通過hotspot中的兩個survivor的設計得到緩解。

老年代(Tenured Gen)

  • 老年代特點:區域較大,對象生命周期長、存活率高,回收不及年輕代頻繁。

  • 這種情況存在大量存活率高的對象,複製演算法明顯變得不合適。一般是由標記—清除或者是標記—清除與標記—整理的混合實現。

    • Mark階段的開銷與存活對象的數量成正比。

    • Sweep階段的開銷與所管理區域的大小成正相關。

    • Compact階段的開銷與存活對象的數據成正比。

以HotSpot中的CMS回收器為例,CMS是基於Mark-Sweep實現的,對於對象的回收效率很高。而對於碎片問題,CMS採用基於Mark-Compact演算法的Seriall old回收器作為補償措施:當記憶體回收不佳(碎片導致的Concurrent Mode Failure時),將採用Serial old執行Full GC以達到對老年代記憶體的整理。

分代的思想被現有的虛擬機廣泛使用。幾乎所有的垃圾回收器都區分新生代和老年代。

8.5.6 增量收集演算法

基本思想

如果一次性將所有的垃圾進行處理,需要造成系統長時間的停頓,那麼就可以讓垃圾收集執行緒和應用程式執行緒交替執行。每次,垃圾收集執行緒只收集一小片區域的記憶體空間,接著切換到應用程式執行緒。依次反覆,直到垃圾收集完成。

總的來說,增量收集演算法的基礎仍是傳統的標記—清除和複製演算法。增量收集演算法通過對執行緒間衝突的妥善處理,允許垃圾收集執行緒以分階段的方式完成標記、清理或複製工作。

缺點

使用這種方式,由於在垃圾回收過程中,間斷性地還執行了應用程式程式碼,所以能減少系統的停頓時間。但是,因為執行緒切換和上下文轉換的消耗,會使得垃圾回收的總體成本上升,造成系統吞吐量的下降

8.5.7 分區演算法

一般來說,在相同條件下,堆空間越大,一次GC時所需要的時間就越長,有關GC產生的停頓也越長。為了更好地控制Gc產生的停頓時間,將一塊大的記憶體區域分割成多個小塊,根據目標的停頓時間,每次合理地回收若千個小區間,而不是整個堆空間,從而減少一次GC所產生的停頓。

分代演算法將按照對象的生命周期長短劃分成兩個部分,分區演算法將整個堆空間劃分成連續的不同小區間。

每一個小區間都獨立使用,獨立回收。這種演算法的好處是可以控制一次回收多少個小區間。

image-20220410143438310

8.6 垃圾回收的概念

1. System.gc()

  • 在默認情況下,通過system.gc()或者Runtime.getRuntime().gc()的調用,會顯式觸發FullGC,同時對老年代和新生代進行回收,嘗試釋放被丟棄對象佔用的記憶體。

  • 然而System.gc()調用附帶一個免責聲明,無法保證對垃圾收集器的調用。

  • JVM實現者可以通過System.gc()調用來決定JVM的GC行為。而一般情況下,垃圾回收應該是自動進行的,無須手動觸發,否則就太過於麻煩了。在一些特殊情況下,如我們正在編寫一個性能基準,我們可以在運行之間調用System.gc()

  • system.runFinalization()強制調用使用引用的對象的finalize()方法

2. 記憶體溢出(OOM)

  • 記憶體溢出相對於記憶體泄漏來說,儘管更容易被理解,但是同樣的,記憶體溢出也是引發程式崩潰的罪魁禍首之一。

  • 由於GC一直在發展,所以一般情況下,除非應用程式佔用的記憶體增長速度非常快,造成垃圾回收已經跟不上記憶體消耗的速度,否則不太容易出現OOM的情況。

  • 大多數情況下,GC會進行各種年齡段的垃圾回收,實在不行了就放大招,來一次獨佔式的Full GC操作,這時候會回收大量的記憶體,供應用程式繼續使用。

  • javadoc中對OutOfMemoryError的解釋是,沒有空閑記憶體,並且垃圾收集器也無法提供更多記憶體

  • 這裡面隱含著一層意思是,在拋出OutOfMemoryError之前,通常垃圾收集器會被觸發,盡其所能去清理出空間。

    • 例如:在引用機制分析中,涉及到JVM會去嘗試回收軟引用指向的對象等
    • 在java.nio.BIts.reserveMemory()方法中,我們能清楚的看到,System. gc()會被調用,以清理空間。
  • 當然,也不是在任何情況下垃圾收集器都會被觸發的

    • 比如,我們去分配一個超大對象,類似一個超大數組超過堆的最大值,JVM可以判斷出垃圾收集並不能解決這個問題,所以直接拋出OutOfMemoryError

首先說沒有空閑記憶體的情況:說明Java虛擬機的堆記憶體不夠。原因有二:

  1. Java虛擬機的堆記憶體設置不夠

    比如:可能存在記憶體泄漏問題;也很有可能就是堆的大小不合理,比如我們要處理比較可觀的數據量,但是沒有顯式指定JVM堆大小或者指定數值偏小。我們可以通過參數-Xms、-Xmx來調整。

  2. 程式碼中創建了大量大對象,並且長時間不能被垃圾收集器收集(存在被引用)對於老版本的Oracle JDK,因為永久代的大小是有限的,並且JVM對永久代垃圾回收(如,常量池回收、卸載不再需要的類型)非常不積極,所以當我們不斷添加新類型的時候,永久代出現OutOfMemoryError也非常多見,尤其是在運行時存在大量動態類型生成的場合;類似intern字元串快取佔用太多空間,也會導致OOM問題。對應的異常資訊,會標記出來和永久代相關: "java.lang.OutOfMemoryError: PermGen space".隨著元數據區的引入,方法區記憶體已經不再那麼窘迫,所以相應的00M有所改觀,出現OOM,異常資訊則變成了:「java. lang . OutOfMemoryError: Metaspace"。 直接記憶體不足,也會導致OOM。

3. 記憶體泄漏(Memory LeaK)

  • 也稱作「存儲滲漏」。嚴格來說,只有對象不會再被程式用到了,但是GC又不能回收他們的情況,才叫記憶體泄漏

  • 但實際情況很多時候一些不太好的實踐(或疏忽)會導致對象的生命周期變得很長甚至導致OOM,也可以叫做寬泛意義上的「記憶體泄漏」

  • 儘管記憶體泄漏並不會立刻引起程式崩潰,但是一旦發生記憶體泄漏,程式中的可用記憶體就會被逐步蠶食,直至耗盡所有記憶體,最終出現OutOfMemory異常,導致程式崩潰。

  • 注意,這裡的存儲空間並不是指物理記憶體,而是指虛擬記憶體大小,這個虛擬記憶體大小取決於磁碟交換區設定的大小

image-20220410150256126

舉例

  1. 單例模式

    單例的生命周期和應用程式是一樣長的,所以單常式序中,如果持有對外部對象的引用的話,那麼這個外部對象是不能被回收的,則會導致記憶體泄漏的產生。

  2. 一些提供close的資源未關閉導致記憶體泄漏資料庫連接(dataSourse.getConnection()),網路連接(socket)和io連接必須手動close,否則是不能被回收的。

4. Stop The World

Stop-the-World,簡稱STW,指的是GC事件發生過程中,會產生應用程式的停頓。停頓產生時整個應用程式執行緒都會被暫停,沒有任何響應,有點像卡死的感覺,這個停頓稱為STW

  • 可達性分析演算法中枚舉根節點(GC Roots)會導致所有Java執行執行緒停頓。

    • 分析工作必須在一個能確保一致性的快照中進行
    • 一致性指整個分析期間整個執行系統看起來像被凍結在某個時間點上
    • 如果出現分析過程中對象引用關係還在不斷變化,則分析結果的準確性無法保證
  • STW事件和採用哪款GC無關,所有的GC都有這個事件。

  • 哪怕是G1也不能完全避免Stop-the-world情況發生,只能說垃圾回收器越來越優秀,回收效率越來越高,儘可能地縮短了暫停時間。

  • STW是JVM在後台自動發起和自動完成的。在用戶不可見的情況下,把用戶正常的工作執行緒全部停掉。

  • 開發中不要用System.gc(),會導致Stop-the-world的發生。

5. 並發(Concurrent)

  • 在作業系統中,是指一個時間段中有幾個程式都處於已啟動運行到運行完畢之間,且這幾個程式都是在同一個處理器上運行。
  • 並發不是真正意義上的「同時進行」,只是CPU把一個時間段劃分成幾個時間片段(時間區間),然後在這幾個時間區間之間來回切換,由於CPU處理的速度非常快,只要時間間隔處理得當,即可讓用戶感覺是多個應用程式同時在進行。

image-20220410164445341

6. 並行(parallel)

  • 當系統有一個以上CPU時,當一個CPU執行一個進程時,另一個CPU可以執行另一個進程,兩個進程互不搶佔CPU資源,可以同時進行,我們稱之為並行(Parallel)。
  • 其實決定並行的因素不是CPU的數量,而是CPU的核心數量,比如一個CPU多個核也可以並行。
  • 適合科學計算,後台處理等弱交互場景

image-20220410164549545

二者對比

  • 並發,指的是多個事情,在同一時間段內同時發生了。

  • 並行,指的是多個事情,在同一時間點上同時發生了。

  • 並發的多個任務之間是互相搶佔資源的。

  • 並行的多個任務之間是不互相搶佔資源的。

  • 只有在多CPU或者一個CPU多核的情況中,才會發生並行。

  • 否則,看似同時發生的事情,其實都是並發執行的。

垃圾回收的並發與並行

並發和並行,在談論垃圾收集器的上下文語境中,它們可以解釋如下:

  • 並行(Parallel)

    • 指多條垃圾收集執行緒並行工作,但此時用戶執行緒仍處於等待狀態。
    • 如ParNew、 Parallel Scavenge、 Parallel 0ld;
  • 串列(Serial)

    • 相較於並行的概念,單執行緒執行。

    • 如果記憶體不夠,則程式暫停,啟動JVM垃圾回收器進行垃圾回收。回收完,再啟動程式的執行緒。

  • 並發(Conlcurrent)

    • 用戶執行緒與垃圾收集執行緒同時執行(但不一定是並行的,可能會交替執行),垃圾回收執行緒在執行時不會停頓用戶程式的運行。
    • 用戶程式在繼續運行,而垃圾收集程式執行緒運行於另一個CPU上,如: CMS、G1

7. 安全點與安全區域

  • 程式執行時並非在所有地方都能停頓下來開始GC, 只有在特定的位置才能停頓下來開始GC,這些位置稱為「安全點(Safepoint) 」。
  • safe Point的選擇很重要,如果太少可能導致GC等待的時間太長,如果太頻繁可能導致運行時的性能問題。大部分指令的執行時間都非常短暫,通常會根據「是否具有讓程式長時間執行的特徵」為標準。比如:選擇些執行時間較長的指令作為Safe Point,如方法調用、循環跳轉和異常跳轉等。
  • Safepoint機制保證了程式執行時,在不太長的時間內就會遇到可進入GC的Safepoint。但是,程式「不執行」的時候呢?例如執行緒處於Sleep狀態或Blocked狀態,這時候執行緒無法響應JVM的中斷請求,「走」 到安全點去中斷掛起,JVM 也不太可能等待執行緒被喚醒。對於這種情況,就需要安全區域(Safe Region)來解決。
  • 安全區域是指在一段程式碼片段中,對象的引用關係不會發生變化,在這個區域中的任何位置開始GC都是安全的。我們也可以把Safe Region看做是被擴展了的Safepoint

如何在GC發生時,檢查所有執行緒都跑到最近的安全點停頓下來呢?

  • 搶先式中斷: ( 目前沒有虛擬機採用了)

    • 首先中斷所有執行緒,如果還有執行緒不在安全點,就恢復執行緒,讓執行緒跑到安全點。
  • 主動式中斷:

    • 設置一個中斷標誌,各個執行緒運行到Safe Point的時候主動輪詢這個標誌,如果中斷標誌為真,則將自己進行中斷掛起。

安全區域實際執行

  1. 當執行緒運行到Safe Region的程式碼時, 首先標識已經進入了Safe Region,如果這段時間內發生GC,JVM會忽略標識為Safe Region狀態的執行緒
  2. 當執行緒即將離開Safe Region時, 會檢查JVM是否已經完成GC,如果完成了,則繼續運行,否則執行緒必須等待直到收到可以安全離開Safe Region的訊號為止

8. 引用

我們希望能描述這樣一類對象: 當記憶體空間還足夠時,則能保留在記憶體中;如果記憶體空間在進行垃圾收集後還是很緊張,則可以拋棄這些對象。

  • 強引用、軟引用、弱引用、虛引用有什麼區別?具體使用場景是什麼?
    • 在JDK 1.2版之後,Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference) 、弱引用(Weak Reference)和虛引用 (Phantom Reference) 4種,這4種引用強度依次逐漸減弱
    • 除強引用外,其他3種引用均可以在java. lang. ref包中找到它們的身影。如下圖,顯示
      了這3種引用類型對應的類,開發人員可以在應用程式中直接使用它們。

Reference子類中只有終結器引用是包內可見的,其他3種引用類型均為public,可以在應用程式中直接使用

  • 強引用(StrongReference)

    最傳統的「引用」的定義,是指在程式程式碼之中普遍存在的引用賦值,即類似「Object obj=new object()”這種引用關係。無論任何情況下,只要強引用關係還存在,垃圾收集器就永遠不會回收掉被引用的對象。

    • 強引用可以直接訪問目標對象。
    • 強引用所指向的對象在任何時候都不會被系統回收,虛擬機寧願拋出OOM異常,也不會回收強引用所指向對象
    • 強引用可能導致記憶體泄漏,
  • 軟引用(SoftReference)

    在系統將要發生記憶體溢出之前,將會把這些對象列入回收範圍之中進行第二次回收。如果這次回收後還沒有足夠的記憶體,才會拋出記憶體溢出異常。

    • 軟引用通常用來實現記憶體敏感的快取。比如:高速快取就有用到軟引用。如果還有空閑記憶體,就可以暫時保留快取,當記憶體不足時清理掉,這樣就保證了使用快取的同時,不會耗盡記憶體。
    • 垃圾回收器在某個時刻決定回收軟可達的對象的時候,會清理軟引用,並可選地把引用存放到一個引用隊列( Reference Queue)。
    SoftReference<T> userSofRef = new SoftReference<T>(new T);
    
  • 弱引用(WeakReference)

    被弱引用關聯的對象只能生存到下一次垃圾收集之前。當垃圾收集器工作時,無論記憶體空間是否足夠,都會回收掉被弱引用關聯的對象。

    弱引用也適合來保存那些可有可無的快取數據。

    WeakReference<T> userWRef = new WeakReference<T>(new T);
    
  • 虛引用(PhantomReference)

    一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來獲得一個對象的實例。為一個對象設置虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知。

    為一個對象設置虛引用關聯的唯一目的在於跟蹤垃圾回收過程。比如:能在這個對象被收集器回收時收到一個系統通知。

  • 終結器引用

    它用以實現對象的finalize()方法,也可以稱為終結器引用

    無需手動編碼,其內部配合引用隊列使用。

    在GC時,終結器引用入隊。由Finalizer執行緒通過終結器引用找到被引用對象並調用它的finalize()方法,第二次GC時才能回收被引用對象。

9. 垃圾回收器

9.1 性能指標

  • 吞吐量: 運行用戶程式碼的時間佔總運行時間的比例

    • 總運行時間: 程式的運行時間+記憶體回收的時間
  • 垃圾收集開銷: 吞吐量的補數,垃圾收集所用時間與總運行時間的比例。

  • 暫停時間: 執行垃圾收集時,程式的工作執行緒被暫停的時間。

  • 收集頻率: 相對於應用程式的執行,收集操作發生的頻率。

  • 記憶體佔用: Java堆區所佔的記憶體大小。

  • 快速: 一個對象從誕生到被回收所經歷的時間。

吞吐量(Throughout)

  • 吞吐量就是CPU用於運行用戶程式碼的時間與CPU總消耗時間的比值

  • 即吞吐量=運行用戶程式碼時間/ (運行用戶程式碼時間+垃圾收集時間)。

  • 比如: 虛擬機總共運行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。

  • 這種情況下,應用程式能容忍較高的暫停時間,因此,高吞吐量的應用程式有更長的時間基準,快速響應是不必考慮的。

  • 吞吐量優先,意味著在單位時間內,STW的時間最短: 0.2 + 0.2 = 0.4

暫停時間(pause time)

  • 「暫停時間」是指一個時間段內應用程式執行緒暫停,讓GC執行緒執行的狀態

  • 例如,GC期間100毫秒的暫停時間意味著在這100毫秒期間內沒有應用程式執行緒是活動的。

  • 暫停時間優先,意味著儘可能讓單次STW的時間最短: 0.1 + 0.1 + 0.1 + 0.1+ 0.1=0.5

image-20220410194236296

比較

  • 高吞吐量較好因為這會讓應用程式的最終用戶感覺只有應用程式執行緒在做生產性工作。直覺上,吞吐量越高程式運行越快。

  • 低暫停時間(低延遲)較好因為從最終用戶的角度來看不管是GC還是其他原因導致一個應用被掛起始終是不好的。這取決於應用程式的類型,有時候甚至短暫的200毫秒暫停都可能打斷終端用戶體驗。因此,具有低的較大暫停時間是非常重要的,特別是對於一個互動式應用程式。

  • 不幸的是」高吞吐量」和」低暫停時間」是一對相互競爭的目標(矛盾)。

  • 因為如果選擇以吞吐量優先,那麼必然需要降低記憶體回收的執行頻率,但是這樣會導致GC需要更長的暫停時間來執行記憶體回收。

  • 相反的,如果選擇以低延遲優先為原則,那麼為了降低每次執行記憶體回收時的暫停時間,也只能頻繁地執行記憶體回收,但這又引起了年輕代記憶體的縮減和導致程式吞吐量的下降。

在設計(或使用) GC演算法時,我們必須確定我們的目標: 一個GC演算法只可能針對兩個目標之一(即只專註於較大吞吐量或最小暫停時間),或嘗試找到一個二者的折衷。

現在標準: 在最大吞吐量優先的情況下,降低停頓時間

9.2 垃圾回收器概述

經典垃圾收集器

image-20220410200032265

  • 新生代收集器: Serial、ParNew、Parallel Scavenge

  • 老年代收集器: Serial Old、 Parallel Old、 CMS

  • 整堆收集器: G1

組合關係

image-20220410200229112

  1. 兩個收集器間有連線,表明它們可以搭配使用: Serial/Serial Old、Serial/CMS、 ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  2. 其中Serial Old作為CMS出現”Concurrent Mode Failure” 失敗的後備預案。

  3. (紅色虛線)由於維護和兼容性測試的成本,在JDK 8時將Serial+CMS、ParNew+Serial 0ld這兩個組合聲明為廢棄(JEP 173),並在JDK 9中完全取消了這些組合的支援(JEP214) ,即: 移除。

  4. (綠色虛線)JDK 14中:棄用Parallel Scavenge和Seria10ld GC組合(JEP366 )

  5. (青色虛線)JDK 14中:刪除CMS垃圾回收器 (JEP 363 )

為什麼要有很多收集器,一個不夠嗎?

  • 因為Java的使用場景很多,移動端,伺服器等。所以就需要針對不同的場景,提供不同的垃圾收集器,提高垃圾收集的性能。

  • 雖然我們會對各個收集器進行比較,但並非為了挑選一個最好的收集器出來。沒有一种放之四海皆準、任何場景下都適用的完美收集器存在,更加沒有萬能的收集器。所以我們選擇的只是對具體應用最合適的收集器

查看默認的垃圾回收器

  • -XX:+PrintCommandLineFlags: 查看命令行相關參數(包含使用的垃圾收集器)
  • 使用命令行指令: jinfo -flag 相關垃圾回收器 參數進程ID

9.3 Serial回收器

串列回收

  • Serial收集器是最基本、歷史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的選擇。

  • Serial收集器作為Hotspot中Client模式下的默認新生代垃圾收集器。

  • Serial收集器採用複製演算法、串列回收和」stop-the-World”機制的方式執行記憶體回收。

  • 除了年輕代之外,Serial收集器還提供用於執行老年代垃圾收集的Serial Old收集器。Serial Old收集器同樣也採用了串列回收和”Stop the World”機制,只不過記憶體回收演算法使用的是標記-壓縮演算法。

    • Serial Old是運行在Client模式下默認的老年代的垃圾回收器

    • Serial Old在Server模式下主要有兩個用途

      ①與新生代的Parallel Scavenge配合使用

      ②作為老年代CMS收集器的後備垃圾收集方案

image-20220410201511850

這個收集器是一個單執行緒的收集器,但它的「單執行緒」的意義並不僅僅說明它只會使用一個CPU或一條收集執行緒去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作執行緒,直到它收集結束(Stop The World)。

  • 優勢: 簡單而高效(與其他收集器的單執行緒比),對於限定單個CPU的環境來說,Serial收集器由於沒有執行緒交互的開銷,專心做垃圾收集自然可以獲得最高的單執行緒收集效率。

    • 運行在Client模式下的虛擬機是個不錯的選擇。
  • 在用戶的桌面應用場景中,可用記憶體一般不大(幾十MB至—兩百MB),可以在較短時間內完成垃圾收集(幾十ms至—百多ms) ,只要不頻繁發生,使用串列回收器是可以接受的。

  • 使用:在HotSpot虛擬機中,使用-XX:+UseSerialGC 參數可以指定年輕代和老年代都使用串列收集器。

    • 等價於新生代用Serial GC,且老年代用Serial Old GC

總結

  • 這種垃圾收集器大家了解,現在已經不用串列的了。而且在限定單核cpu才可以用。現在都不是單核的了

  • 對於交互較強的應用而言,這種垃圾收集器是不能接受的。一般在Javaweb應用程式中是不會採用串列垃圾收集器的。

9.4 ParNew回收器

並行回收

  • 如果說Serial GC是年輕代中的單執行緒垃圾收集器,那麼ParNew收集器則是Serial收集器的多執行緒版本。

    • Par是Parallel的縮寫,New:只能處理的是新生代
  • ParNew收集器除了採用並行回收的方式執行記憶體回收外,兩款垃圾收集器之間幾乎沒有任何區別。ParNew收 集器在年輕代中同樣也是採用複製演算法、”Stop-the-World”機制。

  • ParNew是很多JVM運行在server模式下新生代的默認垃圾收集器。

image-20220410202137491

  • 對於新生代,回收次數頻繁,使用並行方式高效

  • 對於老年代,回收次數少,使用串列方式節省資源(CPU並行需要切換執行緒,串列可以省去切換執行緒的資源)

由於ParNew收集器是基於並行回收,那麼是否可以斷定ParNew收集器的回收效率在任何場景下都會比Serial收集器更高效?

  • ParNew收集器運行在多CPU的環境下,由於可以充分利用多CPU、多核心等物理硬體資源優勢,可以更快速地完成垃圾收集,提升程式的吞吐量。
  • 但是在單個CPU的環境下,ParNew收集器不比Serial收集器更高效。雖然Serial收集器是基於串列回收,但是由於CPU不需要頻繁地做任務切換,因此可以有效避免多執行緒交互過程中產生的一些額外開銷。

使用

  • 在程式中,開發人員可以通過選項”-XX: +UseParNewGC“手動指定使用ParNew收集器執行記憶體回收任務。它表示年輕代使用並行收集器,不影響老年代。

  • -XX: ParallelGCThreads限制執行緒數量,默認開啟和CPU數據相同的執行緒數。

9.5 Parallel Scavenge回收器

吞吐量優先

HotSpot的年輕代中除了擁有ParNew收集器是基於並行回收的以外,Parallel Scavenge收集器同樣也採用了複製演算法、並行回收和”Stop the World”機制

那麼Parallel收集器的出現是否多此一舉?

  • 和ParNew收集器不同,Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput),它也被稱為吞吐量優先的垃圾收集器。
  • 自適應調節策略也是Parallel Scavenge與ParNew一個重要區別。

特點

  • 高吞吐量則可以高效率地利用CPU 時間,儘快完成程式的運算任務,主要適合在後台運算而不需要太多交互的任務。因此,常見在伺服器環境中使用。例如,那些執行批量處理、訂單處理、工資支付、科學計算的應用程式。

  • Parallel收集器在JDK1.6時提供了用於執行老年代垃圾收集的Parallel Old收集器,用來代替老年代的Serial Old收集器。

  • Parallel Old收集器採用了標記—壓縮演算法,但同樣也是基於並行回收和”Stop-the-World”機制

  • 在程式吞吐量優先的應用場景中,Parallel 收集器和Parallel Old 收集器的組合,在Server模式下的記憶體回收性能很不錯。

  • 在Java8中,默認是此垃圾收集器。

image-20220410203255264

參數設置

  • -XX: +UseParallelGC 手動指定年輕代使用Parallel並行收集器執行記憶體回收任務。

  • -XX: +UseParallelOldGC 手動指定老年代都是使用並行回收收集器。

    • 分別適用於新生代和老年代。默認jdk8是開啟的。

    • 上面兩個參數,默認開啟一個,另一個也會被開啟。 (互相激活)

  • -XX: ParallelGCThreads設置年輕代並行收集器的執行緒數。一般地,最好與CPU數量相等,以避免過多的執行緒數影響垃圾收集性能。

    • 在默認情況下,當CPU數量小於8個, ParallelGCThreads的值等於CPU數量。

    • 當CPU數量大於8個, ParallelGCThreads的值等於3+ [5*CPU_ Count]/8]

  • -XX:MaxGdPauseMillis設置垃圾收集器最大停頓時間(即STW的時間)。單位是毫秒。

    • 為了儘可能地把停頓時間控制在MaxGCPauseMills以內,收集器在工作時會調整Java堆大小或者其他一些參數。

    • 對於用戶來講,停頓時間越短體驗越好。但是在伺服器端,我們注重高並發,整體的吞吐量。所以伺服器端適合Parallel,進行控制。

    • 該參數使用需謹慎。

  • -XX:GCTimeRatio垃圾收集時間佔總時間的比例(=1/(N+1))。用于衡量吞吐量的大小。

    • 取值範圍(0, 100)。默認值99,也就是垃圾回收時間不超過1%。

    • 與前一個-XX:MaxGCPauseMillis參數有一定矛盾性。暫停時間越長,Radio參數就容易超過設定的比例。

  • -XX: +UseAdaptiveSizePolicy設置Parallel Scavenge收集器具有自適應調節策略

    • 在這種模式下,年輕代的大小、Eden和Survivor的比例、晉陞老年代的對象年齡等參數會被自動調整,已達到在堆大小、吞吐量和停頓時間之間的平衡點。

    • 在手動調優比較困難的場合,可以直接使用這種自適應的方式,僅指定虛擬機的最大堆、目標的吞吐量(GCT imeRatio)和停頓時間(MaxGCPauseMills),讓虛擬機自己完成調優工作。

9.6 CMS回收器

低延遲

概述

  • 在JDK 1.5時期,HotSpot 推出了一款在強交互應用中幾乎可認為有劃時代意義的垃圾收集器: CMS (Concurrent-Mark- Sweep)收集器,這款收集器是HotSpot虛擬機中第一款真正意義上的並發收集器,它第一次實現了讓垃圾收集執行緒與用戶執行緒同時工作。

  • CMS收集器的關注點是儘可能縮短垃圾收集時用戶執行緒的停頓時間。停頓時間越短(低延遲)就越適合與用戶交互的程式,良好的響應速度能提升用戶體驗。

    • 目前很大一部分的Java應用集中在互聯網站或者B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。CMS收集器就非常符合這類應用的需求。
  • CMS的垃圾收集演算法採用標記—清除演算法,並且也會”Stop-the-world”

不幸的是,CMS作為老年代的收集器,卻無法與JDK 1.4中已經存在的新生代收集器Parallel Scavenge配合工作,所以在JDK 1. 5中使用CMS來收集老年代的時候,新生代只能選擇ParNew或者Seria1收集器中的一一個。

在G1出現之前,CMS使用還是非常廣泛的。一直到今天,仍然有很多系統使用CMS GC。

image-20220410204752297

CMS整個過程比之前的收集器要複雜,整個過程分為4個主要階段,即初始標記階段、並發
標記階段、重新標記階段和並發清除階段。

  • 初始標記(Initial-Mark) 階段

    在這個階段中,程式中所有的工作執行緒都將會因為「Stop- the -World”機制而出現短暫的暫停,這個階段的主要任務僅僅只是標記出GCRoots能直接關聯到的對象。一旦標記完成之後就會恢復之前被暫停的所有應用執行緒。由於直接關聯對象比較小,所以這裡的速度非常快

  • 並發標記(Concurrent-Mark)階段

    從GC Roots的直接關聯對象開始遍歷整個對象圖的過程,這個過程耗時較長但是不需要停頓用戶執行緒,可以與垃圾收集執行緒一起並發運行。

  • 重新標記(Remark) 階段

    由於在並發標記階段中,程式的工作執行緒會和垃圾收集執行緒同時運行或者交叉運行,因此為了修正並發標記期間,因用戶程式繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間通常會比初始標記階段稍長一些,但也遠比並發標記階段的時間短。

  • 並發清除(Concurrent -Sweep)階段

    此階段清理刪除掉標記階段判斷的已經死亡的對象,釋放記憶體空間。由於不需要移動存活對象,所以這個階段也是可以與用戶執行緒同時並發的

儘管CMS收集器採用的是並發回收(非獨佔式),但是在其初始化標記和再次標記這兩個階段中仍然需要執行「Stop-the-World”機制暫停程式中的工作執行緒,不過暫停時間並不會太長,因此可以說明目前所有的垃圾收集器都做不到完全不需要「Stop -the-World”,只是儘可能地縮短暫停時間。

由於最耗費時間的並發標記與並發清除階段都不需要暫停工作,所以整體的回收是低停頓的。

另外,由於在垃圾收集階段用戶執行緒沒有中斷,所以在CMS回收過程中,還應該確保應用程式用戶執行緒有足夠的記憶體可用。因此,CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,而是當堆記憶體使用率達到某一閾值時,便開始進行回收,以確保應用程式在CMS工作過程中依然有足夠的空間支援應用程式運行。要是CMS運行期間預留的記憶體無法滿足程式需要,就會出現一次「Concurrent Mode Failure」失敗,這時虛擬機將啟動後備預案: 臨時啟用Serial Old收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了。

CMS收集器的垃圾收集演算法採用的是標記—清除演算法,這意味著每次執行完記憶體回收後,由於被執行記憶體回收的無用對象所佔用的記憶體空間極有可能是不連續的一些記憶體塊,不可避免地將會產生一些記憶體碎片。那麼CMS在為新對象分配記憶體空間時,將無法使用指針碰撞(Bump the Pointer) 技術,而只能夠選擇空閑列表(Free List) 執行記憶體分配。

image-20220410205507704

有人會覺得既然Mark Sweep會造成記憶體碎片,那麼為什麼不把演算法換成Mark Compact呢?

  • 答案其實很簡單,因為當並發清除的時候,用Compact整理記憶體的話,原來的用戶執行緒使用的記憶體還怎麼用呢?要保證用戶執行緒能繼續執行,前提的它運行的資源不受影響。

    Mark Compact更適合「Stop the World”這種場景下使用

CMS的優點

  • 並發收集

  • 低延遲

CMS的弊端

  1. 會產生記憶體碎片,導致並發清除後,用戶執行緒可用的空間不足。在無法分配大對象的情況下,不得不提前觸發Full GC。

  2. CMS收集器對CPU資源非常敏感。在並發階段,它雖然不會導致用戶停頓,但是會因為佔用了一部分執行緒而導致應用程式變慢,總吞吐量會降低。

  3. CMS收集器無法處理浮動垃圾。可能出現「Concurrent Mode Failure”失敗而導致另一次Full GC的產生。在並發標記階段由於程式的工作執行緒和垃圾收集執行緒是同時運行或者交叉運行的,那麼在並發標記階段如果產生新的垃圾對象,CMS將無法對這些垃圾對象進行標記,最終會導致這些新產生的垃圾對象沒有被及時回收,從而只能在下一次執行GC時釋放這些之前未被回收的記憶體空間。

設置參數

  • -XX: +UseConcMarksweepGC手動指定使用CMS收集器執行記憶體回收任務。

    • 開啟該參數後會自動將-XX: +UseParNewGC打開。

      即: ParNew (Young區用) +CMS(Old區用) +Serial old的組合。

  • -XX:CMSlnitiatingOccupanyFraction設置堆記憶體使用率的閥值,一旦達到該閾值,便開始進行回收。

    • JDK5及以前版本的默認值為68%,即當老年代的空間使用率達到68%時,會執行一次CMS回收。JDK6及以上版本默認值為92%
    • 如果記憶體增長緩慢,則可以設置一個稍大的值,大的閾值可以有效降低CMS的觸發頻率,減少老年代回收的次數可以較為明顯地改善應用程式性能。反之,如果應用程式記憶體使用率增長很快,則應該降低這個閾值,以避免頻繁觸發老年代串列收集器。因此通過該選項便可以有效降低Full GC的執行次數。
  • -XX: +UseCMSCompactAtFullCollection用於指定在執行完Full GC後對記憶體空間進行壓縮整理,以此避免記憶體碎片的產生。不過由於記憶體壓縮整理過程無法並發執行,所帶來的問題就是停頓時間變得更長了。

  • -XX:CMSFullGCsBeforeCompaction設置在執行多少次Full GC後對記憶體空間進行壓縮整理。

  • -XX: ParallelCMSThreads設置CMS的執行緒數量

    • CMS默認啟動的執行緒數是(ParallelGCThreads+3)/4, ParallelGCThreads是年輕代並行收集器的執行緒數。當CPU資源比較緊張時,受到CMS收集器執行緒的影響,應用程式的性能在垃圾回收階段可能會非常糟糕。

JDK9 CMS被標記為Deprecate

JDK14 正式刪除CMS垃圾回收器

小結

  • 如果你想要最小化地使用記憶體和並行開銷,請選Serial GC

  • 如果你想要最大化應用程式的吞吐量,請選Parallel GC

  • 如果你想要最小化GC的中斷或停頓時間,請選CMS GC

9.7 G1回收器

區域分代化

既然我們已經有了前面幾個強大的GC,為什麼還要發布Garbage First (G1) GC?

原因就在於應用程式所應對的業務越來越龐大、複雜,用戶越來越多,沒有GC就不能保證應用程式正常進行,而經常造成STW的GC又跟不上實際的需求,所以才會不斷地嘗試對GC進行優化。G1 (Garbage- First)垃圾回收器是在Java7 update 4之後引入的一一個新的垃圾回收器,是當今收集器技術發展的最前沿成果之一。

與此同時,為了適應現在不斷擴大的記憶體和不斷增加的處理器數量,進一步降低暫停時間(pause time) ,同時兼顧良好的吞吐量。

官方給G1設定的目標是在延遲可控的情況下獲得儘可能高的吞吐量,所以才擔當起「全功能收集器」的重任與期望。

為什麼名字叫做Garbage First (G1)呢?

  • 因為G1是一個並行回收器,它把堆記憶體分割為很多不相關的區域(Region) (物理上不連續的)。使用不同的Region來表示Eden、倖存者0區,倖存者1區,老年代等。

  • G1 GC有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1跟蹤各個Region裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在後台維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region。

  • 由於這種方式的側重點在於回收垃圾最大量的區間(Region) ,所以我們給G1一個名字: 垃圾優先(Garbage First) 。

G1 (Garbage- First)是一款面向服務端應用的垃圾收集器,主要針對配備多核CPU及大容量記憶體的機器,以極高概率滿足GC停頓時間的同時,還兼具高吞吐量的性能特徵。

在JDK1.7版本正式啟用,移除了Experimental的標識,是JDK 9以後的默認垃圾回收器,取代了CMS回收器以及Parallel + Parallel Old組合。被Oracle官方稱為「全功能的垃圾收集器

與此同時,CMS已經在JDK 9中被標記為廢棄(deprecated) 。在jdk8中還不是默認的垃圾回收器,需要使用-XX:+UseG1GC來啟用。

特點

  • 並行與並發

    • 並行性(回收執行緒並行): G1在回收期間,可以有多個GC執行緒同時工作,有效利用多核計算能力。此時用戶執行緒STW
    • 並發性(用戶與回收執行緒並發): G1擁有與應用程式交替執行的能力,部分工作可以和應用程式同時執行,因此,一般來說,不會在整個回收階段發生完全阻塞應用程式的情況
  • 分代收集

    • 從分代上看,G1依然屬於分代型垃圾回收器,它會區分年輕代和老年代,年輕代依然有Eden區和Survivor區,但從堆的結構上看,它不要求整個Eden區、年輕代或者老年代都是連續的,也不再堅持固定大小和固定數量。
    • 堆空間分為若干個區域(Region) , 這些區域中包含了邏輯上的年輕代和老年代
    • 和之前的各類回收器不同,它同時兼顧年輕代和老年代。對比其他回收器,或者工作在年輕代,或者工作在老年代
  • 空間整合

    • CMS: 「標記清除」演算法、記憶體碎片、若干次GC後進行一次碎片整理
    • G1將記憶體劃分為一個個的region。記憶體的回收是以region作為基本單位的。Region之間是複製演算法,但整體上實際可看作是標記—壓縮(Mark-Compact )演算法,兩種演算法都可以避免記憶體碎片。這種特性有利於程式長時間運行,分配大對象時不會因為無法找到連續記憶體空間而提前觸發下一次GC。尤其是當Java堆非常大的時候,G1的優勢更加明顯。
  • 可預測的停頓時間模型

    (即:軟實時soft real-time)

    這是G1相對於CMS的另一大優勢,G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M亳秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。

    • 由於分區的原因,G1可以只選取部分區域進行記憶體回收,這樣縮小了回收的範圍,因此對於全局停頓情況的發生也能得到較好的控制。
    • G1跟蹤各個Region 裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在後台維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region。保證了G1收集器在有限的時間內可以獲取儘可能高的收集效率
    • 相比於CMS GC, G1未必能做到CMs在最好情況下的延時停頓,但是最差情況要好很多。

缺點

  • 相較於CMS,G1還不具備全方位、壓倒性優勢。比如在用戶程式運行過程中,G1無論是為了垃圾收集產生的記憶體佔用(Footprint) 還是程式運行時的額外執行負載(Overload)都要比CMS要高。
  • 從經驗上來說,在小記憶體應用上CMS的表現大概率會優於G1,而G1在大記憶體應用上則發揮其優勢。平衡點在6- 8GB之間。

參數設置

  • -XX: +UseG1GC手動指定 使用G1收集器執行記憶體回收任務。

  • -XX: G1HeapRegionSize 設置每個Region的大小。值是2的冪,範圍是1MB到32MB之間,目標是根據最小的Java堆大小劃分出約2048個區域。默認是堆記憶體的 1/2000

  • -XX: MaxGCPauseMillis設置期望達到的最大GC停頓時間指標(JVM會儘力實現,但不保證達到)。默認值是200ms

  • -XX:ParallelGCThread設置STW工作執行緒數的值。最多設置為8

  • -XX: ConcGCThreads設置並發標記的執行緒數。將n設置為並行垃圾回收執行緒數(ParallelGCThreads)的1/4左右

  • -XX: InitiatingHeapOccupancyPercent設置觸發並發GC周期的Java堆佔用率閾值。超過此值,就觸發GC。默認值是45%。

G1的設計原則就是簡化JVM性能調優,開發人員只需要簡單的三步即可完成調優:

第一步: 開啟G1垃圾收集器

第二步: 設置堆的最大記憶體

第三步: 設置最大的停頓時間

G1中提供了三種垃圾回收模式: YoungGC、 Mixed GC和Full GC,在不同的條件下被觸發。

使用場景

  • 面向服務端應用,針對具有大記憶體、多處理器的機器。(在普通大小的堆里表現並不驚喜)

  • 最主要的應用是需要低GC延遲,並具有大堆的應用程式提供解決方案

  • 如: 在堆大小約6GB或更大時,可預測的暫停時間可以低於0.5秒; (G1通過每次只清理一部分而不是全部的Region的增量式清理來保證每次GC停頓時間不會過長)

  • 用來替換掉JDK1.5中的CMS收集器

    在下面的情況時,使用G1可能比CMS好

    • 超過50%的Java堆被活動數據佔用

    • 對象分配頻率或年代提升頻率變化很大

    • GC停頓時間過長(長於0.5至1秒)

  • HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用內置的JVM執行緒執行GC的多執行緒操作,而G1 GC可以採用應用執行緒承擔後台運行的GC工作,即當JVM的GC執行緒處理速度慢時,系統會調用應用程式執行緒幫助加速垃圾回收過程。

分區Region

使用G1收集器時,它將整個Java堆劃分成約2048個大小相同的獨立Region塊,每個Region塊大小根據堆空間的實際大小而定,整體被控制在1MB到32MB之間,且為2的N次冪,即1MB, 2MB, 4MB, 8MB, 16MB, 32MB。可以通過-XX:G1HeapRegionSize設定。所有的Region大小相同,且在JVM生命周期內不會被改變。

雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region (不需要連續)的集合。通過Region的動態分配方式實現邏輯上的連續。

image-20220410215838917

一個region有可能屬於Eden, Survivor 或者Old/Tenured 記憶體區域。但是一個region只可能屬於一個角色。圖中的E表示該region屬於Eden記憶體區域,S表示屬於Survivor記憶體區域,O表示屬於Old記憶體區域。圖中空白的表示未使用的記憶體空間。

G1垃圾收集器還增加了一種新的記憶體區域,叫做Humongous記憶體區域,如圖中的H塊。主要用於存儲大對象,如果超過1.5個region,就放到H。

設置H的原因

對於堆中的大對象,默認直接會被分配到老年代,但是如果它是一個短期存在的大對象,就會對垃圾收集器造成負面影響。為了解決這個問題,G1劃分了一個Humongous區,它用來專門存放大對象。如果一個H區裝不下一個大對象,那麼G1會尋找連續的H區來存儲。為了能找到連續的H區,有時候不得不啟動Full GC。G1的大多數行為都把H區作為老年代的一部分來看待。

回收過程

G1 GC的垃圾回收過程主要包括如下三個環節:

  • 年輕代GC (Young GC)
  • 老年代並發標記過程 (Concurrent Marking )
  • 混合回收 (Mixed GC)(年輕和老年代)
  • (如果需要,單執行緒、獨佔式、高強度的Full GC還是繼續存在的。它針對GC的評估失敗提供了一種失敗保護機制,即強力回收。)

image-20220410220915968

應用程式分配記憶體,當年輕代的Eden區用盡時開始年輕代回收過程:G1的年輕代收集階段是一個並行的獨佔式收集器。在年輕代回收期,G1 GC暫停所有應用程式執行緒,啟動多執行緒執行年輕代回收。然後從年輕代區間移動存活對象到Survivor區間或者老年區間,也有可能是兩個區間都會涉及。

當堆記憶體使用達到一定值(默認45%)時,開始老年代並發標記過程。

標記完成馬上開始混合回收過程。對於一個混合回收期,G1 GC從老年區間移動存活對象到空閑區間,這些空閑區間也就成為了老年代的一部分。和年輕代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整個老年代被回收,一次只需要掃描/回收一小部分老年代的Region就可以了。同時,這個老年代Region是和年輕代一起被回收的。

舉個例子: 一個Web伺服器,Java進程最大堆記憶體為4G,每分鐘響應1500個請求,每45秒鐘會新分配大約2G的記憶體。G1會每45秒鐘進行一次年輕代回收,每31個小時整個堆的使用率會達到45%,會開始老年代並發標記過程,標記完成後開始四到五次的混合回收。

Remembered Set

  • 一個對象被不同區域引用的問題(老年代對象可能引用Eden區)

  • 一個Region不可能是孤立的,一個Region中的對象可能被其他任意Region中對象引用,判斷對象存活時,是否需要掃描整個Java堆才能保證準確?

    • 在其他的分代收集器,也存在這樣的問題(而G1更突出)

    • 回收新生代也不得不同時掃描老年代?

    • 這樣的話會降低Minor GC的效率;

解決方法

  • 無論G1還是其他分代收集器,JVM都是使用Remembered Set來避免全局掃描
  • 每個Region都有一個對應的Remembered Set
  • 每次Reference類型數據寫操作時,都會產一個Write Barrier 暫時中斷操作
  • 然後檢查將要寫入的引用指向的對象是否和該Reference類型數據在不同的Region (其他收集器:檢查老年代對象是否引用了新生代對象)
  • 如果不同,通過CardTable把相關引用資訊記錄到引用指向對象的所在Region對應的Remembered Set中
  • 當進行垃圾收集時,在GC根節點的枚舉範圍加入Remembered Set; 就可以保證不進行全局掃描,也不會有遺漏。

image-20220410222258632

1. 年輕代GC

  • JVM啟動時,G1先準備好Eden區,程式在運行過程中不斷創建對象到Eden區當Eden空間耗盡時,G1會啟動一次年輕代垃圾回收過程。
  • 年輕代垃圾回收只會回收Eden區和Survivor區
  • YGC時, 首先G1停止應用程式的執行(Stop-The-World) ,G1創建回收集(Collection Set),回收集是指需要被回收的記憶體分段的集合,年輕代回收過程的回收集包含年輕代Eden區和Survivor區所有的記憶體分段。

image-20220410222915740

然後開始如下回收過程

第一階段:掃描根GC Roots

  • 根是指static變數指向的對象,正在執行的方法調用鏈條上的局部變數等。根引用連同RSet記錄的外部引用作為掃描存活對象的入口。

第二階段:更新RSet

  • 處理dirty card queue中的card,更新RSet。此階段完成後,RSet可以準確的反映老年代對所在的記憶體分段中對象的引用

    (使用card的原因是,Rset的處理需要執行緒同步,開銷大,先記錄在card里,然後統一更新,會更好)

第三階段:處理RSet

  • 識別被老年代對象指向的Eden中的對象,這些被指向的Eden中的對象被認為是存活的對象

第四階段:複製對象

  • 此階段,對象樹被遍歷,Eden區記憶體段中存活的對象會被複制到Survivor區中空的記憶體分段, Survivor區記憶體段中存活的對象如果年齡未達閾值,年齡會加1,達到閥值會被會被複制到Old區中空的記憶體分段。如果Survivor空間不夠,Eden空間的部分數據會直接晉陞到老年代空間。

第五階段:處理引用

  • 處理Soft,Weak,Phantom,Final, JNI Weak等引用。最終Eden空間的數據為空,GC停止工作,而目標記憶體中的對象都是連續存儲的,沒有碎片,所以複製過程可以達到記憶體整理的效果,減少碎片。

2. 並發標記過程

  1. 初始標記階段:標記從根節點直接可達的對象。這個階段是STW的,並且會觸發一次年輕代GC

  2. 根區域掃描(Root Region Scanning) :G1 GC掃描Survivor區直接可達的老年代區域對象,並標記被引用的對象。這一過程必須在young GC之前完成

  3. 並發標記(Concurrent Marking):在整個堆中進行並發標記(和應用程式並發執行) ,此過程可能被young GC中斷。在並發標記階段,若發現區域對象中的所有對象都是垃圾那這個區域會被立即回收。同時,並發標記過程中,會計算每個區域的對象活性(區域中存活對象的比例)。

  4. 再次標記(Remark):由於應用程式持續進行,需要修正上一次的標記結果。是STW的。G1中採用了比CMS更快的初始快照演算法: snapshot-at-the-beginning (SATB)

  5. 獨佔清理(cleanup,STW):計算各個區域的存活對象和GC回收比例,並進行排序,識別可以混合回收的區域。為下階段做鋪墊。是STW的。

    • 這個階段並不會實際上去做垃圾的收集
  6. 並發清理階段:識別並清理完全空閑的區域。

3. 混合回收

當越來越多的對象晉陞到老年代Oldregion時,為了避免堆記憶體被耗盡,虛擬機會觸發一個混合的垃圾收集器,即Mixed GC,該演算法並不是一個OldGC,除了回收整個Young Region,還會回收一部分的Old Region。 這裡需要注意:是一部分老年代,而不是全部老年代。可以選擇哪些OldRegion進行收集,從而可以對垃圾回收的耗時時間進行控制。也要注意的是Mixed GC並不是Full GC。

image-20220410224530411

並發標記結束以後,老年代中百分百為垃圾的記憶體分段被回收了,部分為垃圾的記憶體分段被計算了出來。默認情況下,這些老年代的記憶體分段會分8次(可以通過-XX: G1MixedGCCountTarget設置)被回收。

混合回收的回收集(Collection Set) 包括八分之一的老年代記憶體分段,Eden區記憶體分段,Survivor區 記憶體分段。混合回收的演算法和年輕代回收的演算法完全一樣,只是回收集多了老年代的記憶體分段。具體過程請參考上面的年輕代回收過程。

由於老年代中的記憶體分段默認分8次回收,G1會優先回收垃圾多的記憶體分段。垃圾占記憶體分段比例越高的,越會被先回收。並且有一個閾值會決定記憶體分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默認為65%,意思是垃圾占記憶體分段比例要達到65%才會被回收。如果垃圾佔比太低,意味著存活的對象佔比高,在複製的時候會花費更多的時間。

混合回收並不一定要進行8次。有一個閾值-XX: G1HeapWastePercent,默認值為10%,意思是允許整個堆記憶體中有10%的空間被浪費,意味著如果發現可以回收的垃圾占堆記憶體的比例低於10%,則不再進行混合回收。因為GC會花費很多的時間但是回收到的記憶體卻很少。

4. Full GC

G1的初衷就是要避免Full GC的出現。但是如果上述方式不能正常工作,G1會停止應用程式的執行(Stop-The-World) ,使用單執行緒的記憶體回收演算法進行垃圾回收,性能會非常差,應用程式停頓時間會很長。

要避免Full GC的發生,一旦發生需要進行調整。什麼時候會發生Full GC呢?

比如堆記憶體太小,當G1在複製存活對象的時候沒有空的記憶體分段可用,則會回退到Full gc,這種情況可以通過增大記憶體解決。

導致G1 Full GC的原因可能有兩個:

  1. Evacuation的時候沒有足夠的to-space來存放晉陞的對象;
  2. 並發處理過程完成之前空間耗盡。

9.8 總結

image-20220410225548070

參考資料

周志華:深入理解JAVA虛擬機第三版

宋紅康JVM