Java JVM 知識點整理

1. 內存模型

JDK 1.8之前
1.8之前內存模型
JDK 1.8
1.8之後內存模型

線程私有 線程共享
程序計數器,虛擬機棧,本地方法棧 堆,方法區,元空間,直接內存(非運行時數據區的一部分)

1.1 程序計數器

一塊較小的內存空間。

可看作是當前線程所執行的字節碼的行號指示器,字節碼解釋器工作時通過改變這個計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等功能都需要依賴這個計數器來完成。

爲了使線程切換後能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各線程之間計數器互不影響,獨立存儲,所以程序計數器是線程私有的。它的生命週期隨着線程的創建而創建,隨着線程的結束而死亡。

主要有兩個作用:

  1. 幫助字節碼解釋器實現代碼的流程控制
  2. 記錄當前線程執行的位置

程序計數器是唯一一個不會出現 OutOfMemoryError 的內存區域。

1.2 虛擬機棧

描述的是 Java 方法執行的內存模型,每次方法調用的數據都是通過棧傳遞的。虛擬機棧由一個個棧幀組成,而每個棧幀中都擁有:局部變量表、操作數棧、動態鏈接、方法返回地址和一些額外附加信息。

Java 虛擬機棧也是線程私有的,每個線程都有各自的Java虛擬機棧,而且它的生命週期和線程相同,隨着線程的創建而創建,隨着線程的死亡而死亡。

局部變量表
局部變量表主要存放了編譯器可知的各種數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它不同於對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)。

操作數棧
方法的執行操作在操作數棧中完成,每一個字節碼指令往操作數棧進行寫入和提取的過程,就是入棧和出棧的過程。

動態鏈接
每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是爲了支持方法調用過程的動態鏈接。

Class文件的常量池存有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用爲參數。這些符號引用一部分會在類加載階段或第一次使用的時候轉化爲直接引用,這種轉化稱爲靜態解析。另外一部分將在每一次的運行期間轉化爲直接引用,這部分稱爲動態鏈接。

方法出口信息
方法有兩個退出方式,正常和異常。
無論何種退出都需要返回方法調用的位置,方法返回是可能需要在棧幀中保存一些信息,用來幫助他恢復它的上層方法的執行狀態。
正常退出,調用者的程序計數器的值就可以作爲返回地址,棧幀可能保存該計數器值;異常退出,返回地址通過異常處理表確定,棧幀一般不存。

附加信息
添加一些不在規範中的信息保存到棧幀中。

Java 虛擬機棧會出現兩種異常:
StackOverFlowError: 若 Java 虛擬機棧的內存大小不允許動態擴展,那麼當線程請求棧的深度超過當前Java虛擬機棧的最大深度的時候,就拋出StackOverFlowError異常。

OutOfMemoryError: 若 Java 虛擬機棧的內存大小允許動態擴展,且當線程請求棧時內存用完了,無法再動態擴展了,此時拋出OutOfMemoryError異常。

擴展:那麼方法/函數如何調用?
Java 棧可類比數據結構中棧,Java 棧中保存的主要內容是棧幀,每一次函數調用都會有一個對應的棧幀被壓入Java棧,每一個函數調用結束後,都會有一個棧幀被彈出。

Java方法有兩種返回方式:return 語句和拋出異常。不管哪種返回方式都會導致棧幀被彈出。

1.3 本地方法棧

和虛擬機棧所發揮的作用非常相似,區別是: 虛擬機棧爲虛擬機執行 Java 方法 (也就是字節碼)服務,而本地方法棧則爲虛擬機使用到的 Native 方法服務。 在 HotSpot 虛擬機中和 Java 虛擬機棧合二爲一。

本地方法被執行的時候,在本地方法棧也會創建一個棧幀,用於存放該本地方法的局部變量表、操作數棧、動態鏈接、出口信息。

方法執行完畢後相應的棧幀也會出棧並釋放內存空間,也會出現 StackOverFlowError 和 OutOfMemoryError 兩種異常。

1.4 堆

Java 虛擬機所管理的內存中最大的一塊,Java 堆是所有線程共享的一塊內存區域,在虛擬機啓動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數組都在這裏分配內存。

