深入理解Java虛擬機器(自動記憶體管理機制)

張磊BARON發表於2019-06-10

歡迎關注微信公眾號:BaronTalk,獲取更多精彩好文!

書籍真的是常讀常新,古人說「書讀百遍其義自見」還是蠻有道理的。周志明老師的這本《深入理解 Java 虛擬機器》我細讀了不下三遍,每一次閱讀都有新的收穫,每一次閱讀對 Java 虛擬機器的理解就更進一步。因而萌生了將讀書筆記整理成文的想法,一是想檢驗下自己的學習成果,對學習內容進行一次系統性的覆盤;二是給還沒接觸過這部好作品的同學推薦下,在閱讀這部佳作之前能通過我的文章一窺書中的精華。

原想著一篇文章就夠了,但寫著寫著就發現篇幅大大超出了預期。看來還是功力不夠,索性拆成了六篇文章,分別從自動記憶體管理機制類檔案結構類載入機制位元組碼執行引擎程式編譯與程式碼優化高效併發六個方面來做更加細緻的介紹。本文先說說 Java 虛擬機器的自動記憶體管理機制。

一. 執行時資料區

Java 虛擬機器在執行 Java 程式的過程中會把它所管理的記憶體區域劃分為若干個不同的資料區域。這些區域都有各自的用途,以及建立和銷燬的時間,有些區域隨著虛擬機器程式的啟動而存在,有些區域則是依賴執行緒的啟動和結束而建立和銷燬。Java 虛擬機器所管理的記憶體被劃分為如下幾個區域:

深入理解Java虛擬機器(自動記憶體管理機制)

程式計數器

程式計數器是一塊較小的記憶體區域,可以看做是當前執行緒所執行的位元組碼的行號指示器。在虛擬機器的概念模型裡,位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。「屬於執行緒私有的記憶體區域」

Java 虛擬機器棧

就是我們平時所說的棧,每個方法被執行時,都會建立一個棧幀(Stack Frame)用於儲存區域性變數表、操作棧、動態連結、方法出口等資訊。每個方法從被呼叫到執行完成的過程,就對應著一個棧幀在虛擬機器棧中從出棧到入棧的過程。「屬於執行緒私有的記憶體區域」

區域性變數表:區域性變數表是 Java 虛擬機器棧的一部分,存放了編譯器可知的基本資料型別(boolean、byte、char、short、int、float、long、double)、物件引用(reference 型別,不等同於物件本身,根據不同的虛擬機器實現,它可能是一個指向物件起始地址的引用指標,也可能是指向一個代表物件的控制程式碼或者其他與此物件相關的位置)和 returnAddress 型別(指向了一條位元組碼指令的地址)。

本地方法棧

和虛擬機器棧類似,只不過虛擬機器棧為虛擬機器執行的 Java 方法服務,本地方法棧為虛擬機器使用的 Native 方法服務。「屬於執行緒私有的記憶體區域」

Java 堆

對大多數應用而言,Java 堆是虛擬機器所管理的記憶體中最大的一塊,是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一作用就是存放物件例項,幾乎所有的物件例項都是在這裡分配的(不絕對,在虛擬機器的優化策略下,也會存在棧上分配、標量替換的情況,後面的章節會詳細介紹)。Java 堆是 GC 回收的主要區域,因此很多時候也被稱為 GC 堆。從記憶體回收的角度看,Java 堆還可以被細分為新生代和老年代;再細一點新生代還可以被劃分為 Eden Space、From Survivor Space、To Survivor Space。從記憶體回收的角度看,執行緒共享的 Java 堆可能劃分出多個執行緒私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。「屬於執行緒共享的記憶體區域」

方法區

用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。「屬於執行緒共享的記憶體區域」

執行時常量池: 執行時常量池是方法區的一部分,Class 檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊就是常量池(Constant Pool Table),用於存放在編譯期生成的各種字面量和符號引用。

直接記憶體:直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是 Java 虛擬機器規範中定義的記憶體區域。Java 中的 NIO 可以使用 Native 函式直接分配堆外記憶體,然後通過一個儲存在 Java 堆中的 DiectByteBuffer 物件作為這塊記憶體的引用進行操作。這樣能在一些場景顯著提高效能,因為避免了在 Java 堆和 Native 堆中來回複製資料。直接記憶體不受 Java 堆大小的限制。

二. 物件的建立、記憶體佈局及訪問定位

前面介紹了 Java 虛擬機器的執行時資料區,瞭解了虛擬機器記憶體的情況。接下來我們看看物件是如何建立的、物件的記憶體佈局是怎樣的以及物件在記憶體中是如何定位的。

