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

π大新發表於2020-07-20

寫這篇的主要原因呢,就是為了能在簡歷上寫個“熟悉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

相關文章