2萬字長文包教包會 JVM 內存結構 保姆級學習筆記

寫這篇的主要原因呢,就是爲了能在簡歷上寫個“熟悉JVM底層結構”,另一個原因就是能讓讀我文章的大家也寫上這句話,真是個助人爲樂的帥小夥。。。。嗯,不單單只是面向面試學習哈,更重要的是構建自己的 JVM 知識體系,Javaer 們技術棧要有廣度,但是 JVM 的掌握必須有深度

點贊+收藏 就學會系列,文章收錄在 GitHub JavaKeeper ,N線互聯網開發必備技能兵器譜,筆記自取

直擊面試

反正我是帶着這些問題往下讀的

  • 說一下 JVM 運行時數據區吧,都有哪些區?分別是幹什麼的?
  • Java 8 的內存分代改進
  • 舉例棧溢出的情況?
  • 調整棧大小,就能保存不出現溢出嗎?
  • 分配的棧內存越大越好嗎?
  • 垃圾回收是否會涉及到虛擬機棧?
  • 方法中定義的局部變量是否線程安全?

運行時數據區

內存是非常重要的系統資源,是硬盤和 CPU 的中間倉庫及橋樑,承載着操作系統和應用程序的實時運行。JVM 內存佈局規定了 Java 在運行過程中內存申請、分配、管理的策略,保證了 JVM 的高效穩定運行。不同的 JVM 對於內存的劃分方式和管理機制存在着部分差異。

下圖是 JVM 整體架構,中間部分就是 Java 虛擬機定義的各種運行時數據區域。

jvm-framework

Java 虛擬機定義了若干種程序運行期間會使用到的運行時數據區,其中有一些會隨着虛擬機啓動而創建,隨着虛擬機退出而銷燬。另外一些則是與線程一一對應的,這些與線程一一對應的數據區域會隨着線程開始和結束而創建和銷燬。

  • 線程私有:程序計數器、棧、本地棧
  • 線程共享:堆、堆外內存(永久代或元空間、代碼緩存)

下面我們就來一一解毒下這些內存區域,先從最簡單的入手

一、程序計數器

程序計數寄存器(Program Counter Register),Register 的命名源於 CPU 的寄存器,寄存器存儲指令相關的線程信息,CPU 只有把數據裝載到寄存器才能夠運行。

這裏,並非是廣義上所指的物理寄存器,叫程序計數器(或PC計數器或指令計數器)會更加貼切,並且也不容易引起一些不必要的誤會。JVM 中的 PC 寄存器是對物理 PC 寄存器的一種抽象模擬

程序計數器是一塊較小的內存空間,可以看作是當前線程所執行的字節碼的行號指示器

1.1 作用

PC 寄存器用來存儲指向下一條指令的地址,即將要執行的指令代碼。由執行引擎讀取下一條指令。

jvm-pc-counter

(分析:進入class文件所在目錄,執行 javap -v xx.class 反解析(或者通過 IDEA 插件 Jclasslib 直接查看,上圖),可以看到當前類對應的Code區(彙編指令)、本地變量表、異常表和代碼行偏移量映射表、常量池等信息。)

1.2 概述

  • 它是一塊很小的內存空間,幾乎可以忽略不計。也是運行速度最快的存儲區域
  • 在 JVM 規範中,每個線程都有它自己的程序計數器,是線程私有的,生命週期與線程的生命週期一致
  • 任何時間一個線程都只有一個方法在執行,也就是所謂的當前方法。如果當前線程正在執行的是 Java 方法,程序計數器記錄的是 JVM 字節碼指令地址,如果是執行 natice 方法,則是未指定值(undefined)
  • 它是程序控制流的指示器,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成
  • 字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令
  • 它是唯一一個在 JVM 規範中沒有規定任何 OutOfMemoryError 情況的區域

👨‍💻:使用PC寄存器存儲字節碼指令地址有什麼用呢?爲什麼使用PC寄存器記錄當前線程的執行地址呢?

🙋‍♂️:因爲CPU需要不停的切換各個線程,這時候切換回來以後,就得知道接着從哪開始繼續執行。JVM的字節碼解釋器就需要通過改變PC寄存器的值來明確下一條應該執行什麼樣的字節碼指令。

👨‍💻:PC寄存器爲什麼會被設定爲線程私有的?

🙋‍♂️:多線程在一個特定的時間段內只會執行其中某一個線程方法,CPU會不停的做任務切換,這樣必然會導致經常中斷或恢復。爲了能夠準確的記錄各個線程正在執行的當前字節碼指令地址,所以爲每個線程都分配了一個PC寄存器,每個線程都獨立計算,不會互相影響。


二、虛擬機棧

2.1 概述

Java 虛擬機棧(Java Virtual Machine Stacks),早期也叫 Java 棧。每個線程在創建的時候都會創建一個虛擬機棧,其內部保存一個個的棧幀(Stack Frame),對應着一次次 Java 方法調用,是線程私有的,生命週期和線程一致。

作用:主管 Java 程序的運行,它保存方法的局部變量、部分結果,並參與方法的調用和返回。

特點:

  • 棧是一種快速有效的分配存儲方式,訪問速度僅次於程序計數器
  • JVM 直接對虛擬機棧的操作只有兩個:每個方法執行,伴隨着入棧(進棧/壓棧),方法執行結束出棧
  • 棧不存在垃圾回收問題

棧中可能出現的異常:

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

  • 如果採用固定大小的 Java 虛擬機棧,那每個線程的 Java 虛擬機棧容量可以在線程創建的時候獨立選定。如果線程請求分配的棧容量超過 Java 虛擬機棧允許的最大容量,Java 虛擬機將會拋出一個 StackOverflowError 異常
  • 如果 Java 虛擬機棧可以動態擴展,並且在嘗試擴展的時候無法申請到足夠的內存,或者在創建新的線程時沒有足夠的內存去創建對應的虛擬機棧,那 Java 虛擬機將會拋出一個OutOfMemoryError異常

