從JAVA記憶體到垃圾回收,帶你深入理解JVM

華為雲開發者社群 發表於 2021-01-26
摘要:學過Java的程式設計師對JVM應該並不陌生,如果你沒有聽過,沒關係今天我帶你走進JVM的世界。程式設計師為什麼要學習JVM呢,其實不懂JVM也可以照樣寫出優質的程式碼,但是不懂JVM有可能別被面試官虐得體無完膚。

§ 1.JAVA記憶體區域與記憶體溢位異常

§ 1.1執行時資料區域

從JAVA記憶體到垃圾回收,帶你深入理解JVM

§ 1.1.1 程式計數器

當前執行緒所執行的位元組碼的行號指示器,是程式控制流的指示器,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴程式計數器。記憶體較小。

Java 虛擬機器的多執行緒是通過執行緒輪流切換,分配處理器時間的方式來實現的,所以在任何一個確定的時刻,一個處理器(即多處理器的一個核心)都只會執行一條執行緒中的指令。因此,為了執行緒切換後,能恢復到正確的執行位置,每條執行緒都需要一個獨立的程式計數器,各個執行緒之間不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”。

此記憶體區域是唯一一個在《java虛擬機器規範》中沒有規定任何 OOM 情況的區域。

§ 1.1.2 java虛擬機器棧

執行緒私有,Java虛擬機器棧的生命週期與執行緒相同。

Java虛擬機器棧描述的是Java方法執行的執行緒記憶體模型:每個方法被執行的時候,Java虛擬機器都會同步建立一個棧幀用於儲存 區域性變數表、運算元棧、動態連結、方法出口等資訊。每個方法被呼叫直至執行完畢的過程,對應了棧幀在虛擬機器棧中入棧到出棧的過程。

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

Java 虛擬機器棧會出現兩種錯誤:StackOverFlowError 和 OutOfMemoryError。
• StackOverflowError: 若 Java 虛擬機器棧的記憶體大小不允許動態擴充套件,那麼當執行緒請求棧的深度超過當前 Java 虛擬機器棧的最大深度的時候,就丟擲 StackOverFlowError 錯誤。

OutOfMemoryError: 若 Java 虛擬機器棧的記憶體大小允許動態擴充套件,且當執行緒請求棧時記憶體用完了,無法再動態擴充套件了,此時丟擲 OutOfMemoryError 錯誤。Java 方法有兩種返回方式:return 語句;丟擲異常,不管哪種返回方式都會導致棧幀被彈出。

引數-Xss

§ 1.1.3 本地方法棧

