2萬字長文包教包會 JVM 內(nèi)存結構
本文轉載自微信公眾號「 JavaKeeper」,作者海星。轉載本文請聯(lián)系 JavaKeeper公眾號。
直擊面試
反正我是帶著這些問題往下讀的
- 說一下 JVM 運行時數(shù)據(jù)區(qū)吧,都有哪些區(qū)?分別是干什么的?
- Java 8 的內(nèi)存分代改進
- 舉例棧溢出的情況?
- 調整棧大小,就能保存不出現(xiàn)溢出嗎?
- 分配的棧內(nèi)存越大越好嗎?
- 垃圾回收是否會涉及到虛擬機棧?
- 方法中定義的局部變量是否線程安全?
運行時數(shù)據(jù)區(qū)
內(nèi)存是非常重要的系統(tǒng)資源,是硬盤和 CPU 的中間倉庫及橋梁,承載著操作系統(tǒng)和應用程序的實時運行。JVM 內(nèi)存布局規(guī)定了 Java 在運行過程中內(nèi)存申請、分配、管理的策略,保證了 JVM 的高效穩(wěn)定運行。不同的 JVM 對于內(nèi)存的劃分方式和管理機制存在著部分差異。
下圖是 JVM 整體架構,中間部分就是 Java 虛擬機定義的各種運行時數(shù)據(jù)區(qū)域。
jvm-framework
Java 虛擬機定義了若干種程序運行期間會使用到的運行時數(shù)據(jù)區(qū),其中有一些會隨著虛擬機啟動而創(chuàng)建,隨著虛擬機退出而銷毀。另外一些則是與線程一一對應的,這些與線程一一對應的數(shù)據(jù)區(qū)域會隨著線程開始和結束而創(chuàng)建和銷毀。
線程私有:程序計數(shù)器、棧、本地棧
線程共享:堆、堆外內(nèi)存(永久代或元空間、代碼緩存)
下面我們就來一一解毒下這些內(nèi)存區(qū)域,先從最簡單的入手
一、程序計數(shù)器
程序計數(shù)寄存器(Program Counter Register),Register 的命名源于 CPU 的寄存器,寄存器存儲指令相關的線程信息,CPU 只有把數(shù)據(jù)裝載到寄存器才能夠運行。
這里,并非是廣義上所指的物理寄存器,叫程序計數(shù)器(或PC計數(shù)器或指令計數(shù)器)會更加貼切,并且也不容易引起一些不必要的誤會。JVM 中的 PC 寄存器是對物理 PC 寄存器的一種抽象模擬。
程序計數(shù)器是一塊較小的內(nèi)存空間,可以看作是當前線程所執(zhí)行的字節(jié)碼的行號指示器。
1.1 作用
PC 寄存器用來存儲指向下一條指令的地址,即將要執(zhí)行的指令代碼。由執(zhí)行引擎讀取下一條指令。
jvm-pc-counter
(分析:進入class文件所在目錄,執(zhí)行javap -v xx.class反解析(或者通過IDEA插件Jclasslib直接查看,上圖),可以看到當前類對應的Code區(qū)(匯編指令)、本地變量表、異常表和代碼行偏移量映射表、常量池等信息。)
1.2 概述
- 它是一塊很小的內(nèi)存空間,幾乎可以忽略不計。也是運行速度最快的存儲區(qū)域
- 在 JVM 規(guī)范中,每個線程都有它自己的程序計數(shù)器,是線程私有的,生命周期與線程的生命周期一致
- 任何時間一個線程都只有一個方法在執(zhí)行,也就是所謂的當前方法。如果當前線程正在執(zhí)行的是 Java 方法,程序計數(shù)器記錄的是 JVM 字節(jié)碼指令地址,如果是執(zhí)行 natice 方法,則是未指定值(undefined)
- 它是程序控制流的指示器,分支、循環(huán)、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數(shù)器來完成
- 字節(jié)碼解釋器工作時就是通過改變這個計數(shù)器的值來選取下一條需要執(zhí)行的字節(jié)碼指令
- 它是唯一一個在 JVM 規(guī)范中沒有規(guī)定任何 OutOfMemoryError 情況的區(qū)域
使用PC寄存器存儲字節(jié)碼指令地址有什么用呢?為什么使用PC寄存器記錄當前線程的執(zhí)行地址呢?
因為CPU需要不停的切換各個線程,這時候切換回來以后,就得知道接著從哪開始繼續(xù)執(zhí)行。JVM的字節(jié)碼解釋器就需要通過改變PC寄存器的值來明確下一條應該執(zhí)行什么樣的字節(jié)碼指令。
PC寄存器為什么會被設定為線程私有的?
多線程在一個特定的時間段內(nèi)只會執(zhí)行其中某一個線程方法,CPU會不停的做任務切換,這樣必然會導致經(jīng)常中斷或恢復。為了能夠準確的記錄各個線程正在執(zhí)行的當前字節(jié)碼指令地址,所以為每個線程都分配了一個PC寄存器,每個線程都獨立計算,不會互相影響。
二、虛擬機棧
2.1 概述
Java 虛擬機棧(Java Virtual Machine Stacks),早期也叫 Java 棧。每個線程在創(chuàng)建的時候都會創(chuàng)建一個虛擬機棧,其內(nèi)部保存一個個的棧幀(Stack Frame),對應著一次次 Java 方法調用,是線程私有的,生命周期和線程一致。
作用:主管 Java 程序的運行,它保存方法的局部變量、部分結果,并參與方法的調用和返回。
特點:
- 棧是一種快速有效的分配存儲方式,訪問速度僅次于程序計數(shù)器
- JVM 直接對虛擬機棧的操作只有兩個:每個方法執(zhí)行,伴隨著入棧(進棧/壓棧),方法執(zhí)行結束出棧
- 棧不存在垃圾回收問題
棧中可能出現(xiàn)的異常:
Java 虛擬機規(guī)范允許 Java虛擬機棧的大小是動態(tài)的或者是固定不變的
- 如果采用固定大小的 Java 虛擬機棧,那每個線程的 Java 虛擬機棧容量可以在線程創(chuàng)建的時候獨立選定。如果線程請求分配的棧容量超過 Java 虛擬機棧允許的最大容量,Java 虛擬機將會拋出一個 StackOverflowError 異常
- 如果 Java 虛擬機??梢詣討B(tài)擴展,并且在嘗試擴展的時候無法申請到足夠的內(nèi)存,或者在創(chuàng)建新的線程時沒有足夠的內(nèi)存去創(chuàng)建對應的虛擬機棧,那 Java 虛擬機將會拋出一個OutOfMemoryError異常
可以通過參數(shù)-Xss來設置線程的最大??臻g,棧的大小直接決定了函數(shù)調用的最大可達深度。
官方提供的參考工具,可查一些參數(shù)和操作:https://docs.oracle.com/javase/8/docs/technotes/tools/windows/java.html#BGBCIEFC
2.2 棧的存儲單位
棧中存儲什么?
- 每個線程都有自己的棧,棧中的數(shù)據(jù)都是以棧幀(Stack Frame)的格式存在
- 在這個線程上正在執(zhí)行的每個方法都各自有對應的一個棧幀
- 棧幀是一個內(nèi)存區(qū)塊,是一個數(shù)據(jù)集,維系著方法執(zhí)行過程中的各種數(shù)據(jù)信息
2.3 棧運行原理
- JVM 直接對 Java 棧的操作只有兩個,對棧幀的壓棧和出棧,遵循“先進后出/后進先出”原則
- 在一條活動線程中,一個時間點上,只會有一個活動的棧幀。即只有當前正在執(zhí)行的方法的棧幀(棧頂棧幀)是有效的,這個棧幀被稱為當前棧幀(Current Frame),與當前棧幀對應的方法就是當前方法(Current Method),定義這個方法的類就是當前類(Current Class)
- 執(zhí)行引擎運行的所有字節(jié)碼指令只針對當前棧幀進行操作
- 如果在該方法中調用了其他方法,對應的新的棧幀會被創(chuàng)建出來,放在棧的頂端,稱為新的當前棧幀
- 不同線程中所包含的棧幀是不允許存在相互引用的,即不可能在一個棧幀中引用另外一個線程的棧幀
- 如果當前方法調用了其他方法,方法返回之際,當前棧幀會傳回此方法的執(zhí)行結果給前一個棧幀,接著,虛擬機會丟棄當前棧幀,使得前一個棧幀重新成為當前棧幀
- Java 方法有兩種返回函數(shù)的方式,一種是正常的函數(shù)返回,使用 return 指令,另一種是拋出異常,不管用哪種方式,都會導致棧幀被彈出
IDEA 在 debug 時候,可以在 debug 窗口看到 Frames 中各種方法的壓棧和出棧情況
2.4 棧幀的內(nèi)部結構
每個**棧幀(Stack Frame)**中存儲著:
- 局部變量表(Local Variables)
- 操作數(shù)棧(Operand Stack)(或稱為表達式棧)
- 動態(tài)鏈接(Dynamic Linking):指向運行時常量池的方法引用
- 方法返回地址(Return Address):方法正常退出或異常退出的地址
- 一些附加信息
jvm-stack-frame
繼續(xù)深拋棧幀中的五部分~~
2.4.1. 局部變量表
- 局部變量表也被稱為局部變量數(shù)組或者本地變量表
- 是一組變量值存儲空間,主要用于存儲方法參數(shù)和定義在方法體內(nèi)的局部變量,包括編譯器可知的各種 Java 虛擬機基本數(shù)據(jù)類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它并不等同于對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此相關的位置)和returnAddress 類型(指向了一條字節(jié)碼指令的地址,已被異常表取代)
- 由于局部變量表是建立在線程的棧上,是線程的私有數(shù)據(jù),因此不存在數(shù)據(jù)安全問題
- 局部變量表所需要的容量大小是編譯期確定下來的,并保存在方法的 Code 屬性的maximum local variables 數(shù)據(jù)項中。在方法運行期間是不會改變局部變量表的大小的
- 方法嵌套調用的次數(shù)由棧的大小決定。一般來說,棧越大,方法嵌套調用次數(shù)越多。對一個函數(shù)而言,它的參數(shù)和局部變量越多,使得局部變量表膨脹,它的棧幀就越大,以滿足方法調用所需傳遞的信息增大的需求。進而函數(shù)調用就會占用更多的??臻g,導致其嵌套調用次數(shù)就會減少。
- 局部變量表中的變量只在當前方法調用中有效。在方法執(zhí)行時,虛擬機通過使用局部變量表完成參數(shù)值到參數(shù)變量列表的傳遞過程。當方法調用結束后,隨著方法棧幀的銷毀,局部變量表也會隨之銷毀。
- 參數(shù)值的存放總是在局部變量數(shù)組的 index0 開始,到數(shù)組長度 -1 的索引結束
槽 Slot
- 局部變量表最基本的存儲單元是Slot(變量槽)
- 在局部變量表中,32位以內(nèi)的類型只占用一個Slot(包括returnAddress類型),64位的類型(long和double)占用兩個連續(xù)的 Slot
- byte、short、char 在存儲前被轉換為int,boolean也被轉換為int,0 表示 false,非 0 表示 true
- long 和 double 則占據(jù)兩個 Slot
- JVM 會為局部變量表中的每一個 Slot 都分配一個訪問索引,通過這個索引即可成功訪問到局部變量表中指定的局部變量值,索引值的范圍從 0 開始到局部變量表最大的 Slot 數(shù)量
- 當一個實例方法被調用的時候,它的方法參數(shù)和方法體內(nèi)部定義的局部變量將會按照順序被復制到局部變量表中的每一個 Slot 上
- 如果需要訪問局部變量表中一個64bit的局部變量值時,只需要使用前一個索引即可。(比如:訪問 long 或double 類型變量,不允許采用任何方式單獨訪問其中的某一個 Slot)
- 如果當前幀是由構造方法或實例方法創(chuàng)建的,那么該對象引用 this 將會存放在 index 為 0 的 Slot 處,其余的參數(shù)按照參數(shù)表順序繼續(xù)排列(這里就引出一個問題:靜態(tài)方法中為什么不可以引用 this,就是因為this 變量不存在于當前方法的局部變量表中)
- 棧幀中的局部變量表中的槽位是可以重用的,如果一個局部變量過了其作用域,那么在其作用域之后申明的新的局部變量就很有可能會復用過期局部變量的槽位,從而達到節(jié)省資源的目的。(下圖中,this、a、b、c 理論上應該有 4 個變量,c 復用了 b 的槽)
- 在棧幀中,與性能調優(yōu)關系最為密切的就是局部變量表。在方法執(zhí)行時,虛擬機使用局部變量表完成方法的傳遞
- 局部變量表中的變量也是重要的垃圾回收根節(jié)點,只要被局部變量表中直接或間接引用的對象都不會被回收
2.4.2. 操作數(shù)棧
- 每個獨立的棧幀中除了包含局部變量表之外,還包含一個后進先出(Last-In-First-Out)的操作數(shù)棧,也可以稱為表達式棧(Expression Stack)
- 操作數(shù)棧,在方法執(zhí)行過程中,根據(jù)字節(jié)碼指令,往操作數(shù)棧中寫入數(shù)據(jù)或提取數(shù)據(jù),即入棧(push)、出棧(pop)
- 某些字節(jié)碼指令將值壓入操作數(shù)棧,其余的字節(jié)碼指令將操作數(shù)取出棧。使用它們后再把結果壓入棧。比如,執(zhí)行復制、交換、求和等操作
概述
- 操作數(shù)棧,主要用于保存計算過程的中間結果,同時作為計算過程中變量臨時的存儲空間
- 操作數(shù)棧就是 JVM 執(zhí)行引擎的一個工作區(qū),當一個方法剛開始執(zhí)行的時候,一個新的棧幀也會隨之被創(chuàng)建出來,此時這個方法的操作數(shù)棧是空的
- 每一個操作數(shù)棧都會擁有一個明確的棧深度用于存儲數(shù)值,其所需的最大深度在編譯期就定義好了,保存在方法的 Code 屬性的 max_stack 數(shù)據(jù)項中
- 棧中的任何一個元素都可以是任意的 Java 數(shù)據(jù)類型
- 32bit 的類型占用一個棧單位深度
- 64bit 的類型占用兩個棧單位深度
- 操作數(shù)棧并非采用訪問索引的方式來進行數(shù)據(jù)訪問的,而是只能通過標準的入棧和出棧操作來完成一次數(shù)據(jù)訪問
- 如果被調用的方法帶有返回值的話,其返回值將會被壓入當前棧幀的操作數(shù)棧中,并更新PC寄存器中下一條需要執(zhí)行的字節(jié)碼指令
- 操作數(shù)棧中元素的數(shù)據(jù)類型必須與字節(jié)碼指令的序列嚴格匹配,這由編譯器在編譯期間進行驗證,同時在類加載過程中的類檢驗階段的數(shù)據(jù)流分析階段要再次驗證
- 另外,我們說Java虛擬機的解釋引擎是基于棧的執(zhí)行引擎,其中的棧指的就是操作數(shù)棧
棧頂緩存(Top-of-stack-Cashing)
HotSpot 的執(zhí)行引擎采用的并非是基于寄存器的架構,但這并不代表 HotSpot VM 的實現(xiàn)并沒有間接利用到寄存器資源。寄存器是物理 CPU 中的組成部分之一,它同時也是 CPU 中非常重要的高速存儲資源。一般來說,寄存器的讀/寫速度非常迅速,甚至可以比內(nèi)存的讀/寫速度快上幾十倍不止,不過寄存器資源卻非常有限,不同平臺下的CPU 寄存器數(shù)量是不同和不規(guī)律的。寄存器主要用于緩存本地機器指令、數(shù)值和下一條需要被執(zhí)行的指令地址等數(shù)據(jù)。
基于棧式架構的虛擬機所使用的零地址指令更加緊湊,但完成一項操作的時候必然需要使用更多的入棧和出棧指令,這同時也就意味著將需要更多的指令分派(instruction dispatch)次數(shù)和內(nèi)存讀/寫次數(shù)。由于操作數(shù)是存儲在內(nèi)存中的,因此頻繁的執(zhí)行內(nèi)存讀/寫操作必然會影響執(zhí)行速度。為了解決這個問題,HotSpot JVM設計者們提出了棧頂緩存技術,將棧頂元素全部緩存在物理 CPU 的寄存器中,以此降低對內(nèi)存的讀/寫次數(shù),提升執(zhí)行引擎的執(zhí)行效率
2.4.3. 動態(tài)鏈接(指向運行時常量池的方法引用)
- 每一個棧幀內(nèi)部都包含一個指向運行時常量池中該棧幀所屬方法的引用。包含這個引用的目的就是為了支持當前方法的代碼能夠實現(xiàn)動態(tài)鏈接(Dynamic Linking)。
- 在 Java 源文件被編譯到字節(jié)碼文件中時,所有的變量和方法引用都作為符號引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一個方法調用了另外的其他方法時,就是通過常量池中指向方法的符號引用來表示的,那么動態(tài)鏈接的作用就是為了將這些符號引用轉換為調用方法的直接引用
jvm-dynamic-linking
JVM 是如何執(zhí)行方法調用的
方法調用不同于方法執(zhí)行,方法調用階段的唯一任務就是確定被調用方法的版本(即調用哪一個方法),暫時還不涉及方法內(nèi)部的具體運行過程。Class 文件的編譯過程中不包括傳統(tǒng)編譯器中的連接步驟,一切方法調用在 Class文件里面存儲的都是符號引用,而不是方法在實際運行時內(nèi)存布局中的入口地址(直接引用)。也就是需要在類加載階段,甚至到運行期才能確定目標方法的直接引用。
【這一塊內(nèi)容,除了方法調用,還包括解析、分派(靜態(tài)分派、動態(tài)分派、單分派與多分派),這里先不介紹,后續(xù)再挖】
在 JVM 中,將符號引用轉換為調用方法的直接引用與方法的綁定機制有關
- 靜態(tài)鏈接:當一個字節(jié)碼文件被裝載進 JVM 內(nèi)部時,如果被調用的目標方法在編譯期可知,且運行期保持不變時。這種情況下將調用方法的符號引用轉換為直接引用的過程稱之為靜態(tài)鏈接
- 動態(tài)鏈接:如果被調用的方法在編譯期無法被確定下來,也就是說,只能在程序運行期將調用方法的符號引用轉換為直接引用,由于這種引用轉換過程具備動態(tài)性,因此也就被稱之為動態(tài)鏈接
對應的方法的綁定機制為:早期綁定(Early Binding)和晚期綁定(Late Binding)。綁定是一個字段、方法或者類在符號引用被替換為直接引用的過程,這僅僅發(fā)生一次。
早期綁定:早期綁定就是指被調用的目標方法如果在編譯期可知,且運行期保持不變時,即可將這個方法與所屬的類型進行綁定,這樣一來,由于明確了被調用的目標方法究竟是哪一個,因此也就可以使用靜態(tài)鏈接的方式將符號引用轉換為直接引用。
晚期綁定:如果被調用的方法在編譯器無法被確定下來,只能夠在程序運行期根據(jù)實際的類型綁定相關的方法,這種綁定方式就被稱為晚期綁定。
虛方法和非虛方法
- 如果方法在編譯器就確定了具體的調用版本,這個版本在運行時是不可變的。這樣的方法稱為非虛方法,比如靜態(tài)方法、私有方法、final方法、實例構造器、父類方法都是非虛方法
- 其他方法稱為虛方法
虛方法表
在面向對象編程中,會頻繁的使用到動態(tài)分派,如果每次動態(tài)分派都要重新在類的方法元數(shù)據(jù)中搜索合適的目標有可能會影響到執(zhí)行效率。為了提高性能,JVM 采用在類的方法區(qū)建立一個虛方法表(virtual method table),使用索引表來代替查找。非虛方法不會出現(xiàn)在表中。
每個類中都有一個虛方法表,表中存放著各個方法的實際入口。
虛方法表會在類加載的連接階段被創(chuàng)建并開始初始化,類的變量初始值準備完成之后,JVM 會把該類的方法表也初始化完畢。
2.4.4. 方法返回地址(return address)
用來存放調用該方法的 PC 寄存器的值。
一個方法的結束,有兩種方式
- 正常執(zhí)行完成
- 出現(xiàn)未處理的異常,非正常退出
無論通過哪種方式退出,在方法退出后都返回到該方法被調用的位置。方法正常退出時,調用者的 PC 計數(shù)器的值作為返回地址,即調用該方法的指令的下一條指令的地址。而通過異常退出的,返回地址是要通過異常表來確定的,棧幀中一般不會保存這部分信息。
當一個方法開始執(zhí)行后,只有兩種方式可以退出這個方法:
1.執(zhí)行引擎遇到任意一個方法返回的字節(jié)碼指令,會有返回值傳遞給上層的方法調用者,簡稱正常完成出口
一個方法的正常調用完成之后究竟需要使用哪一個返回指令還需要根據(jù)方法返回值的實際數(shù)據(jù)類型而定
在字節(jié)碼指令中,返回指令包含 ireturn(當返回值是boolean、byte、char、short和int類型時使用)、lreturn、freturn、dreturn以及areturn,另外還有一個 return 指令供聲明為 void 的方法、實例初始化方法、類和接口的初始化方法使用。
2.在方法執(zhí)行的過程中遇到了異常,并且這個異常沒有在方法內(nèi)進行處理,也就是只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導致方法退出。簡稱異常完成出口
方法執(zhí)行過程中拋出異常時的異常處理,存儲在一個異常處理表,方便在發(fā)生異常的時候找到處理異常的代碼。
本質上,方法的退出就是當前棧幀出棧的過程。此時,需要恢復上層方法的局部變量表、操作數(shù)棧、將返回值壓入調用者棧幀的操作數(shù)棧、設置PC寄存器值等,讓調用者方法繼續(xù)執(zhí)行下去。
正常完成出口和異常完成出口的區(qū)別在于:通過異常完成出口退出的不會給他的上層調用者產(chǎn)生任何的返回值
2.4.5. 附加信息
棧幀中還允許攜帶與 Java 虛擬機實現(xiàn)相關的一些附加信息。例如,對程序調試提供支持的信息,但這些信息取決于具體的虛擬機實現(xiàn)。
三、本地方法棧
3.1 本地方法接口
簡單的講,一個 Native Method 就是一個 Java 調用非 Java 代碼的接口。我們知道的 Unsafe 類就有很多本地方法。
為什么要使用本地方法(Native Method)?
Java 使用起來非常方便,然而有些層次的任務用 Java 實現(xiàn)起來也不容易,或者我們對程序的效率很在意時,問題就來了
- 與 Java 環(huán)境外交互:有時 Java 應用需要與 Java 外面的環(huán)境交互,這就是本地方法存在的原因。
- 與操作系統(tǒng)交互:JVM 支持 Java 語言本身和運行時庫,但是有時仍需要依賴一些底層系統(tǒng)的支持。通過本地方法,我們可以實現(xiàn)用 Java 與實現(xiàn)了 jre 的底層系統(tǒng)交互, JVM 的一些部分就是 C 語言寫的。
- Sun's Java:Sun的解釋器就是C實現(xiàn)的,這使得它能像一些普通的C一樣與外部交互。jre大部分都是用 Java 實現(xiàn)的,它也通過一些本地方法與外界交互。比如,類java.lang.Thread 的 setPriority() 的方法是用Java 實現(xiàn)的,但它實現(xiàn)調用的是該類的本地方法 setPrioruty(),該方法是C實現(xiàn)的,并被植入 JVM 內(nèi)部。
3.2 本地方法棧(Native Method Stack)
- Java 虛擬機棧用于管理 Java 方法的調用,而本地方法棧用于管理本地方法的調用
- 本地方法棧也是線程私有的
- 允許線程固定或者可動態(tài)擴展的內(nèi)存大小
- 如果線程請求分配的棧容量超過本地方法棧允許的最大容量,Java 虛擬機將會拋出一個 StackOverflowError 異常
- 如果本地方法棧可以動態(tài)擴展,并且在嘗試擴展的時候無法申請到足夠的內(nèi)存,或者在創(chuàng)建新的線程時沒有足夠的內(nèi)存去創(chuàng)建對應的本地方法棧,那么 Java虛擬機將會拋出一個OutofMemoryError異常
- 本地方法是使用C語言實現(xiàn)的
- 它的具體做法是 Mative Method Stack 中登記native方法,在 Execution Engine 執(zhí)行時加載本地方法庫當某個線程調用一個本地方法時,它就進入了一個全新的并且不再受虛擬機限制的世界。它和虛擬機擁有同樣的權限。
- 本地方法可以通過本地方法接口來訪問虛擬機內(nèi)部的運行時數(shù)據(jù)區(qū),它甚至可以直接使用本地處理器中的寄存器,直接從本地內(nèi)存的堆中分配任意數(shù)量的內(nèi)存
- 并不是所有 JVM 都支持本地方法。因為 Java 虛擬機規(guī)范并沒有明確要求本地方法棧的使用語言、具體實現(xiàn)方式、數(shù)據(jù)結構等。如果 JVM 產(chǎn)品不打算支持 native 方法,也可以無需實現(xiàn)本地方法棧
在 Hotspot JVM 中,直接將本地方棧和虛擬機棧合二為一
棧是運行時的單位,而堆是存儲的單位。
棧解決程序的運行問題,即程序如何執(zhí)行,或者說如何處理數(shù)據(jù)。堆解決的是數(shù)據(jù)存儲的問題,即數(shù)據(jù)怎么放、放在哪。
四、堆內(nèi)存
4.1 內(nèi)存劃分
對于大多數(shù)應用,Java 堆是 Java 虛擬機管理的內(nèi)存中最大的一塊,被所有線程共享。此內(nèi)存區(qū)域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數(shù)據(jù)都在這里分配內(nèi)存。
為了進行高效的垃圾回收,虛擬機把堆內(nèi)存邏輯上劃分成三塊區(qū)域(分代的唯一理由就是優(yōu)化 GC 性能):
- 新生帶(年輕代):新對象和沒達到一定年齡的對象都在新生代
- 老年代(養(yǎng)老區(qū)):被長時間使用的對象,老年代的內(nèi)存空間應該要比年輕代更大
- 元空間(JDK1.8之前叫永久代):像一些方法中的操作臨時對象等,JDK1.8之前是占用JVM內(nèi)存,JDK1.8之后直接使用物理內(nèi)存
JDK7
Java 虛擬機規(guī)范規(guī)定,Java 堆可以是處于物理上不連續(xù)的內(nèi)存空間中,只要邏輯上是連續(xù)的即可,像磁盤空間一樣。實現(xiàn)時,既可以是固定大小,也可以是可擴展的,主流虛擬機都是可擴展的(通過 -Xmx 和 -Xms 控制),如果堆中沒有完成實例分配,并且堆無法再擴展時,就會拋出 OutOfMemoryError 異常。
年輕代 (Young Generation)
年輕代是所有新對象創(chuàng)建的地方。當填充年輕代時,執(zhí)行垃圾收集。這種垃圾收集稱為Minor GC。年輕一代被分為三個部分——伊甸園(Eden Memory)和兩個幸存區(qū)(Survivor Memory,被稱為from/to或s0/s1),默認比例是8:1:1
- 大多數(shù)新創(chuàng)建的對象都位于 Eden 內(nèi)存空間中
- 當 Eden 空間被對象填充時,執(zhí)行Minor GC,并將所有幸存者對象移動到一個幸存者空間中
- Minor GC 檢查幸存者對象,并將它們移動到另一個幸存者空間。所以每次,一個幸存者空間總是空的
- 經(jīng)過多次 GC 循環(huán)后存活下來的對象被移動到老年代。通常,這是通過設置年輕一代對象的年齡閾值來實現(xiàn)的,然后他們才有資格提升到老一代
老年代(Old Generation)
舊的一代內(nèi)存包含那些經(jīng)過許多輪小型 GC 后仍然存活的對象。通常,垃圾收集是在老年代內(nèi)存滿時執(zhí)行的。老年代垃圾收集稱為主GC,通常需要更長的時間。
大對象直接進入老年代(大對象是指需要大量連續(xù)內(nèi)存空間的對象)。這樣做的目的是避免在Eden區(qū)和兩個Survivor區(qū)之間發(fā)生大量的內(nèi)存拷貝
元空間
不管是 JDK8 之前的永久代,還是 JDK8 及以后的元空間,都可以看作是 Java 虛擬機規(guī)范中方法區(qū)的實現(xiàn)。
雖然 Java 虛擬機規(guī)范把方法區(qū)描述為堆的一個邏輯部分,但是它卻有一個別名叫 Non-Heap(非堆),目的應該是與 Java 堆區(qū)分開。
所以元空間放在后邊的方法區(qū)再說。
4.2 設置堆內(nèi)存大小和 OOM
Java 堆用于存儲 Java 對象實例,那么堆的大小在 JVM 啟動的時候就確定了,我們可以通過 -Xmx 和 -Xms 來設定
- -Xmx 用來表示堆的起始內(nèi)存,等價于 -XX:InitialHeapSize
- -Xms 用來表示堆的最大內(nèi)存,等價于 -XX:MaxHeapSize
如果堆的內(nèi)存大小超過 -Xms 設定的最大內(nèi)存, 就會拋出 OutOfMemoryError 異常。
我們通常會將 -Xmx 和 -Xms 兩個參數(shù)配置為相同的值,其目的是為了能夠在垃圾回收機制清理完堆區(qū)后不再需要重新分隔計算堆的大小,從而提高性能
- 默認情況下,初始堆內(nèi)存大小為:電腦內(nèi)存大小/64
- 默認情況下,最大堆內(nèi)存大小為:電腦內(nèi)存大小/4
可以通過代碼獲取到我們的設置值,當然也可以模擬 OOM:
- public static void main(String[] args) {
- //返回 JVM 堆大小
- long initalMemory = Runtime.getRuntime().totalMemory() / 1024 /1024;
- //返回 JVM 堆的最大內(nèi)存
- long maxMemory = Runtime.getRuntime().maxMemory() / 1024 /1024;
- System.out.println("-Xms : "+initalMemory + "M");
- System.out.println("-Xmx : "+maxMemory + "M");
- System.out.println("系統(tǒng)內(nèi)存大小:" + initalMemory * 64 / 1024 + "G");
- System.out.println("系統(tǒng)內(nèi)存大?。?quot; + maxMemory * 4 / 1024 + "G");
- }
查看 JVM 堆內(nèi)存分配
1.在默認不配置 JVM 堆內(nèi)存大小的情況下,JVM 根據(jù)默認值來配置當前內(nèi)存大小
2.默認情況下新生代和老年代的比例是 1:2,可以通過 –XX:NewRatio 來配置
- 新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通過-XX:SurvivorRatio來配置
3.若在JDK 7中開啟了 -XX:+UseAdaptiveSizePolicy,JVM 會動態(tài)調整 JVM 堆中各個區(qū)域的大小以及進入老年代的年齡
此時 –XX:NewRatio 和 -XX:SurvivorRatio 將會失效,而 JDK 8 是默認開啟-XX:+UseAdaptiveSizePolicy
在 JDK 8中,不要隨意關閉-XX:+UseAdaptiveSizePolicy,除非對堆內(nèi)存的劃分有明確的規(guī)劃
每次 GC 后都會重新計算 Eden、From Survivor、To Survivor 的大小
計算依據(jù)是GC過程中統(tǒng)計的GC時間、吞吐量、內(nèi)存占用量
- 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 內(nèi)存模型的堆中,堆被劃分為新生代和老年代
- 新生代又被進一步劃分為Eden區(qū)和Survivor區(qū),Survivor區(qū)由From Survivor和To Survivor組成
2.當創(chuàng)建一個對象時,對象會被優(yōu)先分配到新生代的Eden區(qū)
- 此時 JVM 會給對象定義一個對象年輕計數(shù)器(-XX:MaxTenuringThreshold)
3.當 Eden 空間不足時,JVM 將執(zhí)行新生代的垃圾回收(Minor GC)
- JVM 會把存活的對象轉移到 Survivor 中,并且對象年齡 +1
- 對象在 Survivor 中同樣也會經(jīng)歷 Minor GC,每經(jīng)歷一次 Minor GC,對象年齡都會+1
4.如果分配的對象超過了-XX:PetenureSizeThreshold,對象會直接被分配到老年代
4.4 對象的分配過程
為對象分配內(nèi)存是一件非常嚴謹和復雜的任務,JVM 的設計者們不僅需要考慮內(nèi)存如何分配、在哪里分配等問題,并且由于內(nèi)存分配算法和內(nèi)存回收算法密切相關,所以還需要考慮 GC 執(zhí)行完內(nèi)存回收后是否會在內(nèi)存空間中產(chǎn)生內(nèi)存碎片。
- new 的對象先放在伊甸園區(qū),此區(qū)有大小限制
- 當伊甸園的空間填滿時,程序又需要創(chuàng)建對象,JVM 的垃圾回收器將對伊甸園區(qū)進行垃圾回收(Minor GC),將伊甸園區(qū)中的不再被其他對象所引用的對象進行銷毀。再加載新的對象放到伊甸園區(qū)
- 然后將伊甸園中的剩余對象移動到幸存者 0 區(qū)
- 如果再次觸發(fā)垃圾回收,此時上次幸存下來的放到幸存者 0 區(qū),如果沒有回收,就會放到幸存者 1 區(qū)
- 如果再次經(jīng)歷垃圾回收,此時會重新放回幸存者 0 區(qū),接著再去幸存者 1 區(qū)
- 什么時候才會去養(yǎng)老區(qū)呢? 默認是 15 次回收標記
- 在養(yǎng)老區(qū),相對悠閑。當養(yǎng)老區(qū)內(nèi)存不足時,再次觸發(fā) Major GC,進行養(yǎng)老區(qū)的內(nèi)存清理
- 若養(yǎng)老區(qū)執(zhí)行了 Major GC 之后發(fā)現(xiàn)依然無法進行對象的保存,就會產(chǎn)生 OOM 異常
4.5 GC 垃圾回收簡介
Minor GC、Major GC、Full GC
JVM 在進行 GC 時,并非每次都對堆內(nèi)存(新生代、老年代;方法區(qū))區(qū)域一起回收的,大部分時候回收的都是指新生代。
針對 HotSpot VM 的實現(xiàn),它里面的 GC 按照回收區(qū)域又分為兩大類:部分收集(Partial GC),整堆收集(Full GC)
- 部分收集:不是完整收集整個 Java 堆的垃圾收集。其中又分為:
- 目前只有 G1 GC 會有這種行為
- 目前,只有 CMS GC 會有單獨收集老年代的行為
- 很多時候 Major GC 會和 Full GC 混合使用,需要具體分辨是老年代回收還是整堆回收
- 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
- 混合收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集
整堆收集(Full GC):收集整個 Java 堆和方法區(qū)的垃圾
4.6 TLAB
什么是 TLAB (Thread Local Allocation Buffer)?
- 從內(nèi)存模型而不是垃圾回收的角度,對 Eden 區(qū)域繼續(xù)進行劃分,JVM 為每個線程分配了一個私有緩存區(qū)域,它包含在 Eden 空間內(nèi)
- 多線程同時分配內(nèi)存時,使用 TLAB 可以避免一系列的非線程安全問題,同時還能提升內(nèi)存分配的吞吐量,因此我們可以將這種內(nèi)存分配方式稱為快速分配策略
- OpenJDK 衍生出來的 JVM 大都提供了 TLAB 設計
為什么要有 TLAB ?
- 堆區(qū)是線程共享的,任何線程都可以訪問到堆區(qū)中的共享數(shù)據(jù)
- 由于對象實例的創(chuàng)建在 JVM 中非常頻繁,因此在并發(fā)環(huán)境下從堆區(qū)中劃分內(nèi)存空間是線程不安全的
- 為避免多個線程操作同一地址,需要使用加鎖等機制,進而影響分配速度
盡管不是所有的對象實例都能夠在 TLAB 中成功分配內(nèi)存,但 JVM 確實是將 TLAB 作為內(nèi)存分配的首選。
在程序中,可以通過 -XX:UseTLAB 設置是否開啟 TLAB 空間。
默認情況下,TLAB 空間的內(nèi)存非常小,僅占有整個 Eden 空間的 1%,我們可以通過 -XX:TLABWasteTargetPercent 設置 TLAB 空間所占用 Eden 空間的百分比大小。
一旦對象在 TLAB 空間分配內(nèi)存失敗時,JVM 就會嘗試著通過使用加鎖機制確保數(shù)據(jù)操作的原子性,從而直接在 Eden 空間中分配內(nèi)存。
4.7 堆是分配對象存儲的唯一選擇嗎
隨著 JIT 編譯期的發(fā)展和逃逸分析技術的逐漸成熟,棧上分配、標量替換優(yōu)化技術將會導致一些微妙的變化,所有的對象都分配到堆上也漸漸變得不那么“絕對”了。 ——《深入理解 Java 虛擬機》
逃逸分析
逃逸分析(Escape Analysis)是目前 Java 虛擬機中比較前沿的優(yōu)化技術。這是一種可以有效減少 Java 程序中同步負載和內(nèi)存堆分配壓力的跨函數(shù)全局數(shù)據(jù)流分析算法。通過逃逸分析,Java Hotspot 編譯器能夠分析出一個新的對象的引用的使用范圍從而決定是否要將這個對象分配到堆上。
逃逸分析的基本行為就是分析對象動態(tài)作用域:
- 當一個對象在方法中被定義后,對象只在方法內(nèi)部使用,則認為沒有發(fā)生逃逸。
- 當一個對象在方法中被定義后,它被外部方法所引用,則認為發(fā)生逃逸。例如作為調用參數(shù)傳遞到其他地方中,稱為方法逃逸。
例如:
- public static StringBuffer craeteStringBuffer(String s1, String s2) {
- StringBuffer sb = new StringBuffer();
- sb.append(s1);
- sb.append(s2);
- return sb;
- }
StringBuffer sb是一個方法內(nèi)部變量,上述代碼中直接將sb返回,這樣這個 StringBuffer 有可能被其他方法所改變,這樣它的作用域就不只是在方法內(nèi)部,雖然它是一個局部變量,稱其逃逸到了方法外部。甚至還有可能被外部線程訪問到,譬如賦值給類變量或可以在其他線程中訪問的實例變量,稱為線程逃逸。
上述代碼如果想要 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 將不會逃逸出方法。
參數(shù)設置:
- 在 JDK 6u23版本之后,HotSpot 中默認就已經(jīng)開啟了逃逸分析
- 如果使用較早版本,可以通過-XX"+DoEscapeAnalysis顯式開啟
開發(fā)中使用局部變量,就不要在方法外定義。
使用逃逸分析,編譯器可以對代碼做優(yōu)化:
- 棧上分配:將堆分配轉化為棧分配。如果一個對象在子程序中被分配,要使指向該對象的指針永遠不會逃逸,對象可能是棧分配的候選,而不是堆分配
- 同步省略:如果一個對象被發(fā)現(xiàn)只能從一個線程被訪問到,那么對于這個對象的操作可以不考慮同步
- 分離對象或標量替換:有的對象可能不需要作為一個連續(xù)的內(nèi)存結構存在也可以被訪問到,那么對象的部分(或全部)可以不存儲在內(nèi)存,而存儲在 CPU 寄存器
JIT 編譯器在編譯期間根據(jù)逃逸分析的結果,發(fā)現(xiàn)如果一個對象并沒有逃逸出方法的話,就可能被優(yōu)化成棧上分配。分配完成后,繼續(xù)在調用棧內(nèi)執(zhí)行,最后線程結束,棧空間被回收,局部變量對象也被回收。這樣就無需進行垃圾回收了。
常見棧上分配的場景:成員變量賦值、方法返回值、實例引用傳遞
代碼優(yōu)化之同步省略(消除)
- 線程同步的代價是相當高的,同步的后果是降低并發(fā)性和性能
- 在動態(tài)編譯同步塊的時候,JIT 編譯器可以借助逃逸分析來判斷同步塊所使用的鎖對象是否能夠被一個線程訪問而沒有被發(fā)布到其他線程。如果沒有,那么 JIT 編譯器在編譯這個同步塊的時候就會取消對這個代碼的同步。這樣就能大大提高并發(fā)性和性能。這個取消同步的過程就叫做同步省略,也叫鎖消除。
- public void keep() {
- Object keeper = new Object();
- synchronized(keeper) {
- System.out.println(keeper);
- }
- }
如上代碼,代碼中對 keeper 這個對象進行加鎖,但是 keeper 對象的生命周期只在 keep()方法中,并不會被其他線程所訪問到,所以在 JIT編譯階段就會被優(yōu)化掉。優(yōu)化成:
- public void keep() {
- Object keeper = new Object();
- System.out.println(keeper);
- }
代碼優(yōu)化之標量替換
標量(Scalar)是指一個無法再分解成更小的數(shù)據(jù)的數(shù)據(jù)。Java 中的原始數(shù)據(jù)類型就是標量。
相對的,那些的還可以分解的數(shù)據(jù)叫做聚合量(Aggregate),Java 中的對象就是聚合量,因為其還可以分解成其他聚合量和標量。
在 JIT 階段,通過逃逸分析確定該對象不會被外部訪問,并且對象可以被進一步分解時,JVM不會創(chuàng)建該對象,而會將該對象成員變量分解若干個被這個方法使用的成員變量所代替。這些代替的成員變量在棧幀或寄存器上分配空間。這個過程就是標量替換。
通過 -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 就不會直接創(chuàng)建 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);
- }
代碼優(yōu)化之棧上分配
我們通過 JVM 內(nèi)存分配可以知道 JAVA 中的對象都是在堆上進行分配,當對象沒有被引用的時候,需要依靠 GC 進行回收內(nèi)存,如果對象數(shù)量較多的時候,會給 GC 帶來較大壓力,也間接影響了應用的性能。為了減少臨時對象在堆內(nèi)分配的數(shù)量,JVM 通過逃逸分析確定該對象不會被外部訪問。那就通過標量替換將該對象分解在棧上分配內(nèi)存,這樣該對象所占用的內(nèi)存空間就可以隨棧幀出棧而銷毀,就減輕了垃圾回收的壓力。
總結:
關于逃逸分析的論文在1999年就已經(jīng)發(fā)表了,但直到JDK 1.6才有實現(xiàn),而且這項技術到如今也并不是十分成熟的。
其根本原因就是無法保證逃逸分析的性能消耗一定能高于他的消耗。雖然經(jīng)過逃逸分析可以做標量替換、棧上分配、和鎖消除。但是逃逸分析自身也是需要進行一系列復雜的分析的,這其實也是一個相對耗時的過程。
一個極端的例子,就是經(jīng)過逃逸分析之后,發(fā)現(xiàn)沒有一個對象是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。
雖然這項技術并不十分成熟,但是他也是即時編譯器優(yōu)化技術中一個十分重要的手段。
五、方法區(qū)
- 方法區(qū)(Method Area)與 Java 堆一樣,是所有線程共享的內(nèi)存區(qū)域。
- 雖然 Java 虛擬機規(guī)范把方法區(qū)描述為堆的一個邏輯部分,但是它卻有一個別名叫 Non-Heap(非堆),目的應該是與 Java 堆區(qū)分開。
- 運行時常量池(Runtime Constant Pool)是方法區(qū)的一部分。Class 文件中除了有類的版本/字段/方法/接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用于存放編譯期生成的各種字面量和符號引用,這部分內(nèi)容將類在加載后進入方法區(qū)的運行時常量池中存放。運行期間也可能將新的常量放入池中,這種特性被開發(fā)人員利用得比較多的是 String.intern()方法。受方法區(qū)內(nèi)存的限制,當常量池無法再申請到內(nèi)存時會拋出OutOfMemoryError 異常。
- 方法區(qū)的大小和堆空間一樣,可以選擇固定大小也可選擇可擴展,方法區(qū)的大小決定了系統(tǒng)可以放多少個類,如果系統(tǒng)類太多,導致方法區(qū)溢出,虛擬機同樣會拋出內(nèi)存溢出錯誤
- JVM 關閉后方法區(qū)即被釋放
5.1 解惑
你是否也有看不同的參考資料,有的內(nèi)存結構圖有方法區(qū),有的又是永久代,元數(shù)據(jù)區(qū),一臉懵逼的時候?
- 方法區(qū)(method area)只是JVM規(guī)范中定義的一個概念,用于存儲類信息、常量池、靜態(tài)變量、JIT編譯后的代碼等數(shù)據(jù),并沒有規(guī)定如何去實現(xiàn)它,不同的廠商有不同的實現(xiàn)。而永久代(PermGen)是 Hotspot 虛擬機特有的概念, Java8 的時候又被元空間取代了,永久代和元空間都可以理解為方法區(qū)的落地實現(xiàn)。
- 永久代物理是堆的一部分,和新生代,老年代地址是連續(xù)的(受垃圾回收器管理),而元空間存在于本地內(nèi)存(我們常說的堆外內(nèi)存,不受垃圾回收器管理),這樣就不受 JVM 限制了,也比較難發(fā)生OOM(都會有溢出異常)
- Java7 中我們通過-XX:PermSize 和 -xx:MaxPermSize 來設置永久代參數(shù),Java8 之后,隨著永久代的取消,這些參數(shù)也就隨之失效了,改為通過-XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 用來設置元空間參數(shù)
- 存儲內(nèi)容不同,元空間存儲類的元信息,靜態(tài)變量和常量池等并入堆中。相當于永久代的數(shù)據(jù)被分到了堆和元空間中
- 如果方法區(qū)域中的內(nèi)存不能用于滿足分配請求,則 Java 虛擬機拋出 OutOfMemoryError
- JVM 規(guī)范說方法區(qū)在邏輯上是堆的一部分,但目前實際上是與 Java 堆分開的(Non-Heap)
所以對于方法區(qū),Java8 之后的變化:
- 移除了永久代(PermGen),替換為元空間(Metaspace);
- 永久代中的 class metadata 轉移到了 native memory(本地內(nèi)存,而不是虛擬機);
- 永久代中的 interned Strings 和 class static variables 轉移到了 Java heap;
- 永久代參數(shù) (PermSize MaxPermSize) -> 元空間參數(shù)(MetaspaceSize MaxMetaspaceSize)
5.2 設置方法區(qū)內(nèi)存的大小
jdk8及以后:
- 元數(shù)據(jù)區(qū)大小可以使用參數(shù) -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 指定,替代上述原有的兩個參數(shù)
- 默認值依賴于平臺。Windows 下,-XX:MetaspaceSize 是 21M,-XX:MaxMetaspacaSize的值是 -1,即沒有限制
- 與永久代不同,如果不指定大小,默認情況下,虛擬機會耗盡所有的可用系統(tǒng)內(nèi)存。如果元數(shù)據(jù)發(fā)生溢出,虛擬機一樣會拋出異常 OutOfMemoryError:Metaspace
- -XX:MetaspaceSize :設置初始的元空間大小。對于一個 64 位的服務器端 JVM 來說,其默認的 -XX:MetaspaceSize 的值為20.75MB,這就是初始的高水位線,一旦觸及這個水位線,F(xiàn)ull GC 將會被觸發(fā)并卸載沒用的類(即這些類對應的類加載器不再存活),然后這個高水位線將會重置,新的高水位線的值取決于 GC 后釋放了多少元空間。如果釋放的空間不足,那么在不超過 MaxMetaspaceSize時,適當提高該值。如果釋放空間過多,則適當降低該值
- 如果初始化的高水位線設置過低,上述高水位線調整情況會發(fā)生很多次,通過垃圾回收的日志可觀察到 Full GC 多次調用。為了避免頻繁 GC,建議將 -XX:MetaspaceSize 設置為一個相對較高的值。
5.3 方法區(qū)內(nèi)部結構
方法區(qū)用于存儲已被虛擬機加載的類型信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼緩存等。
類型信息
對每個加載的類型(類 class、接口 interface、枚舉 enum、注解 annotation),JVM 必須在方法區(qū)中存儲以下類型信息
- 這個類型的完整有效名稱(全名=包名.類名)
- 這個類型直接父類的完整有效名(對于 interface或是 java.lang.Object,都沒有父類)
- 這個類型的修飾符(public,abstract,final 的某個子集)
- 這個類型直接接口的一個有序列表
域(Field)信息
JVM 必須在方法區(qū)中保存類型的所有域的相關信息以及域的聲明順序
域的相關信息包括:域名稱、域類型、域修飾符(public、private、protected、static、final、volatile、transient 的某個子集)
- 方法(Method)信息
- JVM 必須保存所有方法的
- 方法名稱
- 方法的返回類型
- 方法參數(shù)的數(shù)量和類型
- 方法的修飾符(public,private,protected,static,final,synchronized,native,abstract 的一個子集)
- 方法的字符碼(bytecodes)、操作數(shù)棧、局部變量表及大小(abstract 和 native 方法除外)
- 異常表(abstract 和 native 方法除外)
- 每個異常處理的開始位置、結束位置、代碼處理在程序計數(shù)器中的偏移地址、被捕獲的異常類的常量池索引
棧、堆、方法區(qū)的交互關系
5.4 運行時常量池
運行時常量池(Runtime Constant Pool)是方法區(qū)的一部分,理解運行時常量池的話,我們先來說說字節(jié)碼文件(Class 文件)中的常量池(常量池表)
常量池
一個有效的字節(jié)碼文件中除了包含類的版本信息、字段、方法以及接口等描述信息外,還包含一項信息那就是常量池表(Constant Pool Table),包含各種字面量和對類型、域和方法的符號引用。
為什么需要常量池?
一個 java 源文件中的類、接口,編譯后產(chǎn)生一個字節(jié)碼文件。而 Java 中的字節(jié)碼需要數(shù)據(jù)支持,通常這種數(shù)據(jù)會很大以至于不能直接存到字節(jié)碼里,換另一種方式,可以存到常量池,這個字節(jié)碼包含了指向常量池的引用。在動態(tài)鏈接的時候用到的就是運行時常量池。
如下,我們通過jclasslib 查看一個只有 Main 方法的簡單類,字節(jié)碼中的 #2 指向的就是 Constant Pool
常量池可以看作是一張表,虛擬機指令根據(jù)這張常量表找到要執(zhí)行的類名、方法名、參數(shù)類型、字面量等類型。
運行時常量池
- 在加載類和結構到虛擬機后,就會創(chuàng)建對應的運行時常量池
- 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存儲編譯期生成的各種字面量和符號引用,這部分內(nèi)容將在類加載后存放到方法區(qū)的運行時常量池中
- JVM 為每個已加載的類型(類或接口)都維護一個常量池。池中的數(shù)據(jù)項像數(shù)組項一樣,是通過索引訪問的
- 運行時常量池中包含各種不同的常量,包括編譯器就已經(jīng)明確的數(shù)值字面量,也包括到運行期解析后才能夠獲得的方法或字段引用。此時不再是常量池中的符號地址了,這里換為真實地址
- 運行時常量池,相對于 Class 文件常量池的另一個重要特征是:動態(tài)性,Java 語言并不要求常量一定只有編譯期間才能產(chǎn)生,運行期間也可以將新的常量放入池中,String 類的 intern() 方法就是這樣的
- 當創(chuàng)建類或接口的運行時常量池時,如果構造運行時常量池所需的內(nèi)存空間超過了方法區(qū)所能提供的最大值,則 JVM 會拋出 OutOfMemoryError 異常。
5.5 方法區(qū)在 JDK6、7、8中的演進細節(jié)
只有 HotSpot 才有永久代的概念
jdk1.6及之前
有永久代,靜態(tài)變量存放在永久代上
jdk1.7 |
有永久代,但已經(jīng)逐步“去永久代”,字符串常量池、靜態(tài)變量移除,保存在堆中 |
jdk1.8及之后 |
取消永久代,類型信息、字段、方法、常量保存在本地內(nèi)存的元空間,但字符串常量池、靜態(tài)變量仍在堆中 |
移除永久代原因
http://openjdk.java.net/jeps/122
- 為永久代設置空間大小是很難確定的。
在某些場景下,如果動態(tài)加載類過多,容易產(chǎn)生 Perm 區(qū)的 OOM。如果某個實際 Web 工程中,因為功能點比較多,在運行過程中,要不斷動態(tài)加載很多類,經(jīng)常出現(xiàn) OOM。而元空間和永久代最大的區(qū)別在于,元空間不在虛擬機中,而是使用本地內(nèi)存,所以默認情況下,元空間的大小僅受本地內(nèi)存限制
- 對永久代進行調優(yōu)較困難
5.6 方法區(qū)的垃圾回收
方法區(qū)的垃圾收集主要回收兩部分內(nèi)容:常量池中廢棄的常量和不再使用的類型。
先來說說方法區(qū)內(nèi)常量池之中主要存放的兩大類常量:字面量和符號引用。字面量比較接近 java 語言層次的常量概念,如文本字符串、被聲明為 final 的常量值等。而符號引用則屬于編譯原理方面的概念,包括下面三類常量:
- 類和接口的全限定名
- 字段的名稱和描述符
- 方法的名稱和描述符
HotSpot 虛擬機對常量池的回收策略是很明確的,只要常量池中的常量沒有被任何地方引用,就可以被回收
判定一個類型是否屬于“不再被使用的類”,需要同時滿足三個條件:
該類所有的實例都已經(jīng)被回收,也就是 Java 堆中不存在該類及其任何派生子類的實例
加載該類的類加載器已經(jīng)被回收,這個條件除非是經(jīng)過精心設計的可替換類加載器的場景,如 OSGi、JSP 的重加載等,否則通常很難達成
該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法
Java 虛擬機被允許堆滿足上述三個條件的無用類進行回收,這里說的僅僅是“被允許”,而并不是和對象一樣,不使用了就必然會回收。是否對類進行回收,HotSpot 虛擬機提供了 -Xnoclassgc 參數(shù)進行控制,還可以使用 -verbose:class 以及 -XX:+TraceClassLoading 、-XX:+TraceClassUnLoading 查看類加載和卸載信息。
在大量使用反射、動態(tài)代理、CGLib 等 ByteCode 框架、動態(tài)生成 JSP 以及 OSGi 這類頻繁自定義 ClassLoader 的場景都需要虛擬機具備類卸載的功能,以保證永久代不會溢出。