可以通過參數-Xss來設置線程的最大棧空間,棧的大小直接決定了函數調用的最大可達深度。

官方提供的參考工具,可查一些參數和操作:https://docs.oracle.com/javase/8/docs/technotes/tools/windows/java.html#BGBCIEFC

2.2 棧的存儲單位

棧中存儲什麼?

  • 每個線程都有自己的棧,棧中的數據都是以棧幀(Stack Frame)的格式存在
  • 在這個線程上正在執行的每個方法都各自有對應的一個棧幀
  • 棧幀是一個內存區塊,是一個數據集,維繫着方法執行過程中的各種數據信息

2.3 棧運行原理

  • JVM 直接對 Java 棧的操作只有兩個,對棧幀的壓棧出棧,遵循“先進後出/後進先出”原則

  • 在一條活動線程中,一個時間點上,只會有一個活動的棧幀。即只有當前正在執行的方法的棧幀(棧頂棧幀)是有效的,這個棧幀被稱爲當前棧幀(Current Frame),與當前棧幀對應的方法就是當前方法(Current Method),定義這個方法的類就是當前類(Current Class)

  • 執行引擎運行的所有字節碼指令只針對當前棧幀進行操作

  • 如果在該方法中調用了其他方法,對應的新的棧幀會被創建出來,放在棧的頂端,稱爲新的當前棧幀

  • 不同線程中所包含的棧幀是不允許存在相互引用的,即不可能在一個棧幀中引用另外一個線程的棧幀

  • 如果當前方法調用了其他方法,方法返回之際,當前棧幀會傳回此方法的執行結果給前一個棧幀,接着,虛擬機會丟棄當前棧幀,使得前一個棧幀重新成爲當前棧幀

  • Java 方法有兩種返回函數的方式,一種是正常的函數返回,使用 return 指令,另一種是拋出異常,不管用哪種方式,都會導致棧幀被彈出

IDEA 在 debug 時候,可以在 debug 窗口看到 Frames 中各種方法的壓棧和出棧情況

2.4 棧幀的內部結構

每個棧幀(Stack Frame)中存儲着:

  • 局部變量表(Local Variables)
  • 操作數棧(Operand Stack)(或稱爲表達式棧)
  • 動態鏈接(Dynamic Linking):指向運行時常量池的方法引用
  • 方法返回地址(Return Address):方法正常退出或異常退出的地址
  • 一些附加信息

jvm-stack-frame

繼續深拋棧幀中的五部分~~

2.4.1. 局部變量表

  • 局部變量表也被稱爲局部變量數組或者本地變量表
  • 是一組變量值存儲空間,主要用於存儲方法參數和定義在方法體內的局部變量,包括編譯器可知的各種 Java 虛擬機基本數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它並不等同於對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此相關的位置)和 returnAddress 類型(指向了一條字節碼指令的地址,已被異常表取代)
  • 由於局部變量表是建立在線程的棧上,是線程的私有數據,因此不存在數據安全問題
  • 局部變量表所需要的容量大小是編譯期確定下來的,並保存在方法的 Code 屬性的 maximum local variables 數據項中。在方法運行期間是不會改變局部變量表的大小的
  • 方法嵌套調用的次數由棧的大小決定。一般來說,棧越大,方法嵌套調用次數越多。對一個函數而言,它的參數和局部變量越多,使得局部變量表膨脹,它的棧幀就越大,以滿足方法調用所需傳遞的信息增大的需求。進而函數調用就會佔用更多的棧空間,導致其嵌套調用次數就會減少。
  • 局部變量表中的變量只在當前方法調用中有效。在方法執行時,虛擬機通過使用局部變量表完成參數值到參數變量列表的傳遞過程。當方法調用結束後,隨着方法棧幀的銷燬,局部變量表也會隨之銷燬。
  • 參數值的存放總是在局部變量數組的 index0 開始,到數組長度 -1 的索引結束
槽 Slot
  • 局部變量表最基本的存儲單元是 Slot(變量槽)

  • 在局部變量表中,32 位以內的類型只佔用一個 Slot(包括returnAddress類型),64 位的類型(long和double)佔用兩個連續的 Slot

    • byte、short、char 在存儲前被轉換爲int,boolean也被轉換爲int,0 表示 false,非 0 表示 true
    • long 和 double 則佔據兩個 Slot
  • JVM 會爲局部變量表中的每一個 Slot 都分配一個訪問索引,通過這個索引即可成功訪問到局部變量表中指定的局部變量值,索引值的範圍從 0 開始到局部變量表最大的 Slot 數量

  • 當一個實例方法被調用的時候,它的方法參數和方法體內部定義的局部變量將會按照順序被複制到局部變量表中的每一個 Slot 上

  • 如果需要訪問局部變量表中一個 64bit 的局部變量值時,只需要使用前一個索引即可。(比如:訪問 long 或double 類型變量,不允許採用任何方式單獨訪問其中的某一個 Slot)

  • 如果當前幀是由構造方法或實例方法創建的,那麼該對象引用 this 將會存放在 index 爲 0 的 Slot 處,其餘的參數按照參數表順序繼續排列(這裏就引出一個問題:靜態方法中爲什麼不可以引用 this,就是因爲this 變量不存在於當前方法的局部變量表中)

  • 棧幀中的局部變量表中的槽位是可以重用的,如果一個局部變量過了其作用域,那麼在其作用域之後申明的新的局部變量就很有可能會複用過期局部變量的槽位,從而達到節省資源的目的。(下圖中,this、a、b、c 理論上應該有 4 個變量,c 複用了 b 的槽)

  • 在棧幀中,與性能調優關係最爲密切的就是局部變量表。在方法執行時,虛擬機使用局部變量表完成方法的傳遞
  • 局部變量表中的變量也是重要的垃圾回收根節點,只要被局部變量表中直接或間接引用的對象都不會被回收