Java 堆是垃圾收集器管理的主要區域,因此也被稱作GC堆(Garbage Collected Heap)。從垃圾回收的角度,由於現在收集器基本都採用分代垃圾收集算法,所以Java堆還可以細分爲:新生代和老年代:再細緻一點有:Eden空間、From Survivor、To Survivor空間等。進一步劃分的目的是更好地回收內存,或者更快地分配內存。

1.5 方法區

方法區與 Java 堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息常量靜態變量即時編譯器編譯後的代碼等數據。雖然Java虛擬機規範把方法區描述爲堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。

運行時常量池
運行時常量池是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有常量池信息(用於存放編譯期生成的各種字面量和符號引用)。既然運行時常量池時方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。

JDK1.7及之後版本的 JVM 已經將運行時常量池從方法區中移了出來,在 Java 堆(Heap)中開闢了一塊區域存放運行時常量池。

永久代
方法區也被稱爲永久代。

方法區和永久代的關係:《Java虛擬機規範》只是規定了有方法區這麼個概念和它的作用,並沒有規定如何去實現它。那麼,在不同的 JVM 上方法區的實現肯定是不同的了。 方法區和永久代的關係很像Java中接口和類的關係,類實現了接口,而永久代就是HotSpot虛擬機對虛擬機規範中方法區的一種實現方式。 也就是說,永久代是HotSpot的概念,方法區是Java虛擬機規範中的定義,是一種規範,而永久代是一種實現,一個是標準一個是實現,其他的虛擬機實現並沒有永久代這一說法。

爲什麼要將永久代(PermGen)替換爲元空間(MetaSpace)呢?
爲了使內存不受限。整個永久代有一個 JVM 本身設置固定大小上線,無法進行調整,而元空間使用的是直接內存,受本機可用內存的限制,並且永遠不會得到java.lang.OutOfMemoryError。

你可以使用 -XX:MaxMetaspaceSize 標誌設置最大元空間大小,默認值爲 unlimited,這意味着它只受系統內存的限制。-XX:MetaspaceSize 調整標誌定義元空間的初始大小如果未指定此標誌,則 Metaspace 將根據運行時的應用程序需求動態地重新調整大小。

1.6 直接內存

直接內存並不是虛擬機運行時數據區的一部分,也不是虛擬機規範中定義的內存區域,但是這部分內存也被頻繁地使用。而且也可能導致 OutOfMemoryError 異常出現。

JDK1.4 中新加入的 NIO(New Input/Output) 類,引入了一種基於通道(Channel) 與緩存區(Buffer) 的 I/O 方式,它可以直接使用 Native 函數庫直接分配堆外內存,然後通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作爲這塊內存的引用進行操作。這樣就能在一些場景中顯著提高性能,因爲避免了在 Java 堆和 Native 堆之間來回複製數據。

本機直接內存的分配不會收到 Java 堆的限制,但是,既然是內存就會受到本機總內存大小以及處理器尋址空間的限制。

2. 類加載器

JVM 中內置了三個重要的 ClassLoader,除了 BootstrapClassLoader 其他類加載器均由 Java 實現且全部繼承自java.lang.ClassLoader。

類加載器 介紹
BootstrapClassLoader(啓動類加載器) 最頂層的加載類,由C++實現,負責加載 %JAVA_HOME%/lib目錄下的jar包和類或者或被 -Xbootclasspath參數指定的路徑中的所有類。
ExtensionClassLoader(擴展類加載器) 主要負責加載目錄 %JRE_HOME%/lib/ext 目錄下的jar包和類,或被 java.ext.dirs 系統變量所指定的路徑下的jar包。
AppClassLoader(應用程序類加載器) 面向我們用戶的加載器,負責加載當前應用classpath下的所有jar包和類。

雙親委派模型
每一個類都有一個對應它的類加載器。系統中的 ClassLoder 在協同工作的時候會默認使用 雙親委派模型 。即在類加載的時候,系統會首先判斷當前類是否被加載過。已經被加載的類會直接返回,否則纔會嘗試加載。

加載的時候,首先會把該請求委派該父類加載器的 loadClass() 處理,因此所有的請求最終都應該傳送到頂層的啓動類加載器 BootstrapClassLoader 中。當父類加載器無法處理時,才由自己來處理。當父類加載器爲null時,會使用啓動類加載器 BootstrapClassLoader 作爲父類加載器。

