1. 程式人生 > 其它 >Java進階 JVM 記憶體與垃圾回收篇(一)

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

JVM

1. 引言

1.1 什麼是JVM?

定義

  • Java Vritual Machine - java 程式的執行環境(Java二進位制位元組碼的執行環境)

好處

  • 一次編譯 ,到處執行
  • 自動記憶體管理,垃圾回收功能
  • 資料下標越界越界檢查
  • 多型

比較

Jvm vs Jre vs JDK

1.2 學習路線

本文主要講解的是HotSpot VM

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

學習主要分為三個部分

此文為第一篇

  1. 記憶體與垃圾回收篇

    • JVM概述

    • 類載入過程

    • 執行時資料區

    • 執行引擎

    • 記憶體的分配與回收

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

  3. 效能調優篇

1.3 Java程式碼執行流程

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的優秀特性。

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

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型別,這個過程稱為虛擬機器的類載入機制。

類的生命週期

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

類載入器子系統的作用

  • 類載入器子系統負責從檔案系統或者網路中載入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 類的載入過程

載入

  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個,如下所示:

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

啟動類載入器

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. 如果父類載入器可以完成類載入任務,就成功返回,倘若父類載入器無法完成此載入任務,子載入器才會嘗試自己去載入,這就是雙親委派模式。

優勢

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

沙箱安全機制

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

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

3. 執行時資料區

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

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;
    }
}

動態連結

  • 每一個棧幀內部都包含一個指向執行時常量池中該棧幀所屬方法的引用。包含這個引用的目的就是為了支援當前方法的程式碼能夠實現動態連結(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檢視程序的執行情況,可發現死鎖

    程式發生了死鎖

3.3 本地方法棧

Native Method Stacks

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

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,垃圾收集器)執行垃圾回收的重點區域。

通過new關鍵字,建立物件都會使用堆記憶體

記憶體細分

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

設定引數

  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物件的銷燬都在新生代進行了

圖解物件分配過程

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

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

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的設計。

說明

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

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

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

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

堆空間的引數設定

官網說明書

  • -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 方法區

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

概述

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

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

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

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

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

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

  • 到了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)儲存內容描述如下: 它用於儲存已被虛擬機器載入的型別資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等。

  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異常。

方法區的演進細節

為什麼要使用元空間?

隨著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 小結

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. 物件的例項化

4.1 建立物件的步驟

  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 物件的記憶體佈局

記憶體佈局

  1. 物件頭(Header)

    • 執行時元資料

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

      • 指向類元資料,確定該物件的型別
  2. 例項資料(Instance Data)

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

例項

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

4.3 物件訪問定位

JVM是如何通過棧幀中的物件引用訪問到其內部的物件例項的呢?

物件訪問的兩種方式

  1. 控制代碼訪問

    好處:reference中儲存穩定控制代碼地址,物件被移動(垃圾收集時移動物件很普遍)時只會改變控制代碼中例項資料指標即可,reference本身不需要被修改。

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

5. 直接記憶體

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

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

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

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

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

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

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

使用IO讀取檔案

使用NIO讀取檔案

概述

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

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

  • 缺點

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

  • 如果不指定,預設與堆的最大值-Xmx引數值一致

6. 執行引擎

6.1 概述

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

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

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

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

執行引擎的工作過程

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

6.2 Java程式碼編譯和執行

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

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

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

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

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

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

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

拓展

  • 機器碼

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

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

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

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

    • 為了使計算機使用者程式設計序更容易些,後來就出現了各種高階計算機語言。高階語言比機器語言、組合語言更接近人的語言
    • 當計算機執行高階語言編寫的程式時,仍然需要把程式解釋和編譯成機器的指令碼。完成這個過程的程式就叫做解釋程式編譯程式
  • 位元組碼
    • 位元組碼是一種中間狀態(中間碼)的二進位制程式碼(檔案) ,它比機器碼更抽象,需要直譯器轉譯後才能成為機器碼
    • 位元組碼主要為了實現特定軟體執行和軟體環境、與硬體環境無關
    • 位元組碼的實現方式是通過編譯器和虛擬機器器。編譯器將原始碼編譯成位元組碼,特定平臺上的虛擬機器器將位元組碼轉譯為可以直接執行的指令。
      • 位元組碼的典型應用為Java bytecode

6.3 直譯器

在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檔案編譯成本地機器程式碼的過程。

熱點程式碼及探測方式

當然是否需要啟動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,然後判斷方法呼叫計數器與回邊計數器值之和是否超過方法呼叫計數器的閾值。如果已超過閾值,那麼將會向即時編譯器提交一個該方法的程式碼編譯請求。

  • 熱度衰減

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

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

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

回邊計數器

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

設定程式執行方式

預設情況下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
    }
}

總結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的垃圾回收器中沒有使用這類演算法。

      迴圈引用

小結

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

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

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

  • Python如何解決迴圈引用?

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

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

8.3.2 可達性分析演算法

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

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

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

基本思路

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

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

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

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

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中沒有標記為可達物件,則將其回收。

缺點

  • 效率不算高

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

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

注意: 何為清除?

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

8.5.2 複製演算法

核心思想

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

有點類似於from to區

優點

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

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

缺點

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

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

特別的

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

8.5.3 標記—壓縮演算法

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

執行過程

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

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

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

標記—壓縮演算法的最終效果等同於標記—清除演算法執行完成後,再進行一次記憶體碎片整理,因此,也可以把它稱為標記—清除—壓縮(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所產生的停頓。

分代演算法將按照物件的生命週期長短劃分成兩個部分,分割槽演算法將整個堆空間劃分成連續的不同小區間。

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

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異常,導致程式崩潰。

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

舉例

  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處理的速度非常快,只要時間間隔處理得當,即可讓使用者感覺是多個應用程式同時在進行。

6. 並行(parallel)

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

二者對比

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

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

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

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

  • 只有在多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

比較

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

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

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

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

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

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

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

9.2 垃圾回收器概述

經典垃圾收集器

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

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

  • 整堆收集器: G1

組合關係

  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收集器的後備垃圾收集方案

這個收集器是一個單執行緒的收集器,但它的“單執行緒”的意義並不僅僅說明它只會使用一個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模式下新生代的預設垃圾收集器。

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

  • 對於老年代,回收次數少,使用序列方式節省資源(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中,預設是此垃圾收集器。

引數設定

  • -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。

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) 執行記憶體分配。

有人會覺得既然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的動態分配方式實現邏輯上的連續。

一個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的評估失敗提供了一種失敗保護機制,即強力回收。)

應用程式分配記憶體,當年輕代的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; 就可以保證不進行全域性掃描,也不會有遺漏。

1. 年輕代GC

  • JVM啟動時,G1先準備好Eden區,程式在執行過程中不斷建立物件到Eden區當Eden空間耗盡時,G1會啟動一次年輕代垃圾回收過程。
  • 年輕代垃圾回收只會回收Eden區和Survivor區
  • YGC時, 首先G1停止應用程式的執行(Stop-The-World) ,G1建立回收集(Collection Set),回收集是指需要被回收的記憶體分段的集合,年輕代回收過程的回收集包含年輕代Eden區和Survivor區所有的記憶體分段。

然後開始如下回收過程

第一階段:掃描根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。

併發標記結束以後,老年代中百分百為垃圾的記憶體分段被回收了,部分為垃圾的記憶體分段被計算了出來。預設情況下,這些老年代的記憶體分段會分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 總結

參考資料

周志華:深入理解JAVA虛擬機器第三版

宋紅康JVM