2.4.2. 操作數棧

  • 每個獨立的棧幀中除了包含局部變量表之外,還包含一個後進先出(Last-In-First-Out)的操作數棧,也可以稱爲表達式棧(Expression Stack)

  • 操作數棧,在方法執行過程中,根據字節碼指令,往操作數棧中寫入數據或提取數據,即入棧(push)、出棧(pop)

  • 某些字節碼指令將值壓入操作數棧,其餘的字節碼指令將操作數取出棧。使用它們後再把結果壓入棧。比如,執行復制、交換、求和等操作

概述
  • 操作數棧,主要用於保存計算過程的中間結果,同時作爲計算過程中變量臨時的存儲空間
  • 操作數棧就是 JVM 執行引擎的一個工作區,當一個方法剛開始執行的時候,一個新的棧幀也會隨之被創建出來,此時這個方法的操作數棧是空的
  • 每一個操作數棧都會擁有一個明確的棧深度用於存儲數值,其所需的最大深度在編譯期就定義好了,保存在方法的 Code 屬性的 max_stack 數據項中
  • 棧中的任何一個元素都可以是任意的 Java 數據類型
    • 32bit 的類型佔用一個棧單位深度
    • 64bit 的類型佔用兩個棧單位深度
  • 操作數棧並非採用訪問索引的方式來進行數據訪問的,而是隻能通過標準的入棧和出棧操作來完成一次數據訪問
  • 如果被調用的方法帶有返回值的話,其返回值將會被壓入當前棧幀的操作數棧中,並更新PC寄存器中下一條需要執行的字節碼指令
  • 操作數棧中元素的數據類型必須與字節碼指令的序列嚴格匹配,這由編譯器在編譯期間進行驗證,同時在類加載過程中的類檢驗階段的數據流分析階段要再次驗證
  • 另外,我們說Java虛擬機的解釋引擎是基於棧的執行引擎,其中的棧指的就是操作數棧
棧頂緩存(Top-of-stack-Cashing)

HotSpot 的執行引擎採用的並非是基於寄存器的架構,但這並不代表 HotSpot VM 的實現並沒有間接利用到寄存器資源。寄存器是物理 CPU 中的組成部分之一,它同時也是 CPU 中非常重要的高速存儲資源。一般來說,寄存器的讀/寫速度非常迅速,甚至可以比內存的讀/寫速度快上幾十倍不止,不過寄存器資源卻非常有限,不同平臺下的CPU 寄存器數量是不同和不規律的。寄存器主要用於緩存本地機器指令、數值和下一條需要被執行的指令地址等數據。

基於棧式架構的虛擬機所使用的零地址指令更加緊湊,但完成一項操作的時候必然需要使用更多的入棧和出棧指令,這同時也就意味着將需要更多的指令分派(instruction dispatch)次數和內存讀/寫次數。由於操作數是存儲在內存中的,因此頻繁的執行內存讀/寫操作必然會影響執行速度。爲了解決這個問題,HotSpot JVM 設計者們提出了棧頂緩存技術,將棧頂元素全部緩存在物理 CPU 的寄存器中,以此降低對內存的讀/寫次數,提升執行引擎的執行效率

2.4.3. 動態鏈接(指向運行時常量池的方法引用)

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

jvm-dynamic-linking

JVM 是如何執行方法調用的

方法調用不同於方法執行,方法調用階段的唯一任務就是確定被調用方法的版本(即調用哪一個方法),暫時還不涉及方法內部的具體運行過程。Class 文件的編譯過程中不包括傳統編譯器中的連接步驟,一切方法調用在 Class文件裏面存儲的都是符號引用,而不是方法在實際運行時內存佈局中的入口地址(直接引用)。也就是需要在類加載階段,甚至到運行期才能確定目標方法的直接引用。

【這一塊內容,除了方法調用,還包括解析、分派(靜態分派、動態分派、單分派與多分派),這裏先不介紹,後續再挖】

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

  • 靜態鏈接:當一個字節碼文件被裝載進 JVM 內部時,如果被調用的目標方法在編譯期可知,且運行期保持不變時。這種情況下將調用方法的符號引用轉換爲直接引用的過程稱之爲靜態鏈接
  • 動態鏈接:如果被調用的方法在編譯期無法被確定下來,也就是說,只能在程序運行期將調用方法的符號引用轉換爲直接引用,由於這種引用轉換過程具備動態性,因此也就被稱之爲動態鏈接

對應的方法的綁定機制爲:早期綁定(Early Binding)和晚期綁定(Late Binding)。綁定是一個字段、方法或者類在符號引用被替換爲直接引用的過程,這僅僅發生一次

  • 早期綁定:早期綁定就是指被調用的目標方法如果在編譯期可知,且運行期保持不變時,即可將這個方法與所屬的類型進行綁定,這樣一來,由於明確了被調用的目標方法究竟是哪一個,因此也就可以使用靜態鏈接的方式將符號引用轉換爲直接引用。
  • 晚期綁定:如果被調用的方法在編譯器無法被確定下來,只能夠在程序運行期根據實際的類型綁定相關的方法,這種綁定方式就被稱爲晚期綁定。
虛方法和非虛方法
  • 如果方法在編譯器就確定了具體的調用版本,這個版本在運行時是不可變的。這樣的方法稱爲非虛方法,比如靜態方法、私有方法、final 方法、實例構造器、父類方法都是非虛方法
  • 其他方法稱爲虛方法
虛方法表

在面向對象編程中,會頻繁的使用到動態分派,如果每次動態分派都要重新在類的方法元數據中搜索合適的目標有可能會影響到執行效率。爲了提高性能,JVM 採用在類的方法區建立一個虛方法表(virtual method table),使用索引表來代替查找。非虛方法不會出現在表中。

每個類中都有一個虛方法表,表中存放着各個方法的實際入口。

虛方法表會在類加載的連接階段被創建並開始初始化,類的變量初始值準備完成之後,JVM 會把該類的方法表也初始化完畢。

2.4.4. 方法返回地址(return address)

