1. 程式人生 > 程式設計 >JVM—深入理解記憶體模型與垃圾收集機制

JVM—深入理解記憶體模型與垃圾收集機制

前言

Java是一種跨平臺的語言,當初其設計初衷也是為瞭解決各個平臺編譯環境具有差異,對程式移植性問題造成困難這一痛點,於是推出了Java語言。這麼多年Java受業界追捧的原因除了其面向物件的特性以外就是其可移植性強,而可移植性這一特性正式建立在JVM虛擬機器器這一基礎上的,JVM在其記憶體模型和垃圾回收機制的設計上堪稱神作,瞭解JVM虛擬機器器是每一個Java開發工程師必備的技能。

你瞭解Java的記憶體模型嗎

  • 記憶體簡介

    要說清楚記憶體,首先要提計算機程式是如何執行的。計算機程式指的就是可以讓計算機執行的一些指令集合,簡單地說就是我們平時寫的程式碼,而真正在計算機中執行的是程式程式=程式碼+資料

    ,而要操作資料,則應該先將資料載入進記憶體中,才能對其進行進一步的操作。而記憶體就是一系列地址空間,地址空間又分為核心空間使用者空間核心空間是計算機作業系統執行時所需的空間,如虛擬記憶體、聯網、作業系統排程等所需的空間,而Java程式實際執行時使用的空間是我們的使用者空間

  • JVM架構圖

    JVM架構圖

    類裝載器(ClassLoader):依據特定格式,class檔案載入到記憶體。
    執行引擎(Execution Engine):對命令進行解析。
    本地庫介面(Native Interface):融合不同開發語言的原生庫為Java所用。
    記憶體區域(Runtime Data Area):JVM記憶體空間結構模型。

  • 劃分

    從Java的記憶體區域中可以看到,其分為五個區,分別是 1.程式計數器、2.虛擬機器器棧、3.本地方法棧、4.堆區、5.方法區(元空間)而在這五個區中,分為執行緒私有共享區域

    • 執行緒私有

      1.程式計數器(Program Counter Register):當前執行緒所執行位元組碼(class檔案)行號指示器(邏輯),它改變自身的計數器的值來選取下一條需要執行的位元組碼指令,為了程式執行不互相沖突,所以每個執行緒必須私有程式計數器,保證程式執行不衝突。注:如果是執行Native方法,則計數器值為Undefined。 程式計數器中儲存的資料所佔空間的大小不會隨程式的執行而發生改變, 所以此區域不會出現 OutOfMemoryError 的情況。

      2.Java虛擬機器器棧(Stack)

      :Java虛擬機器器棧即我們平時所說的Java記憶體模型裡的棧記憶體,其存放的最小單位為棧幀,Java虛擬機器器棧中的每個棧幀主要儲存區域性變量表、運算元棧、動態連結、返回地址,當方法呼叫結束時,該棧幀隨即被銷燬,棧幀內的區域性變數也隨即被銷燬。這裡說一下區域性變量表操作棧區域性變量表包含了方法執行過程中的所有變數,而運算元棧主要實現入棧、出棧、複製、交換、產生消費變數等。該區域會產生兩種異常,即 當執行緒請求的棧大小超過棧的總深度,丟擲StackOverflowError異常(例如遞迴),當棧進行擴充套件時無法得到足夠的記憶體,則丟擲OutOfMemoryError異常。

      3.本地方法棧(Native Method Stack):與虛擬機器器棧相似,主要存非Java語言的方法。同樣會丟擲StackOverflowError和OutOfMemoryError異常

    • 所有執行緒共享

      1.方法區(Method Area):方法區主要儲存Class的相關資訊,包括Method和field等等,說這個之前首先說元空間(MetaSpace)永久代(PermGen)的區別,在Java1.7後,將方法區中的字串常量池移動到Java堆中,並且Java1.7之後將永久代變為元空間,它們兩個最大的區別就是元空間使用本地記憶體而永久代使用JVM記憶體,這一改變最大的變化就是,不會再看到ParmGen出現記憶體溢位的異常了,而且字串常量池存在永久代中,容易出現效能問題和記憶體溢位,類和方法的資訊大小難以確定,給永久代的大小指定帶來困難。
      2.Java堆(Heap):該區域是Java記憶體模型中最大的一塊,該區域儲存所有物件的例項,即我們在寫程式碼時new出來的物件,都存在堆區,當堆無法再分配記憶體時,將會丟擲OutOfMemoryError異常。該區域是GC管理的主要區域,因此Java堆又被稱為GC堆,由於GC在垃圾回收的時候使用分代收集,所以堆記憶體也可以被分為新生代老年代,老年代佔堆記憶體的2/3,新生代佔1/3,新生代又可以細分為Eden區From區To區,Eden區的Eden伊甸園的意思,聖經記載,亞當和夏娃在伊甸園偷食禁果,所以伊甸區是人類的起源地,名字也就來源於此,我們在程式中new出的物件(除大物件,大物件直接進入老年代),都存在於Eden區,當多次GC後沒有被回收,則會進入老年代,這一塊在說垃圾回收機制的時候會細說,這裡只要知道Java堆大概分為這幾個區域即可。

