天天看點

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

作者:Civen

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

# 直擊面試

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

  • 說一下 JVM 運作時資料區吧,都有哪些區?分别是幹什麼的?
  • Java 8 的記憶體分代改進
  • 舉例棧溢出的情況?
  • 調整棧大小,就能儲存不出現溢出嗎?
  • 配置設定的棧記憶體越大越好嗎?
  • 垃圾回收是否會涉及到虛拟機棧?
  • 方法中定義的局部變量是否線程安全?
2 萬字長文包教包會 JVM 記憶體結構 保姆級學習筆記

# 運作時資料區

記憶體是非常重要的系統資源,是硬碟和 CPU 的中間倉庫及橋梁,承載着作業系統和應用程式的實時運作。JVM 記憶體布局規定了 Java 在運作過程中記憶體申請、配置設定、管理的政策,保證了 JVM 的高效穩定運作。不同的 JVM 對于記憶體的劃分方式和管理機制存在着部分差異。

下圖是 JVM 整體架構,中間部分就是 Java 虛拟機定義的各種運作時資料區域。

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

Java 虛拟機定義了若幹種程式運作期間會使用到的運作時資料區,其中有一些會随着虛拟機啟動而建立,随着虛拟機退出而銷毀。另外一些則是與線程一一對應的,這些與線程一一對應的資料區域會随着線程開始和結束而建立和銷毀。

  • 線程私有:程式計數器、棧、本地棧
  • 線程共享:堆、堆外記憶體(永久代或元空間、代碼緩存)
下面我們就來一一解毒下這些記憶體區域,先從最簡單的入手

# 一、程式計數器

程式計數寄存器(Program Counter Register),Register 的命名源于 CPU 的寄存器,寄存器存儲指令相關的線程資訊,CPU 隻有把資料裝載到寄存器才能夠運作。

這裡,并非是廣義上所指的實體寄存器,叫程式計數器(或PC計數器或指令計數器)會更加貼切,并且也不容易引起一些不必要的誤會。JVM 中的 PC 寄存器是對實體 PC 寄存器的一種抽象模拟。

程式計數器是一塊較小的記憶體空間,可以看作是目前線程所執行的位元組碼的行号訓示器。

# 1.1 作用

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

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