用來存放調用該方法的 PC 寄存器的值。

一個方法的結束,有兩種方式

  • 正常執行完成
  • 出現未處理的異常,非正常退出

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

當一個方法開始執行後,只有兩種方式可以退出這個方法:

  1. 執行引擎遇到任意一個方法返回的字節碼指令,會有返回值傳遞給上層的方法調用者,簡稱正常完成出口

    一個方法的正常調用完成之後究竟需要使用哪一個返回指令還需要根據方法返回值的實際數據類型而定

    在字節碼指令中,返回指令包含 ireturn(當返回值是 boolean、byte、char、short 和 int 類型時使用)、lreturn、freturn、dreturn 以及 areturn,另外還有一個 return 指令供聲明爲 void 的方法、實例初始化方法、類和接口的初始化方法使用。

  2. 在方法執行的過程中遇到了異常,並且這個異常沒有在方法內進行處理,也就是隻要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導致方法退出。簡稱異常完成出口

    方法執行過程中拋出異常時的異常處理,存儲在一個異常處理表,方便在發生異常的時候找到處理異常的代碼。

本質上,方法的退出就是當前棧幀出棧的過程。此時,需要恢復上層方法的局部變量表、操作數棧、將返回值壓入調用者棧幀的操作數棧、設置PC寄存器值等,讓調用者方法繼續執行下去。

正常完成出口和異常完成出口的區別在於:通過異常完成出口退出的不會給他的上層調用者產生任何的返回值

2.4.5. 附加信息

棧幀中還允許攜帶與 Java 虛擬機實現相關的一些附加信息。例如,對程序調試提供支持的信息,但這些信息取決於具體的虛擬機實現。


三、本地方法棧

3.1 本地方法接口

簡單的講,一個 Native Method 就是一個 Java 調用非 Java 代碼的接口。我們知道的 Unsafe 類就有很多本地方法。

爲什麼要使用本地方法(Native Method)?

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

  • 與 Java 環境外交互:有時 Java 應用需要與 Java 外面的環境交互,這就是本地方法存在的原因。
  • 與操作系統交互:JVM 支持 Java 語言本身和運行時庫,但是有時仍需要依賴一些底層系統的支持。通過本地方法,我們可以實現用 Java 與實現了 jre 的底層系統交互, JVM 的一些部分就是 C 語言寫的。
  • Sun's Java:Sun的解釋器就是C實現的,這使得它能像一些普通的C一樣與外部交互。jre大部分都是用 Java 實現的,它也通過一些本地方法與外界交互。比如,類 java.lang.ThreadsetPriority() 的方法是用Java 實現的,但它實現調用的是該類的本地方法 setPrioruty(),該方法是C實現的,並被植入 JVM 內部。

3.2 本地方法棧(Native Method Stack)

  • Java 虛擬機棧用於管理 Java 方法的調用,而本地方法棧用於管理本地方法的調用

  • 本地方法棧也是線程私有的

  • 允許線程固定或者可動態擴展的內存大小

    • 如果線程請求分配的棧容量超過本地方法棧允許的最大容量,Java 虛擬機將會拋出一個 StackOverflowError 異常
    • 如果本地方法棧可以動態擴展,並且在嘗試擴展的時候無法申請到足夠的內存,或者在創建新的線程時沒有足夠的內存去創建對應的本地方法棧,那麼 Java虛擬機將會拋出一個OutofMemoryError異常
  • 本地方法是使用 C 語言實現的

  • 它的具體做法是 Mative Method Stack 中登記 native 方法,在 Execution Engine 執行時加載本地方法庫當某個線程調用一個本地方法時,它就進入了一個全新的並且不再受虛擬機限制的世界。它和虛擬機擁有同樣的權限。

  • 本地方法可以通過本地方法接口來訪問虛擬機內部的運行時數據區,它甚至可以直接使用本地處理器中的寄存器,直接從本地內存的堆中分配任意數量的內存

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

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


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

棧解決程序的運行問題,即程序如何執行,或者說如何處理數據。堆解決的是數據存儲的問題,即數據怎麼放、放在哪。

四、堆內存

4.1 內存劃分

對於大多數應用,Java 堆是 Java 虛擬機管理的內存中最大的一塊,被所有線程共享。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數據都在這裏分配內存。

爲了進行高效的垃圾回收,虛擬機把堆內存邏輯上劃分成三塊區域(分代的唯一理由就是優化 GC 性能):

  • 新生帶(年輕代):新對象和沒達到一定年齡的對象都在新生代
  • 老年代(養老區):被長時間使用的對象,老年代的內存空間應該要比年輕代更大
  • 元空間(JDK1.8 之前叫永久代):像一些方法中的操作臨時對象等,JDK1.8 之前是佔用 JVM 內存,JDK1.8 之後直接使用物理內存

JDK7

Java 虛擬機規範規定,Java 堆可以是處於物理上不連續的內存空間中,只要邏輯上是連續的即可,像磁盤空間一樣。實現時,既可以是固定大小,也可以是可擴展的,主流虛擬機都是可擴展的(通過 -Xmx-Xms 控制),如果堆中沒有完成實例分配,並且堆無法再擴展時,就會拋出 OutOfMemoryError 異常。

年輕代 (Young Generation)

年輕代是所有新對象創建的地方。當填充年輕代時,執行垃圾收集。這種垃圾收集稱爲 Minor GC。年輕一代被分爲三個部分——伊甸園(Eden Memory)和兩個倖存區(Survivor Memory,被稱爲from/to或s0/s1),默認比例是8:1:1

  • 大多數新創建的對象都位於 Eden 內存空間中
  • 當 Eden 空間被對象填充時,執行Minor GC,並將所有幸存者對象移動到一個倖存者空間中
  • Minor GC 檢查倖存者對象,並將它們移動到另一個倖存者空間。所以每次,一個倖存者空間總是空的
  • 經過多次 GC 循環後存活下來的對象被移動到老年代。通常,這是通過設置年輕一代對象的年齡閾值來實現的,然後他們纔有資格提升到老一代