雙親委派模型

好處
雙親委派模型保證了Java程序的穩定運行,可以避免類的重複加載(JVM 區分不同類的方式不僅僅根據類名,相同的類文件被不同的類加載器加載產生的是兩個不同的類),也保證了 Java 的核心 API 不被篡改。如果不用沒有使用雙親委派模型,而是每個類加載器加載自己的話就會出現一些問題,比如我們編寫一個稱爲 java.lang.Object 類的話,那麼程序運行的時候,系統就會出現多個不同的 Object 類。

如果我們不想用雙親委派模型怎麼辦?
爲了避免雙親委託機制,我們可以自己定義一個類加載器,然後重載 loadClass() 即可。

如何自定義類加載器?
除了 BootstrapClassLoader 其他類加載器均由 Java 實現且全部繼承自java.lang.ClassLoader。如果我們要自定義自己的類加載器,很明顯需要繼承 ClassLoader。

3. 類加載過程

加載->連接->初始化,連接過程又可分爲三步:驗證->準備->解析。
類加載過程
加載
類加載過程的第一步,主要完成下面3件事情:

  1. 通過全類名獲取定義此類的二進制字節流
  2. 將字節流所代表的靜態存儲結構轉換爲方法區的運行時數據結構
  3. 在內存中生成一個代表該類的 Class 對象,作爲方法區這些數據的訪問入口

虛擬機規範多上面這3點並不具體,因此是非常靈活的。比如:“通過全類名獲取定義此類的二進制字節流” 並沒有指明具體從哪裏獲取、怎樣獲取。比如:比較常見的就是從 ZIP 包中讀取(日後出現的JAR、EAR、WAR格式的基礎)、其他文件生成(典型應用就是JSP)等等。

一個非數組類的加載階段(加載階段獲取類的二進制字節流的動作)是可控性最強的階段,這一步我們可以去完成還可以自定義類加載器去控制字節流的獲取方式(重寫一個類加載器的 loadClass() 方法)。數組類型不通過類加載器創建,它由 Java 虛擬機直接創建。

加載階段和連接階段的部分內容是交叉進行的,加載階段尚未結束,連接階段可能就已經開始了。

驗證
驗證是連接階段的第一步,這是檢查類的二進制表示形式並驗證生成的.class文件是否有效的過程,爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。

準備
準備階段是爲類級別或接口級別的靜態變量分配內存和默認值的過程。

解析
解析階段是將符號引用與方法區域中的原始內存引用進行更改的過程,將常量池內的符號引用替換爲直接引用。

符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量(如全類名),只要能無歧義的定位到目標即可。符號引用與虛擬機實現的內存佈局無關,引用的目標並不一定已經加載到內存中。
直接引用:可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用與虛擬機實現的內存佈局相關,同一個符號引用在不同的虛擬機實例上翻譯出來的直接引用一般不會相同。如果是直接引用,那引用的目標必定已經在內存中存在。

初始化
類初始化階段是類加載的最後一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。

在該階段中,所有靜態變量都被分配了原始值,並且靜態塊從父類執行到子類。到了初始化階段,才真正執行類中的定義的Java程序代碼(或者說是字節碼)。

4. 對象的創建過程

Java創建對象的過程
類加載檢查
虛擬機遇到一條 new 指令時,首先將去檢查這個指令的參數是否能在常量池中定位到這個類的符號引用,並且檢查這個符號引用代表的類是否已被加載過、解析和初始化過。如果沒有,那必須先執行相應的類加載過程。

分配內存
在類加載檢查通過後,接下來虛擬機將爲新生對象分配內存。對象所需的內存大小在類加載完成後便可確定,爲對象分配空間的任務等同於把一塊確定大小的內存從 Java 堆中劃分出來。分配方式有 “指針碰撞” 和 “空閒列表” 兩種,選擇那種分配方式由 Java 堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。

內存分配的兩種方式
內存分配的兩種方式
選擇以上兩種方式中的哪一種,取決於 Java 堆內存是否規整。而 Java 堆內存是否規整,取決於 GC 收集器的算法是"標記-清除",還是"標記-整理",值得注意的是,複製算法內存也是規整的