2.1 物件的建立

要建立一個物件首先得在 Java 堆中(不絕對,後面介紹虛擬機器優化策略的時候會做詳細介紹)為這個要建立的物件分配記憶體,分配記憶體的過程要保證併發安全,最後再對記憶體進行相應的初始化,這一系列的過程完成後,一個真正的物件就被建立了。

記憶體分配

先說說記憶體分配,當虛擬機器遇到一條 new 指令時,首先將去檢查這個指令的引數是否能夠在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被載入、解析和初始化。如果沒有,那必須先執行相應的類載入過程。在類載入檢查通過後,虛擬機器將為新生物件分配記憶體。物件所需的記憶體大小在類載入完成後便可完全確定,為物件分配記憶體空間的任務等同於把一塊確定大小的記憶體從 Java 堆中劃分出來。

深入理解Java虛擬機器(自動記憶體管理機制)

在 Java 堆中劃分記憶體涉及到兩個概念:指標碰撞(Bump the Pointer)空閒列表(Free List)

  • 如果 Java 堆中的記憶體絕對規整,所有用過的記憶體都放在一邊,空閒的記憶體放在另一邊,中間放著一個指標作為分界點的指示器,那所分配的記憶體就緊緊是把指標往空閒空間那邊挪動一段與物件大小相等的距離,這種分配方式稱為**「指標碰撞」**。

  • 如果 Java 堆中的記憶體並不是規整的,已使用的記憶體和空閒的記憶體相互交錯,那就沒辦法簡單的進行指標碰撞了。虛擬機器必須維護一個列表來記錄哪些記憶體是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄,這種分配方式稱為**「空閒列表」**。

選擇哪種分配方式是由 Java 堆是否規整來決定的,而 Java 堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。

記憶體分配的兩種方式

保證併發安全

物件的建立在虛擬機器中是一個非常頻繁的行為,哪怕只是修改一個指標所指向的位置,在併發情況下也是不安全的,可能出現正在給物件 A 分配記憶體,指標還沒來得及修改,物件 B 又同時使用了原來的指標來分配記憶體的情況。解決這個問題有兩種方案:

  • 對分配記憶體空間的動作進行同步處理(採用 CAS + 失敗重試來保障更新操作的原子性);

  • 把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在 Java 堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝(Thread Local Allocation Buffer, TLAB)。哪個執行緒要分配記憶體,就在哪個執行緒的 TLAB 上分配。只有 TLAB 用完並分配新的 TLAB 時,才需要同步鎖。

記憶體分配時保證執行緒安全的兩種方式

初始化

記憶體分配完後,虛擬機器要將分配到的記憶體空間初始化為零值(不包括物件頭),如果使用了 TLAB,這一步會提前到 TLAB 分配時進行。這一步保證了物件的例項欄位在 Java 程式碼中可以不賦初始值就直接使用。

接下來設定物件頭(Object Header)資訊,包括物件是哪個類的例項、如何找到類的後設資料、物件的 Hash、物件的 GC 分代年齡等。

這一系列動作完成之後,緊接著會執行 方法,把物件按照程式設計師的意圖進行初始化,這樣一個真正意義上的物件就產生了。

JVM 中物件的建立過程大致如下圖:

深入理解Java虛擬機器(自動記憶體管理機制)

2.2 物件的記憶體佈局

在 HotSpot 虛擬機器中,物件在記憶體中的佈局可以分為 3 塊:物件頭(Header)例項資料(Instance Data)對齊填充(Padding)

物件頭

物件頭包含兩部分資訊,第一部分用於儲存物件自身的執行時資料,比如雜湊碼(HashCode)、GC 分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳等。這部分資料稱之為 Mark Word。物件頭的另一部分是型別指標,即物件指向它的類後設資料指標,虛擬機器通過它來確定物件是哪個類的例項;如果是陣列,物件頭中還必須有一塊用於記錄陣列長度的資料。(並不是所有所有虛擬機器的實現都必須在物件資料上保留型別指標,在下一小節介紹「物件的訪問定位」的時候再做詳細說明)。

例項資料

物件真正儲存的有效資料,也是在程式程式碼中所定義的各種欄位內容。

對齊填充

無特殊含義,不是必須存在的,僅作為佔位符。

2.3 物件的訪問定位

Java 程式需要通過棧上的 reference 資訊來操作堆上的具體物件。根據不同的虛擬機器實現,主流的訪問物件的方式主要有控制程式碼訪問直接指標兩種。