老年代(Old Generation)

舊的一代內存包含那些經過許多輪小型 GC 後仍然存活的對象。通常,垃圾收集是在老年代內存滿時執行的。老年代垃圾收集稱爲 主GC(Major GC),通常需要更長的時間。

大對象直接進入老年代(大對象是指需要大量連續內存空間的對象)。這樣做的目的是避免在 Eden 區和兩個Survivor 區之間發生大量的內存拷貝

元空間

不管是 JDK8 之前的永久代,還是 JDK8 及以後的元空間,都可以看作是 Java 虛擬機規範中方法區的實現。

雖然 Java 虛擬機規範把方法區描述爲堆的一個邏輯部分,但是它卻有一個別名叫 Non-Heap(非堆),目的應該是與 Java 堆區分開。

所以元空間放在後邊的方法區再說。

4.2 設置堆內存大小和 OOM

Java 堆用於存儲 Java 對象實例,那麼堆的大小在 JVM 啓動的時候就確定了,我們可以通過 -Xmx-Xms 來設定

  • -Xmx 用來表示堆的起始內存,等價於 -XX:InitialHeapSize
  • -Xms 用來表示堆的最大內存,等價於 -XX:MaxHeapSize

如果堆的內存大小超過 -Xms 設定的最大內存, 就會拋出 OutOfMemoryError 異常。

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

  • 默認情況下,初始堆內存大小爲:電腦內存大小/64

  • 默認情況下,最大堆內存大小爲:電腦內存大小/4

可以通過代碼獲取到我們的設置值,當然也可以模擬 OOM:

public static void main(String[] args) {

  //返回 JVM 堆大小
  long initalMemory = Runtime.getRuntime().totalMemory() / 1024 /1024;
  //返回 JVM 堆的最大內存
  long maxMemory = Runtime.getRuntime().maxMemory() / 1024 /1024;

  System.out.println("-Xms : "+initalMemory + "M");
  System.out.println("-Xmx : "+maxMemory + "M");

  System.out.println("系統內存大小:" + initalMemory * 64 / 1024 + "G");
  System.out.println("系統內存大小:" + maxMemory * 4 / 1024 + "G");
}

查看 JVM 堆內存分配

  1. 在默認不配置 JVM 堆內存大小的情況下,JVM 根據默認值來配置當前內存大小

  2. 默認情況下新生代和老年代的比例是 1:2,可以通過 –XX:NewRatio 來配置

    • 新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通過 -XX:SurvivorRatio 來配置
  3. 若在 JDK 7 中開啓了 -XX:+UseAdaptiveSizePolicy,JVM 會動態調整 JVM 堆中各個區域的大小以及進入老年代的年齡

    此時 –XX:NewRatio-XX:SurvivorRatio 將會失效,而 JDK 8 是默認開啓-XX:+UseAdaptiveSizePolicy

    在 JDK 8中,不要隨意關閉-XX:+UseAdaptiveSizePolicy,除非對堆內存的劃分有明確的規劃

每次 GC 後都會重新計算 Eden、From Survivor、To Survivor 的大小

計算依據是GC過程中統計的GC時間吞吐量內存佔用量

java -XX:+PrintFlagsFinal -version | grep HeapSize
    uintx ErgoHeapSizeLimit                         = 0                                   {product}
    uintx HeapSizePerGCThread                       = 87241520                            {product}
    uintx InitialHeapSize                          := 134217728                           {product}
    uintx LargePageHeapSizeThreshold                = 134217728                           {product}
    uintx MaxHeapSize                              := 2147483648                          {product}
java version "1.8.0_211"
Java(TM) SE Runtime Environment (build 1.8.0_211-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)
$ jmap -heap 進程號

4.3 對象在堆中的生命週期

  1. 在 JVM 內存模型的堆中,堆被劃分爲新生代和老年代
    • 新生代又被進一步劃分爲 Eden區Survivor區,Survivor 區由 From SurvivorTo Survivor 組成
  2. 當創建一個對象時,對象會被優先分配到新生代的 Eden 區
    • 此時 JVM 會給對象定義一個對象年輕計數器-XX:MaxTenuringThreshold
  3. 當 Eden 空間不足時,JVM 將執行新生代的垃圾回收(Minor GC)
    • JVM 會把存活的對象轉移到 Survivor 中,並且對象年齡 +1
    • 對象在 Survivor 中同樣也會經歷 Minor GC,每經歷一次 Minor GC,對象年齡都會+1
  4. 如果分配的對象超過了-XX:PetenureSizeThreshold,對象會直接被分配到老年代

4.4 對象的分配過程

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

  1. new 的對象先放在伊甸園區,此區有大小限制
  2. 當伊甸園的空間填滿時,程序又需要創建對象,JVM 的垃圾回收器將對伊甸園區進行垃圾回收(Minor GC),將伊甸園區中的不再被其他對象所引用的對象進行銷燬。再加載新的對象放到伊甸園區
  3. 然後將伊甸園中的剩餘對象移動到倖存者 0 區
  4. 如果再次觸發垃圾回收,此時上次倖存下來的放到倖存者 0 區,如果沒有回收,就會放到倖存者 1 區
  5. 如果再次經歷垃圾回收,此時會重新放回倖存者 0 區,接着再去倖存者 1 區
  6. 什麼時候纔會去養老區呢? 默認是 15 次回收標記
  7. 在養老區,相對悠閒。當養老區內存不足時,再次觸發 Major GC,進行養老區的內存清理
  8. 若養老區執行了 Major GC 之後發現依然無法進行對象的保存,就會產生 OOM 異常

4.5 GC 垃圾回收簡介

Minor GC、Major GC、Full GC

JVM 在進行 GC 時,並非每次都對堆內存(新生代、老年代;方法區)區域一起回收的,大部分時候回收的都是指新生代。