內存分配併發問題
在創建對象的時候有一個很重要的問題,就是線程安全,因爲在實際開發過程中,創建對象是很頻繁的事情,作爲虛擬機來說,必須要保證線程是安全的,通常來講,虛擬機採用兩種方式來保證線程安全:
CAS+失敗重試: CAS 是樂觀鎖的一種實現方式。所謂樂觀鎖就是,每次不加鎖而是假設沒有衝突而去完成某項操作,如果因爲衝突失敗就重試,直到成功爲止。虛擬機採用 CAS 配上失敗重試的方式保證更新操作的原子性。
TLAB: 爲每一個線程預先在Eden區分配一塊兒內存,JVM在給線程中的對象分配內存時,首先在TLAB分配,當對象大於TLAB中的剩餘內存或TLAB的內存已用盡時,再採用上述的CAS進行內存分配

初始化零值
內存分配完成後,虛擬機需要將分配到的內存空間都初始化爲零值(不包括對象頭),這一步操作保證了對象的實例字段在 Java 代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。

設置對象頭
初始化零值完成之後,虛擬機要對對象進行必要的設置,例如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的 GC 分代年齡等信息。 這些信息存放在對象頭中。 另外,根據虛擬機當前運行狀態的不同,如是否啓用偏向鎖等,對象頭會有不同的設置方式。

執行 init 方法
在上面工作都完成之後,從虛擬機的視角來看,一個新的對象已經產生了,但從 Java 程序的視角來看,對象創建纔剛開始,

Java 在編譯之後會在字節碼文件中生成 init 方法,稱之爲實例構造器,該實例構造器會將語句塊,變量初始化,調用父類的構造器等操作收斂到 init 方法中,收斂順序爲:
父類變量初始化
父類語句塊
父類構造函數
子類變量初始化
子類語句塊
子類構造函數

初始化順序:
基類static成員,基類static塊,派生類static成員,派生類static塊;(類加載過程中完成)
基類成員,基本構造塊,基類構造函數,派生類成員,派生類構造塊,派生類構造函數。(執行 init 方法中完成)

5. 對象訪問定位

建立對象就是爲了使用對象,我們的Java程序通過棧上的 reference 數據來操作堆上的具體對象。對象的訪問方式有虛擬機實現而定,目前主流的訪問方式有使用句柄和直接指針兩種:

句柄
如果使用句柄的話,那麼Java堆中將會劃分出一塊內存來作爲句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息;
句柄
直接指針
如果使用直接指針訪問,那麼 Java 堆對象的佈局中就必須考慮如何放置訪問類型數據的相關信息,而reference 中存儲的直接就是對象的地址。
直接指針

這兩種對象訪問方式各有優勢。使用句柄來訪問的最大好處是 reference 中存儲的是穩定的句柄地址,在對象被移動時只會改變句柄中的實例數據指針,而 reference 本身不需要修改。使用直接指針訪問方式最大的好處就是速度快,它節省了一次指針定位的時間開銷。

6. 對象存活

堆中幾乎放着所有的對象實例,對堆垃圾回收前的第一步就是要判斷哪些對象已經死亡(即不能再被任何途徑使用的對象)。

6.1 引用計數法

給對象中添加一個引用計數器,每當有一個地方引用它,計數器就加1;當引用失效,計數器就減1;任何時候計數器爲0的對象就是不可能再被使用的。

Java沒有采用引用計數算法來管理內存,原因是它很難解決對象之間相互循環引用的問題。
引用計數法循環依賴

6.2 可達性分析算法

這個算法的基本思想就是通過一系列的稱爲 “GC Roots” 的對象作爲起點,從這些節點開始向下搜索,節點所走過的路徑稱爲引用鏈,當一個對象到 GC Roots 沒有任何引用鏈相連的話,則證明此對象是不可用的。
可達性分析算法
通過可達性算法,成功解決了引用計數所無法解決的“循環依賴”的問題,只要你無法與 GC Root 建立直接或間接的連接,系統就會判定你爲可回收對象。那這樣就引申出了另一個問題,哪些屬於 GC Root。

在 Java 語言中,可作爲 GC Root 的對象包括以下4種:

  1. 虛擬機棧(棧幀中的局部變量表)中引用的對象
  2. 方法區中類靜態屬性引用的對象
  3. 方法區中常量引用的對象
  4. 本地方法棧中 JNI(即一般說的 Native 方法)引用的對象