關於Java記憶體模型的面試題

  • JVM三大效能調優引數-Xms -Xmx -Xss的含義

    答: 1.-Xss:規定了每個執行緒虛擬機器器棧(堆疊)的大小。
    2.-Xms:堆的初始值。
    3.-Xmx:堆能達到的最大值。通常將堆的初始值和最大值設為相同值,防止堆擴容時產生記憶體抖動問題。

  • Java記憶體模型中堆和棧的區別——記憶體分配策略

    靜態儲存:編譯時確定每個資料目標在執行時的儲存空間需求。
    棧式儲存:資料區需求在編譯時未知,執行時模組入口前確定。
    堆式儲存:編譯時或執行時模組入口都無法確定,動態儲存。
    堆和棧的聯絡,引用物件、陣列時,棧裡定義變數儲存堆中物件目標的首地址。
    堆和棧的不同,棧中的變數在方法執行結束後立即被清除(自動釋放),而堆中的物件即使失去引用變為不可達物件,也需等待GC才會被清除,即清除時間時不確定的(需要GC)。
    棧的空間較堆空間小,且棧產生的碎片遠小於堆。
    棧的效率比堆高。

    堆和棧

  • JDK6和JDK6之後的版本對intern()方法的區別

    JDK6:當呼叫intern方法時,如果字串常量池先前已創建出該字串物件,則返回池中的該字串的引用。否則,將此字串物件新增到字串常量池中,並且返回該字串物件的引用。

    JDK6+:當呼叫intern方法時,如果字串常量池先前已創建出該字串物件,則返回池中的該字串的引用,否則,如果該字串物件已經存在於Java堆中,則將堆中對此物件的引用新增到字串常量池中,並且返回該引用;如果堆中不存在,則在池中建立該字串並返回其引用。

    注:Java1.8中 已經將字串常量池已經從方法區移動到堆中