針對 HotSpot VM 的實現,它裏面的 GC 按照回收區域又分爲兩大類:部分收集(Partial GC),整堆收集(Full GC)

  • 部分收集:不是完整收集整個 Java 堆的垃圾收集。其中又分爲:
    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
      • 目前,只有 CMS GC 會有單獨收集老年代的行爲
      • 很多時候 Major GC 會和 Full GC 混合使用,需要具體分辨是老年代回收還是整堆回收
    • 混合收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集
      • 目前只有 G1 GC 會有這種行爲
  • 整堆收集(Full GC):收集整個 Java 堆和方法區的垃圾

4.6 TLAB

什麼是 TLAB (Thread Local Allocation Buffer)?

  • 從內存模型而不是垃圾回收的角度,對 Eden 區域繼續進行劃分,JVM 爲每個線程分配了一個私有緩存區域,它包含在 Eden 空間內
  • 多線程同時分配內存時,使用 TLAB 可以避免一系列的非線程安全問題,同時還能提升內存分配的吞吐量,因此我們可以將這種內存分配方式稱爲快速分配策略
  • OpenJDK 衍生出來的 JVM 大都提供了 TLAB 設計

爲什麼要有 TLAB ?

  • 堆區是線程共享的,任何線程都可以訪問到堆區中的共享數據
  • 由於對象實例的創建在 JVM 中非常頻繁,因此在併發環境下從堆區中劃分內存空間是線程不安全的
  • 爲避免多個線程操作同一地址,需要使用加鎖等機制,進而影響分配速度

儘管不是所有的對象實例都能夠在 TLAB 中成功分配內存,但 JVM 確實是將 TLAB 作爲內存分配的首選。

在程序中,可以通過 -XX:UseTLAB 設置是否開啓 TLAB 空間。

默認情況下,TLAB 空間的內存非常小,僅佔有整個 Eden 空間的 1%,我們可以通過 -XX:TLABWasteTargetPercent 設置 TLAB 空間所佔用 Eden 空間的百分比大小。

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

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

隨着 JIT 編譯期的發展和逃逸分析技術的逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化,所有的對象都分配到堆上也漸漸變得不那麼“絕對”了。 ——《深入理解 Java 虛擬機》

逃逸分析

逃逸分析(Escape Analysis)是目前 Java 虛擬機中比較前沿的優化技術。這是一種可以有效減少 Java 程序中同步負載和內存堆分配壓力的跨函數全局數據流分析算法。通過逃逸分析,Java Hotspot 編譯器能夠分析出一個新的對象的引用的使用範圍從而決定是否要將這個對象分配到堆上。

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

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

例如:

public static StringBuffer craeteStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb;
}

StringBuffer sb是一個方法內部變量,上述代碼中直接將sb返回,這樣這個 StringBuffer 有可能被其他方法所改變,這樣它的作用域就不只是在方法內部,雖然它是一個局部變量,稱其逃逸到了方法外部。甚至還有可能被外部線程訪問到,譬如賦值給類變量或可以在其他線程中訪問的實例變量,稱爲線程逃逸。

上述代碼如果想要 StringBuffer sb不逃出方法,可以這樣寫:

public static String createStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb.toString();
}

不直接返回 StringBuffer,那麼 StringBuffer 將不會逃逸出方法。

參數設置:

  • 在 JDK 6u23 版本之後,HotSpot 中默認就已經開啓了逃逸分析
  • 如果使用較早版本,可以通過-XX"+DoEscapeAnalysis顯式開啓

開發中使用局部變量,就不要在方法外定義。

使用逃逸分析,編譯器可以對代碼做優化:

  • 棧上分配:將堆分配轉化爲棧分配。如果一個對象在子程序中被分配,要使指向該對象的指針永遠不會逃逸,對象可能是棧分配的候選,而不是堆分配
  • 同步省略:如果一個對象被發現只能從一個線程被訪問到,那麼對於這個對象的操作可以不考慮同步
  • 分離對象或標量替換:有的對象可能不需要作爲一個連續的內存結構存在也可以被訪問到,那麼對象的部分(或全部)可以不存儲在內存,而存儲在 CPU 寄存器

JIT 編譯器在編譯期間根據逃逸分析的結果,發現如果一個對象並沒有逃逸出方法的話,就可能被優化成棧上分配。分配完成後,繼續在調用棧內執行,最後線程結束,棧空間被回收,局部變量對象也被回收。這樣就無需進行垃圾回收了。

常見棧上分配的場景:成員變量賦值、方法返回值、實例引用傳遞

代碼優化之同步省略(消除)
  • 線程同步的代價是相當高的,同步的後果是降低併發性和性能
  • 在動態編譯同步塊的時候,JIT 編譯器可以藉助逃逸分析來判斷同步塊所使用的鎖對象是否能夠被一個線程訪問而沒有被髮布到其他線程。如果沒有,那麼 JIT 編譯器在編譯這個同步塊的時候就會取消對這個代碼的同步。這樣就能大大提高併發性和性能。這個取消同步的過程就叫做同步省略,也叫鎖消除
public void keep() {
  Object keeper = new Object();
  synchronized(keeper) {
    System.out.println(keeper);
  }
}

如上代碼,代碼中對 keeper 這個對象進行加鎖,但是 keeper 對象的生命週期只在 keep()方法中,並不會被其他線程所訪問到,所以在 JIT編譯階段就會被優化掉。優化成:

public void keep() {
  Object keeper = new Object();
  System.out.println(keeper);
}
代碼優化之標量替換

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

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

在 JIT 階段,通過逃逸分析確定該對象不會被外部訪問,並且對象可以被進一步分解時,JVM 不會創建該對象,而會將該對象成員變量分解若干個被這個方法使用的成員變量所代替。這些代替的成員變量在棧幀或寄存器上分配空間。這個過程就是標量替換

通過 -XX:+EliminateAllocations 可以開啓標量替換,-XX:+PrintEliminateAllocations 查看標量替換情況。

public static void main(String[] args) {
   alloc();
}