6.3 強引用,軟引用,弱引用,虛引用

無論是通過引用計數法判斷對象引用數量,還是通過可達性分析法判斷對象的引用鏈是否可達,判定對象的存活都與“引用”有關。

JDK1.2之前,Java中引用的定義很傳統:如果reference類型的數據存儲的數值代表的是另一塊內存的起始地址,就稱這塊內存代表一個引用。

JDK1.2以後,Java對引用的概念進行了擴充,將引用分爲強引用、軟引用、弱引用、虛引用四種(引用強度逐漸減弱)

強引用 StrongReference
以前我們使用的大部分引用實際上都是強引用,這是使用最普遍的引用。如果一個對象具有強引用,那就類似於必不可少的生活用品,垃圾回收器絕不會回收它。當內存空間不足,Java虛擬機寧願拋出OutOfMemoryError錯誤,使程序異常終止,也不會靠隨意回收具有強引用的對象來解決內存不足問題。

軟引用 SoftReference
如果一個對象只具有軟引用,那就類似於可有可無的生活用品。如果內存空間足夠,垃圾回收器就不會回收它,如果內存空間不足了,就會回收這些對象的內存。只要垃圾回收器沒有回收它,該對象就可以被程序使用。軟引用可用來實現內存敏感的高速緩存。

軟引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果軟引用所引用的對象被垃圾回收,JAVA虛擬機就會把這個軟引用加入到與之關聯的引用隊列中。

弱引用 WeakReference
如果一個對象只具有弱引用,那就類似於可有可無的生活用品。

弱引用與軟引用的區別在於:只具有弱引用的對象擁有更短暫的生命週期。在垃圾回收器線程掃描它所管轄的內存區域的過程中,一旦發現了只具有弱引用的對象,不管當前內存空間足夠與否,都會回收它的內存。不過,由於垃圾回收器是一個優先級很低的線程, 因此不一定會很快發現那些只具有弱引用的對象。

弱引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果弱引用所引用的對象被垃圾回收,Java虛擬機就會把這個弱引用加入到與之關聯的引用隊列中。

虛引用 PhantomReference
虛引用顧名思義,就是形同虛設,與其他幾種引用都不同,虛引用並不會決定對象的生命週期。如果一個對象僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收

虛引用主要用來跟蹤對象被垃圾回收的活動。

虛引用與軟引用和弱引用的一個區別在於: 虛引用必須和引用隊列(ReferenceQueue)聯合使用。當垃圾回收器準備回收一個對象時,如果發現它還有虛引用,就會在回收對象的內存之前,把這個虛引用加入到與之關聯的引用隊列中。程序可以通過判斷引用隊列中是 否已經加入了虛引用,來了解被引用的對象是否將要被垃圾回收。程序如果發現某個虛引用已經被加入到引用隊列,那麼就可以在所引用的對象的內存被回收之前採取必要的行動。

特別注意,在程序設計中一般很少使用弱引用與虛引用,使用軟引用的情況較多,這是因爲軟引用可以加速JVM對垃圾內存的回收速度,可以維護系統的運行安全,防止內存溢出(OutOfMemory)等問題的產生。

7. 垃圾收集

堆是垃圾收集器管理的主要區域,但是方法區也會進行垃圾收集,主要回收兩部分內容:廢棄常量,無用的類。

廢棄常量
假如在常量池中存在字符串 “abc”,如果當前沒有任何String對象引用該字符串常量的話,就說明常量 “abc” 就是廢棄常量,如果這時發生內存回收的話而且有必要的話,“abc” 就會被系統清理出常量池。

無用的類

  1. 該類所有的實例都已經被回收,也就是 Java 堆中不存在該類的任何實例。
  2. 加載該類的 ClassLoader 已經被回收。
  3. 該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

虛擬機可以對滿足上述3個條件的無用類進行回收,這裏說的僅僅是“可以”,而並不是和對象一樣不使用了就會必然被回收。

7.1 垃圾收集算法

