Java記憶體模型

monkeysayhi發表於2017-10-08

面試中問到“記憶體模型”,通常是考察Java記憶體結構和GC而不是Happens-Before等更深入、細緻的內容。記憶體模型是考察coder對一門語言的理解能力,從而進一步延伸到對JVM優化,和平時學習的深度上,是Java面試中最重要的一部分。這裡整理了記憶體結構和GC的知識點,Happens-Before模型預計在以後學習過JVM過再來整理。

如果把記憶體模型看做一個資料結構,那麼面試中考察的重點分為記憶體結構和GC,不過有時候會單獨問到GC,另外大問題分解為小問題也方便理解。

記憶體結構

記憶體結構簡介

JVM的記憶體結構大概分為:

  1. 堆(heap):執行緒共享,所有的物件例項以及陣列都要在堆上分配。回收器主要管理的物件。
  2. 方法區(MEATHOD AREA):執行緒共享,儲存類資訊、常量、靜態變數、即時編譯器編譯後的程式碼。
  3. 方法棧(JVM Stack):執行緒私有、儲存區域性變數表、操作棧、動態連結、方法出口,物件指標。
  4. 本地方法棧(NATIVE METHOD STACK):執行緒私有。為虛擬機器使用到的Native 方法服務。如Java使用c或者c++編寫的介面服務時,程式碼在此區執行。
  5. PC暫存器(PC Register):執行緒私有。指向下一條要執行的指令。

image.png
image.png

各區域詳細說明

在Java的記憶體結構中,我們重點關注的是堆和方法區。

image.png
image.png

堆的作用是存放物件例項和陣列。從結構上來分,可以分為新生代和老生代。而新生代又可以分為Eden 空間、From Survivor 空間(s0)、To Survivor 空間(s1)。 所有新生成的物件首先都是放在年輕代的。需要注意,Survivor的兩個區是對稱的,沒先後關係,所以同一個區中可能同時存在從Eden複製過來 物件,和從前一個Survivor複製過來的物件,而複製到年老區的只有從第一個Survivor去過來的物件。而且,Survivor區總有一個是空的。

控制引數

-Xms設定堆的最小空間大小。-Xmx設定堆的最大空間大小。-XX:NewSize設定新生代最小空間大小。-XX:MaxNewSize設定新生代最小空間大小。

垃圾回收

此區域是垃圾回收的主要操作區域。

異常情況

如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError 異常。

方法區

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

很多人願意把方法區稱為“永久代”(Permanent Generation),本質上兩者並不等價,僅僅是因為HotSpot虛擬機器的設計團隊選擇把GC 分代收集擴充套件至方法區,或者說使用永久代來實現方法區而已。對於其他虛擬機器(如BEA JRockit、IBM J9 等)來說是不存在永久代的概念的。在Java8中永生代徹底消失了。

控制引數

-XX:PermSize 設定最小空間 -XX:MaxPermSize 設定最大空間。

垃圾回收

對此區域會涉及但是很少進行垃圾回收。這個區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝,一般來說這個區域的回收“成績”比較難以令人滿意。

異常情況

根據Java 虛擬機器規範的規定, 當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError。

方法棧