和虛擬機器棧所發揮的作用非常相似,區別是: 虛擬機器棧為虛擬機器執行 Java 方法 (也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的 Native 方法服務。 在 HotSpot 虛擬機器中和 Java 虛擬機器棧合二為一。和虛擬機器棧一樣會產生StackOverFlowError 和 OutOfMemoryError。

§ 1.1.4 java堆

Java 虛擬機器所管理的記憶體中最大的一塊,Java 堆是所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項以及陣列都在這裡分配記憶體。

Java世界中“幾乎”所有的物件都在堆中分配,但是,隨著JIT編譯期的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化,所有的物件都分配到堆上也漸漸變得不那麼“絕對”了。從jdk 1.7開始已經預設開啟逃逸分析,如果某些方法中的物件引用沒有被返回或者未被外面使用(也就是未逃逸出去),那麼物件可以直接在棧上分配記憶體。

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

在 JDK 7 版本及JDK 7 版本之前,堆記憶體被通常被分為下面三部分:

  1. 新生代記憶體(Young Generation)
  2. 老生代(Old Generation)
  3. 永生代(Permanent Generation)

堆這裡最容易出現的就是 OutOfMemoryError 錯誤,比如:

  1. OutOfMemoryError: GC Overhead Limit Exceeded : 當JVM花太多時間執行垃圾回收並且只能回收很少的堆空間時,就會發生此錯誤。
  2. java.lang.OutOfMemoryError: Java heap space :假如在建立新的物件時, 堆記憶體中的空間不足以存放新建立的物件, 就會引發java.lang.OutOfMemoryError: Java heap space 錯誤。

§ 1.1.5 方法區

執行緒共享,用於儲存已被虛擬機器載入的型別資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等資料。雖然 Java 虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。

方法區和永久代的關係

《Java 虛擬機器規範》只是規定了有方法區這個概念和它的作用,並沒有規定如何去實現它。那麼,在不同的 JVM 上方法區的實現肯定是不同的了。 方法區和永久代的關係很像 Java 中介面和類的關係,類實現了介面,而永久代就是 HotSpot 虛擬機器對虛擬機器規範中方法區的一種實現方式,當時的HotSpot 虛擬機器設計團隊選擇把收集器的分代設計擴充套件到方法區。 也就是說,永久代是 HotSpot 的概念,方法區是 Java 虛擬機器規範中的定義,是一種規範,而永久代是一種實現,一個是標準一個是實現,其他的虛擬機器實現並沒有永久代這一說法。

為什麼要將永久代 (PermGen) 替換為元空間 (MetaSpace) 呢?

  1. 整個永久代有一個 JVM 本身設定固定大小上限,無法進行調整,而元空間使用的是直接記憶體,受本機可用記憶體的限制,雖然元空間仍舊可能溢位,但是比原來出現的機率會更小。
    當元空間溢位時會得到如下錯誤: java.lang.OutOfMemoryError: MetaSpace
    你可以使用 -XX:MaxMetaspaceSize 標誌設定最大元空間大小,預設值為 unlimited,這意味著它只受系統記憶體的限制。-XX:MetaspaceSize 調整標誌定義元空間的初始大小如果未指定此標誌,則 Metaspace 將根據執行時的應用程式需求動態地重新調整大小。
  2. 元空間裡面存放的是類的後設資料,這樣載入多少類的後設資料就不由 MaxPermSize 控制了, 而由系統的實際可用空間來控制,這樣能載入的類就更多了。
  3. 在 JDK8,合併 HotSpot 和 JRockit 的程式碼時, JRockit 從來沒有一個叫永久代的概念, 合併之後就沒有必要額外的設定這麼一個永久代的地方了。

方法區的發展遷移過程

JDK 6 時,HotSpot 團隊就有放棄永久代、逐步改為本地記憶體來實現方法區的計劃了。JDK 7 ,已經把原本放在永久代的字串常量池、靜態變數等移除。JDK8,終於完全放棄了永久代,把JDK 7 中永久代還剩餘的內容(主要是型別資訊)全部移到元空間中。
根據《Java虛擬機器規範》,如果方法區無法滿足新的記憶體分配需求時,將丟擲 OOM 異常。

§ 1.1.6 執行時常量池

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

既然執行時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會丟擲 OutOfMemoryError 錯誤。

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

  1. JDK1.7之前執行時常量池邏輯包含字串常量池存放在方法區, 此時hotspot虛擬機器對方法區的實現為永久代
  2. JDK1.7 字串常量池被從方法區拿到了堆中, 這裡沒有提到執行時常量池,也就是說字串常量池被單獨拿到堆,執行時常量池剩下的東西還在方法區, 也就是hotspot中的永久代 。
  3. JDK1.8 hotspot移除了永久代用元空間(Metaspace)取而代之, 這時候字串常量池還在堆, 執行時常量池還在方法區, 只不過方法區的實現從永久代變成了元空間(Metaspace)

§ 1.1.7 直接記憶體

直接記憶體並不是虛擬機器執行時資料區的一部分,也不是虛擬機器規範中定義的記憶體區域,但是這部分記憶體也被頻繁地使用。而且也可能導致 OutOfMemoryError 錯誤出現。JDK1.4 中新加入的 NIO(New Input/Output) 類,引入了一種基於通道(Channel) 與快取區(Buffer) 的 I/O 方式,它可以直接使用 Native 函式庫直接分配堆外記憶體,然後通過一個儲存在 Java 堆中的 DirectByteBuffer 物件作為這塊記憶體的引用進行操作。這樣就能在一些場景中顯著提高效能,因為避免了在 Java 堆和 Native 堆之間來回複製資料。

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

§ 2 垃圾回收

§ 2.1

虛擬機器如何判斷物件是否存活?

1.引用計數演算法

給物件中新增一個引用計數器,每當有一個地方引用它時,計數器就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的。

考慮一種情形:物件objA和objB都有欄位instance,賦值令objA.instance=objB和objB.instance=objA;除此之外,這兩個物件再無任何引用,實際上這兩個物件以及不可能再被訪問,但是它們因為互相引用著對方,導致它們的引用計數都不為0,於是引用計數演算法無法通知GC收集器回收它們。如果這個物件特別大,則會造成嚴重的記憶體洩露。

2.可達性分析演算法

基本思想:通過一系列的稱為”GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈時,則證明此物件是不可用的。

GC Roots的物件包括下面幾種:
• 虛擬機器棧(棧幀中的本地變數表)中引用的物件。
• 方法區中類靜態屬性引用的物件。
• 方法區中常量引用的物件。
• 本地方法棧中JNI引用的物件。

3.垃圾收集演算法

1.標記-清除演算法(Mark-Sweep)

從JAVA記憶體到垃圾回收,帶你深入理解JVM

最基礎的收集演算法,分為”標記”和”清除”兩個階段:首先標記處所有需要回收的物件,在標記完成後統一回收所有被標記的物件。

主要不足有兩個:一是效率問題,標記和清除兩個過程的效率都不高;另一個是空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片大多可能會導致以後再程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前出發另一次垃圾收集動作。

2.複製演算法(Copying)

從JAVA記憶體到垃圾回收,帶你深入理解JVM

為了解決效率問題,一種稱為”複製“的收集演算法出現,它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指正按順序分配記憶體即可,實現簡單,執行高效。只是這種演算法的代價是將記憶體縮小為原來的一般,未免太高了一點。

現代的商業虛擬機器都採用這種收集演算法來回收新生代,IBM公司研究表明,新生代的物件98%都是”朝生夕死“的,所以並不需要1:1的比例來劃分記憶體空間,而是將記憶體劃分為一塊較大的Eden空間和兩塊較小的Survivor空間。HotSpot預設Eden和Survivor的大小比例是8:1.如果Survivor空間不夠用時,需要依賴其他記憶體(老年代)進行分配擔保(Handle Promotion)。

3.標記-整理演算法(Mark-Compact)

從JAVA記憶體到垃圾回收,帶你深入理解JVM

複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。根據老年代的特點,提出此種演算法,標記過程仍然與”標記-清除“演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界意外的記憶體。

4.分代收集算(Generational Collection)

當前商業虛擬機器的垃圾收集都採用”分代收集“演算法。一般是把java堆分成新生代和老年代。在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用”標記——清理“或者”標記——整理“演算法來進行回收。

垃圾收集器

  • Serial收集器

這是一個單執行緒的收集器,但它的“單執行緒”的意義並不僅僅說明它只會使用一個CPU或一條手機執行緒去完成垃圾手機工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作執行緒,直到它收集結束。“Stop the world”,由虛擬機器在後臺自動發起和自動完成的,在使用者不可見的情況下把使用者正常工作的執行緒全部停掉,這對很多應用來說都是難以接受的。它是虛擬機器執行在Client模式下的預設新生代收集器。

優點:簡單而高效(與其他收集器的單執行緒比),對於限定單個CPU的環境來說,Serial收集器由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得最高的單執行緒收集效率。

  • ParNew收集器

ParNew收集器其實就是serial收集器的多執行緒版本,除了使用多條執行緒進行垃圾收集之外,其餘行為與Serial收集器一樣。ParNew收集器也是使用-XX:+UseConcMarkSweepGC選項後的預設新生代收集器,也可以使用-XX:+UseParNewGC選項來強制指定它。

  • Parallel Scavenge收集器

Parallel Scavenge收集器也是一個新生代收集器,它也是使用複製演算法的收集器,又是並行多執行緒收集器。parallel Scavenge收集器的特點是它的關注點與其他收集器不同,CMS等收集器的關注點是儘可能地縮短垃圾收集時使用者執行緒的停頓時間,而parallel Scavenge收集器的目標則是達到一個可控制的吞吐量。吞吐量= 程式執行時間/(程式執行時間 + 垃圾收集時間),虛擬機器總共執行了100分鐘。其中垃圾收集花掉1分鐘,那吞吐量就是99%。

Parallel Scavenge收集器提供了兩個引數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis引數以及直接設定吞吐量大小的-XX:GCTimeRatio引數。

  • Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同樣使用一個單執行緒執行收集,使用“標記-整理”演算法。主要使用在Client模式下的虛擬機器。

  • Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多執行緒和“標記-整理”演算法。

  • CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。CMS收集器是基於“標記-清除”演算法實現的,整個收集過程大致分為4個步驟:

• 初始標記(CMS initial mark)
• 併發標記(CMS concurrenr mark)
• 重新標記(CMS remark)
• 併發清除(CMS concurrent sweep)

其中初始標記、重新標記這兩個步驟任然需要停頓其他使用者執行緒。初始標記僅僅只是標記出GC ROOTS能直接關聯到的物件,速度很快,併發標記階段是進行GC ROOTS 根搜尋演算法階段,會判定物件是否存活。而重新標記階段則是為了修正併發標記期間,因使用者程式繼續執行而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間會被初始標記階段稍長,但比並發標記階段要短。

由於整個過程中耗時最長的併發標記和併發清除過程中,收集器執行緒都可以與使用者執行緒一起工作,所以整體來說,CMS收集器的記憶體回收過程是與使用者執行緒一起併發執行的。

CMS收集器的優點:併發收集、低停頓,但是CMS還遠遠達不到完美,器主要有三個顯著缺點:

(1)CMS收集器對CPU資源非常敏感。在併發階段,雖然不會導致使用者執行緒停頓,但是會佔用CPU資源而導致引用程式變慢,總吞吐量下降。CMS預設啟動的回收執行緒數是:(CPU數量+3) / 4。

(2)CMS收集器無法處理浮動垃圾,可能出現“Concurrent Mode Failure“,失敗後而導致另一次Full GC的產生。由於CMS併發清理階段使用者執行緒還在執行,伴隨程式的執行自熱會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之後,CMS無法在本次收集中處理它們,只好留待下一次GC時將其清理掉。這一部分垃圾稱為“浮動垃圾”。也是由於在垃圾收集階段使用者執行緒還需要執行,即需要預留足夠的記憶體空間給使用者執行緒使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分記憶體空間提供併發收集時的程式運作使用。

在預設設定下,CMS收集器在老年代使用了68%的空間時就會被啟用,也可以通過引數-XX:CMSInitiatingOccupancyFraction的值來提供觸發百分比,以降低記憶體回收次數提高效能。要是CMS執行期間預留的記憶體無法滿足程式其他執行緒需要,就會出現“Concurrent Mode Failure”失敗,這時候虛擬機器將啟動後備預案:臨時啟用Serial Old收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了。所以說引數-XX:CMSInitiatingOccupancyFraction設定的過高將會很容易導致“Concurrent Mode Failure”失敗,效能反而降低。

(3)最後一個缺點,CMS是基於“標記-清除”演算法實現的收集器,使用“標記-清除”演算法收集後,會產生大量碎片。空間碎片太多時,將會給物件分配帶來很多麻煩,比如說大物件,記憶體空間找不到連續的空間來分配不得不提前觸發一次Full GC。為了解決這個問題,CMS收集器提供了一個-XX:UseCMSCompactAtFullCollection開關引數,用於在Full GC之後增加一個碎片整理過程,還可通過-XX:CMSFullGCBeforeCompaction引數設定執行多少次不壓縮的Full GC之後,跟著來一次碎片整理過程。

  • G1收集器(Garbage-First)

G1是一款面向伺服器應用垃圾收集器,與其他GC收集器想必,G1具備以下特點:

• 並行與併發:G1能充分利用多CPU、多核環境下的硬體優勢,使用多個CPU來縮短Stop-The-World停頓的時間,部分其他收集器原本需要停頓Java執行緒執行的GC動作,G1收集器仍然可以通過併發的方式讓Java程式繼續執行。

• 分代收集:與其他收集器一樣,分代概念在G1中依然得以保留。雖然G1可以不要其他收集器配合就能獨立管理整個GC堆,但它能夠採用不同的方式去處理新建立的物件和已經存活了一半時間、熬過多次GC的舊物件以獲取更好的收集效果。

• 空間整合:與CMS的“標記-清理”演算法不同,G1從整體上看是基於“標記-整理”演算法實現的收集器,從區域性(兩個Region之間)上來看是基於“複製”演算法實現,無論如何,這兩種演算法都意味著G1執行期間不會產生記憶體空間碎片,收集後能提供規整的可用記憶體。

• 可預測的停頓:這是G1相對於CMS的另一個大優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,小號在垃圾收集上的時間不能超過N毫秒,這幾乎已經是實時Java(RTSJ)的垃圾收集器的特徵了。

在G1收集器中,Region之間的物件引用以及其他收集器中的新生代與老年代之間的物件引用,虛擬機器都是使用Remembered Set來避免全堆掃描的。G1中每個Region都有一個與之對應的Remebered Set,虛擬機器發現程式在對Reference型別的資料進行寫操作時,會產生一個Write Barrier暫時中斷寫操作,檢查Reference引用的物件是否處於不同的Region之中(在分代的例子中,就是檢查是否老年代中的讀寫引用了新生代中的物件)。如果是,便通過CardTable把相關引用資訊記錄到被引用物件所屬的Region的Remembered Set之中。當進行記憶體回收時,在GC根節點的列舉範圍中加入Rememered Set即可保證不對全隊掃描也不會有遺漏。

如果不計算維護Remembered Set的操作,G1收集器的運作大致可劃分為以下幾個步驟:
• 初始標記
• 併發標記
• 最終標記
• 篩選標記

本文分享自華為雲社群《深入理解JVM閱讀筆記一》,原文作者:ayin 。

 

點選關注,第一時間瞭解華為雲新鮮技術~