標記-清除算法
標記清除
標記-清除算法(Mark-Sweep)是最基礎的一種垃圾回收算法,算法分爲“標記”和“清除”階段:首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象。它是最基礎的收集算法,後續的算法都是對其不足進行改進得到。這種垃圾收集算法會帶來兩個明顯的問題:

  1. 效率問題
  2. 空間問題(標記清除後會產生大量不連續的碎片)

複製算法
複製
複製算法(Copying)是在標記清除算法上演化而來,解決標記清除算法的內存碎片問題。它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。保證了內存的連續可用,內存分配時也就不用考慮內存碎片等複雜情況,邏輯清晰,運行高效。

標記-整理算法
標記整理
標記-整理算法(Mark-Compact)標記過程仍然與標記-清除算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,再清理掉端邊界以外的內存區域。

標記整理算法一方面在標記-清除算法上做了升級,解決了內存碎片的問題,也規避了複製算法只能利用一半內存區域的弊端。看起來很美好,但從上圖可以看到,它對內存變動更頻繁,需要整理所有存活對象的引用地址,在效率上比複製算法要差很多。

分代收集算法
分代收集算法(Generational Collection)嚴格來說並不是一種思想或理論,而是融合上述3種基礎的算法思想,而產生的針對不同情況所採用不同算法的一套組合拳。對象存活週期的不同將內存劃分爲幾塊。一般是把 Java 堆分爲新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集算法。

在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用複製算法,只需要付出少量存活對象的複製成本就可以完成收集。而老年代中因爲對象存活率高、沒有額外空間對它進行分配擔保,就必須使用標記-清理或者標記-整理算法來進行回收。

7.2 堆中內存模型

Java 堆(Java Heap)是JVM所管理的內存中最大的一塊,堆又是垃圾收集器管理的主要區域。
堆中內存模型
Java 堆主要分爲2個區域:年輕代與老年代,其中年輕代又分 Eden 區和 Survivor 區,其中 Survivor 區又分 From 和 To 2個區。

Eden 區
IBM 公司的專業研究表明,有將近98%的對象是朝生夕死,所以針對這一現狀,大多數情況下,對象會在新生代 Eden 區中進行分配,當 Eden 區沒有足夠空間進行分配時,虛擬機會發起一次 Minor GC,Minor GC 相比 Major GC 更頻繁,回收速度也更快。

通過 Minor GC 之後,Eden 會被清空,Eden 區中絕大部分對象會被回收,而那些無需回收的存活對象,將會進到 Survivor 的 From 區(若 From 區不夠,則直接進入 Old 區)。

新生代GC(Minor GC):指發生新生代的的垃圾收集動作,Minor GC非常頻繁,回收速度一般也比較快。
老年代GC(Major GC/Full GC):指發生在老年代的GC,出現了Major GC經常會伴隨至少一次的Minor GC(並非絕對),Major GC的速度一般會比Minor GC的慢10倍以上。

Survivor 區
Survivor 區相當於是 Eden 區和 Old 區的一個緩衝,類似於我們交通燈中的黃燈。Survivor 又分爲2個區,一個是 From 區,一個是 To 區。每次執行 Minor GC,會將 Eden 區和 From 存活的對象放到 Survivor 的 To 區(如果 To 區不夠,則直接進入 Old 區)。

爲啥需要?
如果沒有 Survivor 區,Eden 區每進行一次 Minor GC,存活的對象就會被送到老年代,老年代很快就會被填滿。而有很多對象雖然一次 Minor GC 沒有消滅,但其實也並不會蹦躂多久,或許第二次,第三次就需要被清除。這時候移入老年區,很明顯不是一個明智的決定。

所以,Survivor 的存在意義就是減少被送到老年代的對象,進而減少 Major GC 的發生。Survivor 的預篩選保證,只有經歷16次 Minor GC 還能在新生代中存活的對象,纔會被送到老年代。

爲啥需要倆?
設置兩個 Survivor 區最大的好處就是解決內存碎片化。

我們先假設一下,Survivor 如果只有一個區域會怎樣。Minor GC 執行後,Eden 區被清空了,存活的對象放到了 Survivor 區,而之前 Survivor 區中的對象,可能也有一些是需要被清除的。問題來了,這時候我們怎麼清除它們?在這種場景下,我們只能標記清除,而我們知道標記清除最大的問題就是內存碎片,在新生代這種經常會消亡的區域,採用標記清除必然會讓內存產生嚴重的碎片化。