控制程式碼訪問

Java 堆中劃分出一塊記憶體來作為控制程式碼池,reference 中儲存的就是物件的控制程式碼地址,而控制程式碼中包含了物件例項資料與型別資料各自的具體地址資訊。

深入理解Java虛擬機器(自動記憶體管理機制)

使用控制程式碼訪問的好處就是 reference 中儲存的是穩定的控制程式碼地址,在物件被移動時只需要改變控制程式碼中例項資料的指標,而 reference 本身不需要修改。

直接指標

在物件頭中儲存型別資料相關資訊,reference 中儲存的物件地址。

深入理解Java虛擬機器(自動記憶體管理機制)

使用直接指標訪問的好處是速度更快,它節省了一次指標定位的開銷。由於物件訪問在 Java 中非常頻繁,因此這類開銷積少成多也是一項非常可觀的執行成本。HotSpot 中採用的就是這種方式。

三. 垃圾回收器與記憶體分配策略

在前面我們介紹 JVM 執行時資料區的時候說過,程式計數器、虛擬機器棧、本地方法棧 3 個區域隨執行緒而生,隨執行緒而死;棧中的棧幀隨著方法的進入和退出而有條不紊的執行著入棧和出棧的操作。每一個棧幀中分配多少記憶體基本上在資料結構確定下來的時候就已經知道了,因此這幾個區域記憶體的分配和回收是具有確定性的,所以不用過度考慮記憶體回收的問題,因為在方法結束或者執行緒結束時,記憶體就跟著回收了。

而 Java 堆和方法區則不一樣,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式執行期才能知道會建立哪些物件,這部分記憶體的分配和回收是動態的,垃圾收集器要關注的就是這部分記憶體。

3.1 物件回收的判定規則

垃圾收集器在做垃圾回收的時候,首先需要判定的就是哪些記憶體是需要被回收的,哪些物件是「存活」的,是不可以被回收的;哪些物件已經「死掉」了,需要被回收。

引用計數法

判斷物件存活與否的一種方式是「引用計數」,即物件被引用一次,計數器就加 1,如果計數器為 0 則判斷這個物件可以被回收。但是引用計數法有一個很致命的缺陷就是它無法解決迴圈依賴的問題,因此現在主流的虛擬機器基本不會採用這種方式。

可達性分析演算法

可達性分析演算法又叫根搜尋演算法,該演算法的基本思想就是通過一系列稱為「GC Roots」的物件作為起始點,從這些起始點開始往下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到 GC Roots 物件之間沒有任何引用鏈的時候(不可達),證明該物件是不可用的,於是就會被判定為可回收物件。

深入理解Java虛擬機器(自動記憶體管理機制)

在 Java 中可作為 GC Roots 的物件包含以下幾種:

  • 虛擬機器棧(棧幀中的本地變數表)中引用的物件;
  • 方法區中類靜態屬性引用的物件;
  • 方法區中常量引用的物件;
  • 本地方法棧中 JNI(Native 方法)引用的物件。

Java 中是四種引用型別

無論是通過引用計數器還是通過可達性分析來判斷物件是否可以被回收都設計到「引用」的概念。在 Java 中,根據引用關係的強弱不一樣,將引用型別劃為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)和虛引用(Phantom Reference)。

強引用Object obj = new Object()這種方式就是強引用,只要這種強引用存在,垃圾收集器就永遠不會回收被引用的物件。

軟引用:用來描述一些有用但非必須的物件。在 OOM 之前垃圾收集器會把這些被軟引用的物件列入回收範圍進行二次回收。如果本次回收之後還是記憶體不足才會觸發 OOM。在 Java 中使用 SoftReference 類來實現軟引用。

弱引用:同軟引用一樣也是用來描述非必須物件的,但是它的強度比軟引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件。在 Java 中使用 WeakReference 類來實現。

虛引用:是最弱的一種引用關係,一個物件是否有虛引用的存在完全不影響物件的生存時間,也無法通過虛引用來獲取一個物件的例項。一個物件使用虛引用的唯一目的是為了在被垃圾收集器回收時收到一個系統通知。在 Java 中使用 PhantomReference 類來實現。

生存還是死亡,這是一個問題

在可達性分析中判定為不可達的物件,也不一定就是「非死不可的」。這時它們處於「緩刑」階段,真正要宣告一個物件死亡,至少需要經歷兩次標記過程:

第一次標記:如果物件在進行可達性分析後被判定為不可達物件,那麼它將被第一次標記並且進行一次篩選。篩選的條件是此物件是否有必要執行 finalize() 方法。物件沒有覆蓋 finalize() 方法或者該物件的 finalize() 方法曾經被虛擬機器呼叫過,則判定為沒必要執行。

第二次標記:如果被判定為有必要執行 finalize() 方法,那麼這個物件會被放置到一個 F-Queue 佇列中,並在稍後由虛擬機器自動建立的、低優先順序的 Finalizer 執行緒去執行該物件的 finalize() 方法。但是虛擬機器並不承諾會等待該方法結束,這樣做是因為,如果一個物件的 finalize() 方法比較耗時或者發生了死迴圈,就可能導致 F-Queue 佇列中的其他物件永遠處於等待狀態,甚至導致整個記憶體回收系統崩潰。finalize() 方法是物件逃脫死亡命運的最後一次機會,如果物件要在 finalize() 中挽救自己,只要重新與 GC Roots 引用鏈關聯上就可以了。這樣在第二次標記時它將被移除「即將回收」的集合,如果物件在這個時候還沒有逃脫,那麼它基本上就真的被回收了。

方法區回收

前面介紹過,方法區在 HotSpot 虛擬機器中被劃分為永久代。在 Java 虛擬機器規範中沒有要求方法區實現垃圾收集,而且方法區垃圾收集的價效比也很低。

方法區(永久代)的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。

廢棄常量的回收和 Java 堆中物件的回收非常類似,這裡就不做過多的解釋了。

類的回收條件就比較苛刻了。要判定一個類是否可以被回收,要滿足以下三個條件:

  1. 該類的所有例項已經被回收;
  2. 載入該類的 ClassLoader 已經被回收;
  3. 該類的 Class 物件沒有被引用,無法再任何地方通過反射訪問該類的方法。

3.2 垃圾回收演算法

標記-清除演算法

正如標記-清除的演算法名一樣,該演算法分為「標記」和「清除」兩個階段:

首先標記出所有需要回收的物件,在標記完成後回收所有被標記的物件。標記-清除演算法是一種最基礎的演算法,後續其它演算法都是在它的基礎上基於不足之處改進而來的。它的不足體現在兩方面:一是效率問題,標記和清除的效率都不高;二是空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後程式的執行過程中又要分配較大物件是,無法找打足夠的連續記憶體而不得不提前出發下一次 GC。

深入理解Java虛擬機器(自動記憶體管理機制)

複製演算法

為了解決效率問題,於是就有了複製演算法,它將記憶體一分為二劃分為大小相等的兩塊記憶體區域。每次只使用其中的一塊。當這一塊用完時,就將還存活的物件複製到另一塊上面,然後再把已使用過的記憶體空間一次清理掉。這樣做的好處是不用考慮記憶體碎片問題了,簡單高效。只不過這種演算法代價也很高,記憶體因此縮小了一半。

深入理解Java虛擬機器(自動記憶體管理機制)

現在的商業虛擬機器都採用這種演算法來回收新生代,在 IBM 的研究中新生代中的物件 98% 都是「朝生夕死」,所以並不需要按照 1:1 的比例來劃分空間,而是將記憶體分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor。當回收時,將 Eden 和 Survivor 中還存活的物件一次性複製到另一塊 Survivor 空間上,最後清理掉 Eden 和剛才用過的 Survivor 空間。 HotSpot 預設 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用的記憶體為整個新生代容量的 90%(80%+10%),只有 10% 會被浪費。當然,98% 的物件可回收只是一般場景下的資料,我們沒辦法保證每次回收後都只有不多於 10% 的物件存活,當 Survivor 空間不夠用時,需要依賴其它記憶體(這裡指老年代)進行分配擔保。如果另外一塊 Survivor 空間沒有足夠空間存放上一次新生代收集下來存活的物件時,這些物件將直接通過分配擔保機制進入老年代。

標記-整理演算法

通過前面對複製-收集演算法的介紹我們知道,其對老年代這種物件存活時間長的記憶體區域就不適用了,而標記整理的演算法就比較適用這一場景。

標記-整理演算法的標記過程與「標記-清除」演算法一樣,但是後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

深入理解Java虛擬機器(自動記憶體管理機制)

分代回收演算法

當前商業虛擬機器的垃圾蒐集都採用「分代回收」演算法,這種演算法並沒有什麼新的思想,只是根據物件存活週期的不同將記憶體劃分為幾塊。一般是將 Java 堆分為新生代和老年代,這樣可以根據各個年代的特點採用最合適的蒐集演算法。

在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。