每個執行緒會有一個私有的棧。每個執行緒中方法的呼叫又會在本棧中建立一個棧幀。在方法棧中會存放編譯期可知的各種基本資料型別(boolean、byte、char、short、int、float、long、double)、物件引用(reference 型別,它不等同於物件本身。區域性變數表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變數表的大小。

控制引數

-Xss控制每個執行緒棧的大小。

異常情況

在Java 虛擬機器規範中,對這個區域規定了兩種異常狀況:

  1. StackOverflowError: 異常執行緒請求的棧深度大於虛擬機器所允許的深度時丟擲;
  2. OutOfMemoryError 異常: 虛擬機器棧可以動態擴充套件,當擴充套件時無法申請到足夠的記憶體時會丟擲。

本地方法棧

本地方法棧(Native Method Stacks)與虛擬機器棧所發揮的作用是非常相似的,其
區別不過是虛擬機器棧為虛擬機器執行Java 方法(也就是位元組碼)服務,而本地方法棧則
是為虛擬機器使用到的Native 方法服務。

控制引數

在Sun JDK中本地方法棧和方法棧是同一個,因此也可以用-Xss控制每個執行緒的大小。

異常情況

與虛擬機器棧一樣,本地方法棧區域也會丟擲StackOverflowError 和OutOfMemoryError
異常。

PC計數器

它的作用可以看做是當前執行緒所執行的位元組碼的行號指示器。

異常情況

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

參考:
Java虛擬機器詳解02----JVM記憶體結構
JVM記憶體結構

GC

簡言之,Java程式記憶體主要(這裡強調主要二字)分兩部分,堆和非堆。大家一般new的物件和陣列都是在堆中的,而GC主要回收的記憶體也是這塊堆記憶體。

複習堆記憶體模型

既然重點是堆記憶體,我們就再看看堆的記憶體模型。

堆記憶體由垃圾回收器的自動記憶體管理系統回收。
堆記憶體分為兩大部分:新生代和老年代。比例為1:2。
老年代主要存放應用程式中生命週期長的存活物件。
新生代又分為三個部分:一個Eden區和兩個Survivor區,比例為8:1:1。
Eden區存放新生的物件。
Survivor存放每次垃圾回收後存活的物件。

image.png
image.png

關注這幾個問題:

  1. 為什麼要分新生代和老年代?
  2. 新生代為什麼分一個Eden區和兩個Survivor區?
  3. 一個Eden區和兩個Survivor區的比例為什麼是8:1:1?

這幾個問題都是垃圾回收機制所採用的演算法決定的。所以問題轉化為,是何種演算法?為什麼要採用此種演算法?

判定可回收物件

在進行垃圾回收之前,我們需要清除一個問題——什麼樣的物件是垃圾(無用物件),需要被回收?

目前最常見的有兩種演算法用來判定一個物件是否為垃圾。

引用計數演算法

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

image.png
image.png

優點是簡單,高效,現在的objective-c用的就是這種演算法。

缺點是很難處理迴圈引用,比如圖中相互引用的兩個物件則無法釋放。

這個缺點很致命,有人可能會問,那objective-c不是用的好好的嗎?我個人並沒有覺得objective-c好好的處理了這個迴圈引用問題,它其實是把這個問題拋給了開發者。

可達性分析演算法(根搜尋演算法)

為了解決上面的迴圈引用問題,Java採用了一種新的演算法:可達性分析演算法。

從GC Roots(每種具體實現對GC Roots有不同的定義)作為起點,向下搜尋它們引用的物件,可以生成一棵引用樹,樹的節點視為可達物件,反之視為不可達。

image.png
image.png

OK,即使迴圈引用了,只要沒有被GC Roots引用了依然會被回收,完美!

但是,這個GC Roots的定義就要考究了,Java語言定義瞭如下GC Roots物件:

  1. 虛擬機器棧(幀棧中的本地變數表)中引用的物件。
  2. 方法區中靜態屬性引用的物件。
  3. 方法區中常量引用的物件。
  4. 本地方法棧中JNI引用的物件。

Stop The World

有了上面的垃圾物件的判定,我們還要考慮一個問題,請大家做好心裡準備,那就是Stop The World。

因為垃圾回收的時候,需要整個的引用狀態保持不變,否則判定是判定垃圾,等我稍後回收的時候它又被引用了,這就全亂套了。所以,GC的時候,其他所有的程式執行處於暫停狀態,卡住了。

幸運的是,這個卡頓是非常短(尤其是新生代),對程式的影響微乎其微 (關於其他GC比如併發GC之類的,在此不討論)。

所以GC的卡頓問題由此而來,也是情有可原,暫時無可避免。

垃圾回收

已經知道哪些是垃圾物件了,怎麼回收呢?

目前主流有以下幾種演算法,目前JVM採用的是分代回收演算法,而分代回收演算法正是從這幾種演算法發展而來。

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

標記-清除演算法分為兩個階段:標記階段和清除階段。標記階段的任務是標記出所有需要被回收的物件,清除階段就是回收被標記的物件所佔用的空間。

image.png
image.png

優點:簡單實現。

缺點:容易產生記憶體碎片(碎片太多可能會導致後續過程中需要為大物件分配空間時無法找到足夠的空間而提前觸發新的一次垃圾收集動作)。

複製演算法 (Copying)

複製演算法將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用的記憶體空間一次清理掉,這樣一來就不容易出現記憶體碎片的問題。

image.png
image.png

優點:實現簡單,執行高效且不容易產生記憶體碎片。

缺點:對記憶體空間的使用做出了高昂的代價,因為能夠使用的記憶體縮減到原來的一半。

從演算法原理我們可以看出,複製演算法演算法的效率跟存活物件的數目多少有很大的關係,如果存活物件很多,那麼複製演算法演算法的效率將會大大降低

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

該演算法標記階段和Mark-Sweep一樣,但是在完成標記之後,它不是直接清理可回收物件,而是將存活物件都向一端移動,然後清理掉端邊界以外的記憶體。

image.png
image.png

優點:實現簡單,不容易產生記憶體碎片,記憶體使用高效。

缺點:效率非常低。

所以,特別適用於存活物件多,回收物件少的情況下

分代回收演算法

以上幾種演算法都有各自的優點和缺點,適用於不同的記憶體情景。而分代回收演算法根據Java的語言特性,將複製演算法和標記整理演算法的的特點相結合,針對不同的記憶體情景使用不同的回收演算法。

這裡重複一下兩種老演算法的適用場景:

複製演算法:適用於存活物件很少。回收物件多
標記整理演算法: 適用用於存活物件多,回收物件少

兩種演算法剛好互補,不同型別的物件生命週期決定了更適合採用哪種演算法。

於是,我們根據物件存活的生命週期將記憶體劃分為若干個不同的區域。一般情況下將堆區劃分為老年代(Old Generation)和新生代(Young Generation),老年代的特點是每次垃圾收集時只有少量物件需要被回收,使用標記整理演算法,而新生代的特點是每次垃圾回收時都有大量的物件需要被回收,複製演算法,那麼就可以根據不同代的特點採取最適合的收集演算法。

現在回頭去看堆記憶體為什麼要劃分新生代和老年代,是不是覺得如此的清晰和自然了?

具體來看:

  1. 對於新生代,雖然採取的是複製演算法,但是,實際中並不是按照上面演算法中說的1:1的比例來劃分新生代的空間,而是將新生代劃分為一塊較大的Eden空間和兩塊較小的Survivor空間,比例為8:1:1。為什麼?下一節深入分析。
  2. 老年代的特點是每次回收都只回收少量物件,這符合一個穩定系統的主要特徵——超過一半的物件會長期駐留在記憶體中。所以老年代的比例要大於新生代,預設的新生代:老年代的比例為1:2。

這就是分代回收演算法。

深入理解分代回收演算法

對於這個演算法,我相信很多人還是有疑問的,我們來各個擊破,說清楚了就很簡單。

為什麼不是一塊Survivor空間而是兩塊?

這裡涉及到一個新生代和老年代的存活週期的問題,比如一個物件在新生代經歷15次(僅供參考)GC,就可以移到老年代了。問題來了,當我們第一次GC的時候,我們可以把Eden區的存活物件放到Survivor A空間,但是第二次GC的時候,Survivor A空間的存活物件也需要再次用Copying演算法,放到Survivor B空間上,而把剛剛的Survivor A空間和Eden空間清除。第三次GC時,又把Survivor B空間的存活物件複製到Survivor A空間,如此反覆。

所以,這裡就需要兩塊Survivor空間來回倒騰。

為什麼Eden空間這麼大而Survivor空間要分的少一點?

新建立的物件都是放在Eden空間,這是很頻繁的,尤其是大量的區域性變數產生的臨時物件,這些物件絕大部分都應該馬上被回收,能存活下來被轉移到survivor空間的往往不多。所以,設定較大的Eden空間和較小的Survivor空間是合理的,大大提高了記憶體的使用率,緩解了Copying演算法的缺點。

我看8:1:1就挺好的,當然這個比例是可以調整的,包括上面的新生代和老年代的1:2的比例也是可以調整的。

新的問題又來了,從Eden空間往Survivor空間轉移的時候Survivor空間不夠了怎麼辦?直接放到老年代去。

Eden空間和兩塊Survivor空間的工作流程

這裡本來簡單的Copying演算法被劃分為三部分後很多朋友一時理解不了,也確實不好描述,下面我來演示一下Eden空間和兩塊Survivor空間的工作流程。

現在假定有新生代Eden,Survivor A, Survivor B三塊空間和老生代Old一塊空間。

// 分配了一個又一個物件
放到Eden區
// 不好,Eden區滿了,只能GC(新生代GC:Minor GC)了
把Eden區的存活物件copy到Survivor A區,然後清空Eden區(本來Survivor B區也需要清空的,不過本來就是空的)
// 又分配了一個又一個物件
放到Eden區
// 不好,Eden區又滿了,只能GC(新生代GC:Minor GC)了
把Eden區和Survivor A區的存活物件copy到Survivor B區,然後清空Eden區和Survivor A區
// 又分配了一個又一個物件
放到Eden區
// 不好,Eden區又滿了,只能GC(新生代GC:Minor GC)了
把Eden區和Survivor B區的存活物件copy到Survivor A區,然後清空Eden區和Survivor B區
// ...
// 有的物件來回在Survivor A區或者B區呆了比如15次,就被分配到老年代Old區
// 有的物件太大,超過了Eden區,直接被分配在Old區
// 有的存活物件,放不下Survivor區,也被分配到Old區
// ...
// 在某次Minor GC的過程中突然發現:
// 不好,老年代Old區也滿了,這是一次大GC(老年代GC:Major GC)
Old區慢慢的整理一番,空間又夠了
// 繼續Minor GC
// ...
// ...複製程式碼

觸發GC的型別

瞭解這些是為了解決實際問題,Java虛擬機器會把每次觸發GC的資訊列印出來來幫助我們分析問題,所以掌握觸發GC的型別是分析日誌的基礎。

  • GC_FOR_MALLOC: 表示是在堆上分配物件時記憶體不足觸發的GC。
  • GC_CONCURRENT: 當我們應用程式的堆記憶體達到一定量,或者可以理解為快要滿的時候,系統會自動觸發GC操作來釋放記憶體。
  • GC_EXPLICIT: 表示是應用程式呼叫System.gc、VMRuntime.gc介面或者收到SIGUSR1訊號時觸發的GC。
  • GC_BEFORE_OOM: 表示是在準備拋OOM異常之前進行的最後努力而觸發的GC。

參考:


本文連結:Java記憶體模型
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。

相關文章