private static void alloc() {
   Point point = new Point(1,2);
   System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
    private int x;
    private int y;
}

以上代碼中,point 對象並沒有逃逸出 alloc() 方法,並且 point 對象是可以拆解成標量的。那麼,JIT 就不會直接創建 Point 對象,而是直接使用兩個標量 int x ,int y 來替代 Point 對象。

private static void alloc() {
   int x = 1;
   int y = 2;
   System.out.println("point.x="+x+"; point.y="+y);
}
代碼優化之棧上分配

我們通過 JVM 內存分配可以知道 JAVA 中的對象都是在堆上進行分配,當對象沒有被引用的時候,需要依靠 GC 進行回收內存,如果對象數量較多的時候,會給 GC 帶來較大壓力,也間接影響了應用的性能。爲了減少臨時對象在堆內分配的數量,JVM 通過逃逸分析確定該對象不會被外部訪問。那就通過標量替換將該對象分解在棧上分配內存,這樣該對象所佔用的內存空間就可以隨棧幀出棧而銷燬,就減輕了垃圾回收的壓力。

總結:

關於逃逸分析的論文在1999年就已經發表了,但直到JDK 1.6纔有實現,而且這項技術到如今也並不是十分成熟的。

其根本原因就是無法保證逃逸分析的性能消耗一定能高於他的消耗。雖然經過逃逸分析可以做標量替換、棧上分配、和鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程。

一個極端的例子,就是經過逃逸分析之後,發現沒有一個對象是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。

雖然這項技術並不十分成熟,但是他也是即時編譯器優化技術中一個十分重要的手段。


五、方法區

  • 方法區(Method Area)與 Java 堆一樣,是所有線程共享的內存區域。
  • 雖然 Java 虛擬機規範把方法區描述爲堆的一個邏輯部分,但是它卻有一個別名叫 Non-Heap(非堆),目的應該是與 Java 堆區分開。
  • 運行時常量池(Runtime Constant Pool)是方法區的一部分。Class 文件中除了有類的版本/字段/方法/接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將類在加載後進入方法區的運行時常量池中存放。運行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的是 String.intern()方法。受方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。
  • 方法區的大小和堆空間一樣,可以選擇固定大小也可選擇可擴展,方法區的大小決定了系統可以放多少個類,如果系統類太多,導致方法區溢出,虛擬機同樣會拋出內存溢出錯誤
  • JVM 關閉後方法區即被釋放

5.1 解惑

你是否也有看不同的參考資料,有的內存結構圖有方法區,有的又是永久代,元數據區,一臉懵逼的時候?

  • 方法區(method area)只是 JVM 規範中定義的一個概念,用於存儲類信息、常量池、靜態變量、JIT編譯後的代碼等數據,並沒有規定如何去實現它,不同的廠商有不同的實現。而永久代(PermGen)Hotspot 虛擬機特有的概念, Java8 的時候又被元空間取代了,永久代和元空間都可以理解爲方法區的落地實現。
  • 永久代物理是堆的一部分,和新生代,老年代地址是連續的(受垃圾回收器管理),而元空間存在於本地內存(我們常說的堆外內存,不受垃圾回收器管理),這樣就不受 JVM 限制了,也比較難發生OOM(都會有溢出異常)
  • Java7 中我們通過-XX:PermSize-xx:MaxPermSize 來設置永久代參數,Java8 之後,隨着永久代的取消,這些參數也就隨之失效了,改爲通過-XX:MetaspaceSize-XX:MaxMetaspaceSize 用來設置元空間參數
  • 存儲內容不同,元空間存儲類的元信息,靜態變量和常量池等併入堆中。相當於永久代的數據被分到了堆和元空間中
  • 如果方法區域中的內存不能用於滿足分配請求,則 Java 虛擬機拋出 OutOfMemoryError
  • JVM 規範說方法區在邏輯上是堆的一部分,但目前實際上是與 Java 堆分開的(Non-Heap)

所以對於方法區,Java8 之後的變化:

  • 移除了永久代(PermGen),替換爲元空間(Metaspace);
  • 永久代中的 class metadata 轉移到了 native memory(本地內存,而不是虛擬機);
  • 永久代中的 interned Strings 和 class static variables 轉移到了 Java heap;
  • 永久代參數 (PermSize MaxPermSize) -> 元空間參數(MetaspaceSize MaxMetaspaceSize)

5.2 設置方法區內存的大小

JDK8 及以後:

  • 元數據區大小可以使用參數 -XX:MetaspaceSize-XX:MaxMetaspaceSize 指定,替代上述原有的兩個參數
  • 默認值依賴於平臺。Windows 下,-XX:MetaspaceSize 是 21M,-XX:MaxMetaspacaSize 的值是 -1,即沒有限制
  • 與永久代不同,如果不指定大小,默認情況下,虛擬機會耗盡所有的可用系統內存。如果元數據發生溢出,虛擬機一樣會拋出異常 OutOfMemoryError:Metaspace
  • -XX:MetaspaceSize :設置初始的元空間大小。對於一個 64 位的服務器端 JVM 來說,其默認的 -XX:MetaspaceSize 的值爲20.75MB,這就是初始的高水位線,一旦觸及這個水位線,Full GC 將會被觸發並卸載沒用的類(即這些類對應的類加載器不再存活),然後這個高水位線將會重置,新的高水位線的值取決於 GC 後釋放了多少元空間。如果釋放的空間不足,那麼在不超過 MaxMetaspaceSize時,適當提高該值。如果釋放空間過多,則適當降低該值
  • 如果初始化的高水位線設置過低,上述高水位線調整情況會發生很多次,通過垃圾回收的日誌可觀察到 Full GC 多次調用。爲了避免頻繁 GC,建議將 -XX:MetaspaceSize 設置爲一個相對較高的值。

5.3 方法區內部結構

方法區用於存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等。

類型信息