Java垃圾回收機制

  • 物件被判定為垃圾的標準

    在垃圾回收機制中,把沒有被其它物件引用的物件判定為垃圾,而垃圾回收機制的各種演演算法也是基於這一標準,主要的中心即放在如何判定一個物件是否被引用如何被回收

  • 引用計數演演算法

    引用計數演演算法中,主要是通過計算一個物件的引用數量來判斷物件是否為垃圾,是否應該被回收。其實現方式是對存在於堆中的每一個物件都置一個引用數量計數器。當建立一個物件時,將該物件例項分配給一個引用物件,則將該物件的引用數量計數器的值加一,完成引用則減一。因此,當該例項物件的引用計數器值為0時,則可以將該物件視為垃圾,在GC呼叫時,則將會回收該物件的空間。
    引用計數演演算法的優劣:引用計數演演算法其優點是執行效率高,程式執行受影響較小,因為其執行時只需將引用數量計數器的值加一或減一,運算量極小,效率極高,可以交織在程式執行中。其缺點也是十分明顯的,引用計數演演算法有一個致命的缺陷,就是它無法處理迴圈引用的情況,所謂迴圈引用就是當A引用B,B又引用A,兩個物件互相引用,實際上這兩個物件是可以被回收的,但由於其引用計數器的值均為1,所以造成了此種演演算法判定這兩個物件為不可回收,導致記憶體洩漏。所以Java中的GC並不會採用此種演演算法。

    迴圈引用

  • 可達性分析演演算法

    可達性分析演演算法是通過判斷物件的引用鏈是否可達,來決定物件是否可以被回收,該演演算法從離散數學中的圖論引入,程式之間的引用關係可以看作是一個十分複雜的圖,通過一系列的名為GC Root的節點作為起始點,向下搜尋,搜尋中走過的路徑就被稱為引用鏈(Reference Chain),當一個物件從GC Root沒有任何的引用鏈,則證明該物件是不可達的,該物件就會被標記為垃圾。

    例如圖中Object5、Object6、Object7均為不可達,所以這三個物件將會在下一次GC中被清除。

    可達性分析演演算法

    可作為GC Root的物件: 1.虛擬機器器棧中引用的物件(棧幀中的區域性變量表)
    2.方法區中常量引用的物件
    3.方法區中類靜態屬性引用的物件
    4.本地方法棧中Native方法中的引用物件
    5.活躍執行緒的引用物件
    簡單來說:就是所有被引用的物件(包括靜態物件和非靜態物件)+執行緒+Native方法中的物件,都可以作為GC Root的物件。

垃圾回收演演算法