而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用「標記-清除」或者「標記-整理」演算法來進行回收。

3.3 記憶體分配與回收策略

所謂自動記憶體管理,最終要解決的也就是記憶體分配和記憶體回收兩個問題。前面我們介紹了記憶體回收,這裡我們再來聊聊記憶體分配。

物件的記憶體分配通常是在 Java 堆上分配(隨著虛擬機器優化技術的誕生,某些場景下也會在棧上分配,後面會詳細介紹),物件主要分配在新生代的 Eden 區,如果啟動了本地執行緒緩衝,將按照執行緒優先在 TLAB 上分配。少數情況下也會直接在老年代上分配。總的來說分配規則不是百分百固定的,其細節取決於哪一種垃圾收集器組合以及虛擬機器相關引數有關,但是虛擬機器對於記憶體的分配還是會遵循以下幾種「普世」規則:

物件優先在 Eden 區分配

多數情況,物件都在新生代 Eden 區分配。當 Eden 區分配沒有足夠的空間進行分配時,虛擬機器將會發起一次 Minor GC。如果本次 GC 後還是沒有足夠的空間,則將啟用分配擔保機制在老年代中分配記憶體。

這裡我們提到 Minor GC,如果你仔細觀察過 GC 日常,通常我們還能從日誌中發現 Major GC/Full GC。

  • Minor GC 是指發生在新生代的 GC,因為 Java 物件大多都是朝生夕死,所有 Minor GC 非常頻繁,一般回收速度也非常快;

  • Major GC/Full GC 是指發生在老年代的 GC,出現了 Major GC 通常會伴隨至少一次 Minor GC。Major GC 的速度通常會比 Minor GC 慢 10 倍以上。

大物件直接進入老年代

所謂大物件是指需要大量連續記憶體空間的物件,頻繁出現大物件是致命的,會導致在記憶體還有不少空間的情況下提前觸發 GC 以獲取足夠的連續空間來安置新物件。

前面我們介紹過新生代使用的是標記-清除演算法來處理垃圾回收的,如果大物件直接在新生代分配就會導致 Eden 區和兩個 Survivor 區之間發生大量的記憶體複製。因此對於大物件都會直接在老年代進行分配。

長期存活物件將進入老年代

虛擬機器採用分代收集的思想來管理記憶體,那麼記憶體回收時就必須判斷哪些物件應該放在新生代,哪些物件應該放在老年代。因此虛擬機器給每個物件定義了一個物件年齡的計數器,如果物件在 Eden 區出生,並且能夠被 Survivor 容納,將被移動到 Survivor 空間中,這時設定物件年齡為 1。物件在 Survivor 區中每「熬過」一次 Minor GC 年齡就加 1,當年齡達到一定程度(預設 15) 就會被晉升到老年代。

動態物件年齡判斷

為了更好的適應不同程式的記憶體情況,虛擬機器並不是永遠要求物件的年齡必需達到某個固定的值(比如前面說的 15)才會被晉升到老年代,而是會去動態的判斷物件年齡。如果在 Survivor 區中相同年齡所有物件大小的總和大於 Survivor 空間的一半,年齡大於等於該年齡的物件就可以直接進入老年代。

空間分配擔保

在新生代觸發 Minor GC 後,如果 Survivor 中任然有大量的物件存活就需要老年隊來進行分配擔保,讓 Survivor 區中無法容納的物件直接進入到老年代。

寫在最後

對於我們 Java 程式設計師來說,虛擬機器的自動記憶體管理機制為我們在編碼過程中帶來了極大的便利,不用像 C/C++ 等語言的開發者一樣小心翼翼的去管理每一個物件的生命週期。但同時我們也喪失了記憶體控制的管理許可權,一旦發生記憶體洩漏如果不瞭解虛擬機器的記憶體管理原理,就很排查問題。希望這篇文章能對大家理解 Java 虛擬機器的記憶體管理機制有所幫助。如果想對 Java 虛擬機器有更進一步的瞭解,推薦大家去讀周志明老師的《深入理解 Java 虛擬機器:JVM 高階特性與最佳實踐》這本書。

好了,關於 Java 虛擬機器的自動記憶體管理機制就介紹到這裡,下一篇我們來聊聊「類檔案結構」。

參考資料:

  • 《深入理解 Java 虛擬機器:JVM 高階特性與最佳實踐(第 2 版)》

如果你喜歡我的文章,就關注下我的公眾號 BaronTalk知乎專欄 或者在 GitHub 上添個 Star 吧!

深入理解Java虛擬機器(自動記憶體管理機制)

相關文章