因爲 Survivor 有2個區域,所以每次 Minor GC,會將之前 Eden 區和 From 區中的存活對象複製到 To 區域。第二次 Minor GC 時,From 與 To 職責兌換,這時候會將 Eden 區和 To 區中的存活對象再複製到 From 區域,以此反覆。

這種機制最大的好處就是,整個過程中,永遠有一個 Survivor space 是空的,另一個非空的 Survivor space 是無碎片的。那麼,Survivor 爲什麼不分更多塊呢?比方說分成三個、四個、五個?顯然,如果 Survivor 區再細分下去,每一塊的空間就會比較小,容易導致 Survivor 區滿,兩塊 Survivor 區可能是經過權衡之後的最佳方案。

Old 區
老年代佔據着2/3的堆內存空間,只有在 Major GC 的時候纔會進行清理,每次 GC 都會觸發“Stop-The-World”。內存越大,STW 的時間也越長,所以內存也不僅僅是越大就越好。由於複製算法在對象存活率較高的老年代會進行很多次的複製操作,效率很低,所以老年代這裏採用的是標記-整理算法。

除了上述所說,在內存擔保機制下,無法安置的對象會直接進到老年代,以下幾種情況也會進入老年代。

大對象
大對象指需要大量連續內存空間的對象,這部分對象不管是不是“朝生夕死”,都會直接進到老年代。這樣做主要是爲了避免在 Eden 區及2個 Survivor 區之間發生大量的內存複製。當你的系統有非常多“朝生夕死”的大對象時,得注意了。

長期存活對象
虛擬機給每個對象定義了一個對象年齡(Age)計數器。正常情況下對象會不斷的在 Survivor 的 From 區與 To 區之間移動,對象在 Survivor 區中每經歷一次 Minor GC,年齡就增加1歲。當年齡增加到15歲時,這時候就會被轉移到老年代。當然,這裏的15,JVM 也支持進行特殊設置。

動態對象年齡
虛擬機並不重視要求對象年齡必須到15歲,纔會放入老年區,如果 Survivor 空間中相同年齡所有對象大小的總合大於 Survivor 空間的一半,年齡大於等於該年齡的對象就可以直接進去老年區,無需等你“成年”。

這其實有點類似於負載均衡,輪詢是負載均衡的一種,保證每臺機器都分得同樣的請求。看似很均衡,但每臺機的硬件不通,健康狀況不同,我們還可以基於每臺機接受的請求數,或每臺機的響應時間等,來調整我們的負載均衡算法。

7.3 垃圾收集器

Serial收集器
Serial(串行)收集器是最基本、歷史最悠久的垃圾收集器了,是一個單線程收集器了。它的 “單線程” 的意義不僅僅意味着它只會使用一條垃圾收集線程去完成垃圾收集工作,更重要的是它在進行垃圾收集工作的時候必須暫停其他所有的工作線程( “Stop The World” ),直到它收集結束。
Serial收集器
新生代採用複製算法,老年代採用標記-整理算法。

Serial收集器簡單而高效(與其他收集器的單線程相比)。
Serial收集器由於沒有線程交互的開銷,自然可以獲得很高的單線程收集效率。Serial收集器對於運行在Client模式下的虛擬機來說是個不錯的選擇。

ParNew收集器
ParNew收集器其實就是Serial收集器的多線程版本,除了使用多線程進行垃圾收集外,其餘行爲(控制參數、收集算法、回收策略等等)和Serial收集器完全一樣。

ParNew收集器

新生代採用複製算法,老年代採用標記-整理算法。

ParNew收集器是許多運行在Server模式下的虛擬機的首要選擇,除了Serial收集器外,只有它能與CMS收集器(真正意義上的併發收集器)配合工作。

並行(Parallel) :指多條垃圾收集線程並行工作,但此時用戶線程仍然處於等待狀態。
併發(Concurrent):指用戶線程與垃圾收集線程同時執行(但不一定是並行,可能會交替執行),用戶程序在繼續運行,而垃圾收集器運行在另一個CPU上。