對每個加載的類型(類 class、接口 interface、枚舉 enum、註解 annotation),JVM 必須在方法區中存儲以下類型信息

  • 這個類型的完整有效名稱(全名=包名.類名)
  • 這個類型直接父類的完整有效名(對於 interface或是 java.lang.Object,都沒有父類)
  • 這個類型的修飾符(public,abstract,final 的某個子集)
  • 這個類型直接接口的一個有序列表

域(Field)信息

  • JVM 必須在方法區中保存類型的所有域的相關信息以及域的聲明順序
  • 域的相關信息包括:域名稱、域類型、域修飾符(public、private、protected、static、final、volatile、transient 的某個子集)

方法(Method)信息

JVM 必須保存所有方法的

  • 方法名稱
  • 方法的返回類型
  • 方法參數的數量和類型
  • 方法的修飾符(public,private,protected,static,final,synchronized,native,abstract 的一個子集)
  • 方法的字符碼(bytecodes)、操作數棧、局部變量表及大小(abstract 和 native 方法除外)
  • 異常表(abstract 和 native 方法除外)
    • 每個異常處理的開始位置、結束位置、代碼處理在程序計數器中的偏移地址、被捕獲的異常類的常量池索引

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

5.4 運行時常量池

運行時常量池(Runtime Constant Pool)是方法區的一部分,理解運行時常量池的話,我們先來說說字節碼文件(Class 文件)中的常量池(常量池表)

常量池

一個有效的字節碼文件中除了包含類的版本信息、字段、方法以及接口等描述信息外,還包含一項信息那就是常量池表(Constant Pool Table),包含各種字面量和對類型、域和方法的符號引用。

爲什麼需要常量池?

一個 Java 源文件中的類、接口,編譯後產生一個字節碼文件。而 Java 中的字節碼需要數據支持,通常這種數據會很大以至於不能直接存到字節碼裏,換另一種方式,可以存到常量池,這個字節碼包含了指向常量池的引用。在動態鏈接的時候用到的就是運行時常量池。

如下,我們通過 jclasslib 查看一個只有 Main 方法的簡單類,字節碼中的 #2 指向的就是 Constant Pool

常量池可以看作是一張表,虛擬機指令根據這張常量表找到要執行的類名、方法名、參數類型、字面量等類型。

運行時常量池

  • 在加載類和結構到虛擬機後,就會創建對應的運行時常量池
  • 常量池表(Constant Pool Table)是 Class 文件的一部分,用於存儲編譯期生成的各種字面量和符號引用,這部分內容將在類加載後存放到方法區的運行時常量池中
  • JVM 爲每個已加載的類型(類或接口)都維護一個常量池。池中的數據項像數組項一樣,是通過索引訪問的
  • 運行時常量池中包含各種不同的常量,包括編譯器就已經明確的數值字面量,也包括到運行期解析後才能夠獲得的方法或字段引用。此時不再是常量池中的符號地址了,這裏換爲真實地址
    • 運行時常量池,相對於 Class 文件常量池的另一個重要特徵是:動態性,Java 語言並不要求常量一定只有編譯期間才能產生,運行期間也可以將新的常量放入池中,String 類的 intern() 方法就是這樣的
  • 當創建類或接口的運行時常量池時,如果構造運行時常量池所需的內存空間超過了方法區所能提供的最大值,則 JVM 會拋出 OutOfMemoryError 異常。

5.5 方法區在 JDK6、7、8中的演進細節

只有 HotSpot 纔有永久代的概念

jdk1.6及之前 有永久代,靜態變量存放在永久代上
jdk1.7 有永久代,但已經逐步“去永久代”,字符串常量池、靜態變量移除,保存在堆中
jdk1.8及之後 取消永久代,類型信息、字段、方法、常量保存在本地內存的元空間,但字符串常量池、靜態變量仍在堆中

移除永久代原因

http://openjdk.java.net/jeps/122

  • 爲永久代設置空間大小是很難確定的。

    在某些場景下,如果動態加載類過多,容易產生 Perm 區的 OOM。如果某個實際 Web 工程中,因爲功能點比較多,在運行過程中,要不斷動態加載很多類,經常出現 OOM。而元空間和永久代最大的區別在於,元空間不在虛擬機中,而是使用本地內存,所以默認情況下,元空間的大小僅受本地內存限制

  • 對永久代進行調優較困難

5.6 方法區的垃圾回收

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

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

  • 類和接口的全限定名
  • 字段的名稱和描述符
  • 方法的名稱和描述符

HotSpot 虛擬機對常量池的回收策略是很明確的,只要常量池中的常量沒有被任何地方引用,就可以被回收

判定一個類型是否屬於“不再被使用的類”,需要同時滿足三個條件:

  • 該類所有的實例都已經被回收,也就是 Java 堆中不存在該類及其任何派生子類的實例
  • 加載該類的類加載器已經被回收,這個條件除非是經過精心設計的可替換類加載器的場景,如 OSGi、JSP 的重加載等,否則通常很難達成
  • 該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法

Java 虛擬機被允許堆滿足上述三個條件的無用類進行回收,這裏說的僅僅是“被允許”,而並不是和對象一樣,不使用了就必然會回收。是否對類進行回收,HotSpot 虛擬機提供了 -Xnoclassgc 參數進行控制,還可以使用 -verbose:class 以及 -XX:+TraceClassLoading-XX:+TraceClassUnLoading 查看類加載和卸載信息。

在大量使用反射、動態代理、CGLib 等 ByteCode 框架、動態生成 JSP 以及 OSGi 這類頻繁自定義 ClassLoader 的場景都需要虛擬機具備類卸載的功能,以保證永久代不會溢出。

參考與感謝

算是一篇學習筆記,共勉,主要來源:

《深入理解 Java 虛擬機 第三版》

宋紅康老師的 JVM 教程

https://docs.oracle.com/javase/specs/index.html

https://www.cnblogs.com/wicfhwffg/p/9382677.html

https://www.cnblogs.com/hollischuang/p/12501950.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章