這裡可能有人會蒙,剛才不是談了垃圾回收演演算法了嗎,怎麼又開始說垃圾回收演演算法了,其實從這裡開始,才是真正的垃圾回收演演算法,上面的兩個演演算法可以算是垃圾回收前的準備工作,即對要回收的物件進行標記判斷。這個物件是否應該被回收,是上面那兩個演演算法的工作,而這個物件應該怎麼被回收,回收後要對記憶體做哪些工作,這就是垃圾回收演演算法所要考慮的事情。

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

    標記-清除演演算法將演演算法分為兩個步驟,即標記清除,所謂標記,就是從根節點進行掃描,對存活的物件進行標記。所謂清除,就是對堆記憶體中從頭到尾進行線性遍歷,回收未被標記的物件記憶體,即不可達物件記憶體,最後將原來做過標記物件的標記清空,為下一次GC做準備

    標記-清除演演算法的優缺點:標記-清除演演算法的優勢是其效率高,僅需掃描一遍記憶體即可將所有的垃圾進行回收。但是其缺陷也是十分的明顯,在標記-清除演演算法中,只要某物件被標記為垃圾,則呼叫GC時就會直接進行回收,這勢必會帶來一個問題,就是記憶體的碎片化。所謂記憶體碎片化,即在GC過程中,由於垃圾所處的記憶體空間並不連續,導致回收過後會存在很多的不連續的記憶體空間。
    舉個例子,有兩個物件A and B,A佔用1B記憶體,B佔用1B記憶體,他們兩個所處的位置並不連續,而當它們被同時標記為垃圾並被回收了之後,就會產生兩塊1B的記憶體,此時來了一個2B的物件,但是它就無法使用這兩塊不連續的1B儲存空間了。如果此時記憶體已滿,將會丟擲OutOfMemoryException,這就是記憶體碎片所造成的後果。

    碎片化

  • 複製演演算法(Copying)

    複製演演算法,將記憶體分為物件面空閒面,物件只存在於物件面上。當複製演演算法執行時,首先會像標誌-清除演演算法一樣,對存在引用的物件做標記,然後將帶有標記的物件複製到空閒面上,並且按照記憶體順序儲存,當全部帶標記的物件都被移動到空閒面上後,將物件面的所有物件一併清除,然後將空閒面和物件面進行互相轉換,即此時物件面變為空閒面,空閒面變為物件面。由於複製操作也存在效率問題,所以這種演演算法適用於物件存活率低的場景,因為這樣就不會有很多的物件需要複製。實際上這種演演算法是應用在堆記憶體中的新生代中的,因為在大量的實踐中證明,在新生代區的物件,最後存活下來的比例大概只有10%,所以相當適合這種演演算法。至於在新生代中這種演演算法的執行步驟是怎樣的,放在下文中說。
    由於複製演演算法對複製後的物件按照記憶體順序儲存,所以它解決了標記-清除演演算法中記憶體碎片化的問題。

    複製演演算法

  • 標記-整理演演算法(Compacting)

    標記-整理演演算法採用和標記-清除演演算法一樣的步驟,從根集合進行掃描,對存活的物件進行標記,但在清除時,這個演演算法會移動所有存活的物件,且按照記憶體地址次序依次進行排列,然後將末端記憶體地址以後的記憶體全部進行回收。由於此種演演算法在標記-清除的基礎上,加之對物件進行整理,所以其效率更低,但解決了記憶體碎片化的問題。
    該演演算法由於一次GC會有較高的資源消耗,所以該演演算法適用於存活率高的場景,例如堆記憶體中的老年代

    標記整理演演算法

  • 分代收集演演算法(Generational Collector)

    有了上述三種的垃圾回收演演算法,有些同學可能心存疑慮,到底JVM中使用的是哪一種演演算法來對垃圾進行回收呢?其實JVM使用了上述幾種演演算法的組合拳,即分代收集演演算法。從嚴格意義上來說,分代收集演演算法並不是一種新的演演算法,它只是將上述幾種演演算法進行了一個整合。按照物件生命週期的不同劃分割槽域,採用不同的垃圾回收演演算法。

    這裡先說一下JVM記憶體模型物件生命週期之間的關係,在我們new一個普通物件時,這個物件會在Eden區被建立,假如在一次GC過後,這個物件沒有被清除,則稱這個物件是倖存者,將其年齡屬性加一,而後將會移動到From 區或To區,這兩個區域也被統稱為Servivor區,(Eden區:From區:To區=8:1:1),當一個物件經歷了15次GC後都沒有被回收,則會直接被移動到堆區中的老年代,老年代中的物件被認為是回收可能性不大的物件,因為經歷了15次GC都沒有被回收的物件,經歷150次GC被回收的可能性也不大。

    所以瞭解了這個原理之後,再說分代收集演演算法就將會變得簡單。上文提到,複製演演算法由於其複製物件到空閒區需要消耗資源,所以適合物件存活率不高的場景,而新生代就很好地滿足了這個條件,所以新新生代通常使用複製演演算法進行垃圾回收。在多次的實踐中證明,一批被新建的物件,最終存活率大概在10%左右,所以這一批物件將會被複制到Servivor區,而複製完成後立即回收Eden區。而新生代中的From區和To區又和複製演演算法中的空閒區和物件區相對應。這就對複製演演算法的施行製造了很好的環境。

    老年代由於其儲存的物件具有不易被GC這個特點,所以上文中提到的標記-整理演演算法將會變得十分合適,標記-整理演演算法由於需要在清除後對存活的物件進行一次整理以消除記憶體碎片化,所以如果有大量的記憶體碎片,將非常不利於這種演演算法的執行,而老年代則給了適合這種演演算法的土壤。

    在分代收集演演算法中還有兩個重要的概念是未曾提到的Minor GCFull GC,存在於新生代的GC由於其垃圾回收範圍較小,被稱為MinorGC,而在老年代的GC中通常伴隨著所有記憶體的GC,所以其又被稱為Full GC。Full GC效率低,但是不常被觸發。

    觸發Full GC的條件

    1.老年代空間不足
    2.永久代空間不足(已移除)
    3.CMS GC時出現Promotion failed,concurrent mode failure
    4.Minor GC晉升到老年代的平均大小大於老年代的剩餘空間
    5.呼叫System.gc()
    6.使用RMI進行RPC或管理的JDK應用,每小時執行一次Full GC

    常用的調優引數
    1.-XX:SurvivalRatio:Eden和Servivor的比值,預設8:1
    2.-XX:NewRatio:老年代和年輕代的記憶體大小的比例
    3.-XX:MaxTenuriingThreshold:物件從年輕代晉升到老年代經過GC的最大閾值

    堆記憶體