(分析:進入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 萬字長文包教包會 JVM 記憶體結構 保姆級學習筆記

# 2.4 棧幀的内部結構

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

  • 局部變量表(Local Variables)
  • 操作數棧(Operand Stack)(或稱為表達式棧)
  • 動态連結(Dynamic Linking):指向運作時常量池的方法引用
  • 方法傳回位址(Return Address):方法正常退出或異常退出的位址
  • 一些附加資訊
2 萬字長文包教包會 JVM 記憶體結構 保姆級學習筆記

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

# 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 萬字長文包教包會 JVM 記憶體結構 保姆級學習筆記
  • 在棧幀中,與性能調優關系最為密切的就是局部變量表。在方法執行時,虛拟機使用局部變量表完成方法的傳遞
  • 局部變量表中的變量也是重要的垃圾回收根節點,隻要被局部變量表中直接或間接引用的對象都不會被回收

# 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 檔案的常量池中。比如:描述一個方法調用了另外的其他方法時,就是通過常量池中指向方法的符号引用來表示的,那麼動态連結的作用就是為了将這些符号引用轉換為調用方法的直接引用
2 萬字長文包教包會 JVM 記憶體結構 保姆級學習筆記

# JVM 是如何執行方法調用的

方法調用不同于方法執行,方法調用階段的唯一任務就是确定被調用方法的版本(即調用哪一個方法),暫時還不涉及方法内部的具體運作過程。Class 檔案的編譯過程中不包括傳統編譯器中的連接配接步驟,一切方法調用在 Class檔案裡面存儲的都是符号引用,而不是方法在實際運作時記憶體布局中的入口位址(直接引用)。也就是需要在類加載階段,甚至到運作期才能确定目标方法的直接引用。

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

在 JVM 中,将符号引用轉換為調用方法的直接引用與方法的綁定機制有關

  • 靜态連結:當一個位元組碼檔案被裝載進 JVM 内部時,如果被調用的目标方法在編譯期可知,且運作期保持不變時。這種情況下将調用方法的符号引用轉換為直接引用的過程稱之為靜态連結
  • 動态連結:如果被調用的方法在編譯期無法被确定下來,也就是說,隻能在程式運作期将調用方法的符号引用轉換為直接引用,由于這種引用轉換過程具備動态性,是以也就被稱之為動态連結

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

  • 早期綁定:早期綁定就是指被調用的目标方法如果在編譯期可知,且運作期保持不變時,即可将這個方法與所屬的類型進行綁定,這樣一來,由于明确了被調用的目标方法究竟是哪一個,是以也就可以使用靜态連結的方式将符号引用轉換為直接引用。
  • 晚期綁定:如果被調用的方法在編譯器無法被确定下來,隻能夠在程式運作期根據實際的類型綁定相關的方法,這種綁定方式就被稱為晚期綁定。

# 虛方法和非虛方法

  • 如果方法在編譯器就确定了具體的調用版本,這個版本在運作時是不可變的。這樣的方法稱為非虛方法,比如靜态方法、私有方法、final 方法、執行個體構造器、父類方法都是非虛方法
  • 其他方法稱為虛方法

# 虛方法表

在面向對象程式設計中,會頻繁的使用到動态分派,如果每次動态分派都要重新在類的方法中繼資料中搜尋合适的目标有可能會影響到執行效率。為了提高性能,JVM 采用在類的方法區建立一個虛方法表(virtual method table),使用索引表來代替查找。非虛方法不會出現在表中。

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

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

# 2.4.4. 方法傳回位址(return address)

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

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

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

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

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

  1. 執行引擎遇到任意一個方法傳回的位元組碼指令,會有傳回值傳遞給上層的方法調用者,簡稱正常完成出口
  2. 一個方法的正常調用完成之後究竟需要使用哪一個傳回指令還需要根據方法傳回值的實際資料類型而定
  3. 在位元組碼指令中,傳回指令包含 ireturn(當傳回值是 boolean、byte、char、short 和 int 類型時使用)、lreturn、freturn、dreturn 以及 areturn,另外還有一個 return 指令供聲明為 void 的方法、執行個體初始化方法、類和接口的初始化方法使用。
  4. 在方法執行的過程中遇到了異常,并且這個異常沒有在方法内進行處理,也就是隻要在本方法的異常表中沒有搜尋到比對的異常處理器,就會導緻方法退出。簡稱異常完成出口
  5. 方法執行過程中抛出異常時的異常處理,存儲在一個異常處理表,友善在發生異常的時候找到處理異常的代碼。

本質上,方法的退出就是目前棧幀出棧的過程。此時,需要恢複上層方法的局部變量表、操作數棧、将傳回值壓入調用者棧幀的操作數棧、設定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.Thread 的 setPriority() 的方法是用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 之後直接使用實體記憶體
2 萬字長文包教包會 JVM 記憶體結構 保姆級學習筆記

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 區之間發生大量的記憶體拷貝

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

# 元空間

不管是 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 來配置
  3. 新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通過 -XX:SurvivorRatio 來配置
  4. 若在 JDK 7 中開啟了 -XX:+UseAdaptiveSizePolicy,JVM 會動态調整 JVM 堆中各個區域的大小以及進入老年代的年齡
  5. 此時 –XX:NewRatio 和 -XX:SurvivorRatio 将會失效,而 JDK 8 是預設開啟-XX:+UseAdaptiveSizePolicy
  6. 在 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 Survivor 和 To 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

Thread Local Allocation Buffer 的簡寫,基于 CAS 的獨享線程(Mutator Threads)可以優先将對象配置設定在 Eden 中的一塊記憶體,因為是 Java 線程獨享的記憶體區沒有鎖競争,是以配置設定速度更快,每個 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

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

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

# 運作時常量池

  • 在加載類和結構到虛拟機後,就會建立對應的運作時常量池
  • 常量池表(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

2 萬字長文包教包會 JVM 記憶體結構 保姆級學習筆記
  • 為永久代設定空間大小是很難确定的。
  • 在某些場景下,如果動态加載類過多,容易産生 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

繼續閱讀