Parallel Scavenge收集器
Parallel Scavenge 收集器類似於ParNew 收集器,但是它更關注吞吐量(高效率的利用CPU)。
CMS等垃圾收集器的關注點更多的是用戶線程的停頓時間(提高用戶體驗)。
所謂吞吐量就是CPU中用於運行用戶代碼的時間與CPU總消耗時間的比值。 Parallel Scavenge收集器提供了很多參數供用戶找到最合適的停頓時間或最大吞吐量。
Parallel Scavenge收集器
新生代採用複製算法,老年代採用標記-整理算法。

Serial Old收集器
Serial收集器的老年代版本,它同樣是一個單線程收集器。
它主要有兩大用途:一種用途是在JDK1.5以及以前的版本中與Parallel Scavenge收集器搭配使用,另一種用途是作爲CMS收集器的後備方案。

Parallel Old收集器
Parallel Scavenge收集器的老年代版本。使用多線程和“標記-整理”算法。在注重吞吐量以及CPU資源的場合,都可以優先考慮 Parallel Scavenge收集器和Parallel Old收集器。

CMS(Concurrent Mark Sweep)收集器
CMS收集器是一種 “標記-清除”算法實現的,它的運作過程相比於前面幾種垃圾收集器來說更加複雜一些。

它是一種以獲取最短回收停頓時間爲目標的收集器,非常符合在注重用戶體驗的應用上使用;是HotSpot虛擬機第一款真正意義上的併發收集器,它第一次實現了讓垃圾收集線程與用戶線程(基本上)同時工作。

整個過程分爲四個步驟:

  1. 初始標記: 暫停所有的其他線程,並記錄下直接與root相連的對象,速度很快 ;
  2. 併發標記: 同時開啓GC和用戶線程,用一個閉包結構去記錄可達對象。但在這個階段結束,這個閉包結構並不能保證包含當前所有的可達對象。因爲用戶線程可能會不斷的更新引用域,所以GC線程無法保證可達性分析的實時性。所以這個算法裏會跟蹤記錄這些發生引用更新的地方。
  3. 重新標記: 重新標記階段就是爲了修正併發標記期間因爲用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段的時間稍長,遠遠比並發標記階段時間短
  4. 併發清除: 開啓用戶線程,同時GC線程開始對爲標記的區域做清掃。
    CMS收集器

主要優點:併發收集、低停頓。但是它有下面三個明顯的缺點:

  1. 對CPU資源敏感;
  2. 無法處理浮動垃圾;
  3. 它使用的回收算法“標記-清除”算法會導致收集結束時會有大量空間碎片產生。

G1收集器
G1 (Garbage-First)是一款面向服務器的垃圾收集器,主要針對配備多顆處理器及大容量內存的機器.,以極高概率滿足GC停頓時間要求的同時,還具備高吞吐量性能特徵,被視爲JDK1.7中HotSpot虛擬機的一個重要進化特徵。

特點:

  1. 並行與併發:G1能充分利用CPU、多核環境下的硬件優勢,使用多個CPU(CPU或者CPU核心)來縮短Stop-The-World停頓時間。部分其他收集器原本需要停頓Java線程執行的GC動作,G1收集器仍然可以通過併發的方式讓java程序繼續執行。
  2. 分代收集:雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但是還是保留了分代的概念。
  3. 空間整合:與CMS的“標記-清理”算法不同,G1從整體來看是基於“標記-整理”算法實現的收集器;從局部上來看是基於“複製”算法實現的。
  4. 可預測的停頓:這是G1相對於CMS的另一個大優勢,降低停頓時間是G1 和 CMS 共同的關注點,但G1 除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度爲M毫秒的時間片段內。

G1收集器的運作大致分爲以下幾個步驟:

  1. 初始標記
  2. 併發標記
  3. 最終標記
  4. 篩選回收

G1收集器在後臺維護了一個優先列表,每次根據允許的收集時間,優先選擇回收價值最大的Region。這種使用Region劃分內存空間以及有優先級的區域回收方式,保證了G1收集器在有限時間內可以儘可能高的收集效率(把內存化整爲零)。

參考:
JavaGuide面試突擊版-JVM
咱們從頭到尾說一次 Java 垃圾回收
JVM體系結構:JVM類加載器和運行時數據區
深入理解java虛擬機(全章節完整)
深入JVM類加載機制

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