常用的垃圾收集器

在說垃圾收集器之前,先得明白兩個概念

  • Stop-the-World

    什麼是Stop-the-World?JVM由於要執行GC,而停止應用程式的執行,這就是Stop-the-World,這種現象在任何一種GC演演算法中都會發生,所以如何讓Stop-the-World發生的次數越來越少,以優化GC效能,是大多數垃圾收集器優化GC的策略。

  • Safe Point

    這個詞相對來說很好理解,在GC過程中,會有程式不斷地產生垃圾物件,這會造成一邊打掃一邊扔的效果,所以GC是以快照方式進行垃圾回收的,在程式執行到特定位置時,例如跳轉,會生成一個Safe Point,而GC將會根據這個Safe Point中的垃圾進行回收。

  • 垃圾收集器

常用的垃圾收集器
上圖中上半部分為新生代垃圾收集器,下半部分為老年代垃圾收集器。
兩個垃圾收集器之間如果有連線,代表可以配合使用。

新生代垃圾收集器

Serial收集器是目前JVM執行在Client模式下的預設收集器,使用複製演演算法。因為它是單執行緒收集的,進行垃圾收集時必須暫停所有工作執行緒。
ParNew收集器 是多執行緒垃圾收集器,除了多執行緒這個特點,其餘的行為、特點和Serial收集器一樣。它是Server模式下JVM預設的垃圾收集器。

老年代垃圾收集器

Serial Old收集器 使用標記-整理演演算法,單執行緒收集,進行垃圾收集時,必須暫停所有工作執行緒。簡單高效,是Client模式下預設的老年代垃圾收集器。
CMS收集器 使用標記-整理演演算法,多執行緒收集,GC執行緒幾乎可以和工作執行緒同時工作。

GC相關面試題

  • Object的finalize()方法作用是否與C++的解構函式作用相同
    答:Object的finalize()方法不能保證在呼叫時立即回收目標物件,而是要等一次GC才能開始回收,因此它是不確定的。而C++中的解構函式是確定的。

  • Java中的強引用、軟引用、弱引用、虛引用有什麼用。
    強引用:指該物件存在至少一個引用物件引用的情況,這時GC絕不會回收該物件,當記憶體不足時,即使報OutOfMemoryException也不會回收該物件。
    軟引用:物件處於有用但非必須的狀態,只有當記憶體不足時,GC才會回收該引用的記憶體。可用來實現記憶體敏感的快取記憶體,因為在記憶體不足就被回收這一特性,我們不用太擔心OutOfMenoryException這一異常 用法:

    String str = new String("abc");//強引用
    SoftReference<String> softRef = new SoftReference<String>(str);//軟引用
    複製程式碼

    弱引用:非必須的物件,比軟引用更弱一些,GC時會被回收。被回收的概率也不大,因為GC執行緒優先順序比較低,適用於偶爾被使用且不影響垃圾收集的物件。
    用法:

    String str = new String("abc");//強引用
    WeakReference<String> weakRef = new WeakReferences<String>(str);//弱引用
    複製程式碼

    虛引用:不會決定物件的生命週期,在任何時候都可能被垃圾收集器回收,它可以跟蹤物件被垃圾收集器回收的活動。必須與ReferenceQueue聯用。
    用法:

    String str = new String("abc");
    ReferenceQueue queue = new ReferenceQueue();
    PhantomReference ref = new PhantomReference(str,queue);
    複製程式碼

    綜上,強引用>軟引用>弱引用>虛引用

結語

在寫這篇之前,我看過一篇文章,名字記不太清了,大致是,《面試官:求求你們了,再問你們Java記憶體模型不要再和我說堆區和棧區了》,當時我瞭解的JVM也僅限於此,看完那篇文後就有了去了解JVM的想法,只有自己實際瞭解過之後,才意識到自己是“學然後知不足,教然後知困”。也許在以後再回過頭來看這篇文章,我依然會有這種感覺,但我希望可以有那個時候。

本文圖片來自網路,侵刪。

歡迎大家訪問我的個人部落格:Object's Blog