Java 內存結構分配和回收規則


Java同C++的內存管理不一樣, 不需要開發者自己維護對象的生命週期. JVM的自動內存管理機制會幫助回收需要被回收的對象, 並且不會輕易出現內存泄露等問題. 這種機制極大得提高了日常的開發效率並降低項目的維護成本, 但是由於內存回收的過程封閉在JVM中, 爲了避免出了問題兩眼一抹黑的情況, 我們應該更多得了解JVM的內存回收機制, 要知其然知其所以然才能能好得幫助我們排查問題.

我們從下面幾個步驟來分別介紹內存回收的過程. 首先需要了解Java的內存結構, 在JVM的管理下內存是如何分區的, 對象的實例存放在哪裏? JVM是如何知道哪個對象需要被回收? JVM有哪些回收內存的方式? JVM內存回收的整體方案有哪些?

內存結構

由於線程是JVM中CPU調度的最小單位, 所以JAVA的整個內存區從這個角度可以分爲共享內存(多個線程間共享)和線程私有內存(只在線程內部可見). 從內存的功能和作用可以分爲程序計數器, 虛擬機棧, 本地方法棧, 方法區和堆這五個區域. 如下表所示.

分區 共享|私有 描述
程序計數器 私有內存 當前線程執行字節碼的行號記錄器. 保證線程中指令的運行順序.
VM Stack虛擬機棧 私有內存 Java方法執行的內存模型. 每個方法在運行時都會創建棧幀stack frame, 用來存儲局部變量表, 動態鏈接, 方法簽名和方法出口等信息. 如果虛擬機棧的調用深度超過JVM參數設置會拋出stack overflow異常, 如果在創建棧幀時不能申請到充足的內存拋出OOM異常.
Native Method Stack本地方法棧 私有內存 native方法執行的內存模型. 在Hotspot虛擬機中, 本地方法棧跟虛擬機棧合併在了一起.
Method Area方法區 共享內存 用來存放類信息, 常量, 靜態屬性, 編譯後的代碼等信息. 有些場景也會被叫做永生代.
Heap堆 共享內存 幾乎所有對象的實例都在堆上存儲. GC工作的主要場所. 可以在物理內存上不連續, 但是邏輯要連續的空間.
Direct Memory直接內存 公共內存 直接內存的分配不受堆棧的限制, 但是需要受總機內存的限制. 使用不當也會造成OOM. 例如NIO爲了避免在Java堆和native堆來回拷貝緩存數據, 使用直接內存來存放緩存提高效率.

對象引用分析

簡單來說我們都是通過對象是否有被有效引用來判斷對象是否存活的. JVM有提供四種不同強度的引用, 方便開發者在有限的內存空間下結合自己實際的需求更加合適和高效得配合JVM來回收內存. 下表引用強度依次降低.

引用類型 GC回收時機 補充
強引用 只要有引用就不會被回收 -
軟引用 OOM前對軟引用進行GC回收 -
弱引用 創建引用後, 只要GC就回收 -
虛引用 不影響對象的存活週期 通常用來監控對象是否被回收

通常有兩種做法來判斷對象是否存活: 引用計數法和可達性分析法.

Reference Counting, 引用計數法. 這種方式實現最簡單, 每個對象維護一個引用計數器, 如果被引用了就加一, 取消引用就減一. 在GC時如果對象的引用計數器值等於零就說明對象可以被回收. 但是在JVM中一般不用引用計數法來回收內存. 雖然他的效率比較高, 但是有循環引用問題需要解決. 例如在一個作用域中對象A中引用了對象B, 對象B中也引用了A, 退出作用域後按道理來講應該要回收這兩個對象的內存, 但是由於AB兩個對象內部有互相引用他們的引用計數器都不爲0, AB兩個對象不會被回收造成內存泄露. 主流的高級語言都是用可達性分析法來判斷對象是否需要回收的.

Reachability Analysis, 可達性分析法. 基本思路是通過一系列的GC Root對象向下搜索, 搜索的路徑就是GC Reference Chain引用鏈. 如果一個對象沒有在任何引用鏈引上, 就說明當前對象可以被回收了. 可以被當做GC Root的點:

  1. 虛擬機棧, 本地方法棧中的對象引用.
  2. 方法區中靜態, 常量屬性的對象引用.

GC回收算法

上節講了JVM通過對象引用的可達性分析來區分出來哪些對象是存活的, 哪些對象是可以被回收的. 接下來有下面四種回收方式來整理和回收內存, 他們都有自己的特點和優缺點, 可以在不同的場景下使用.

  1. 標記清除算法. 最基礎的回收方式, 先用可達性分析法知道存活的對象, 再遍歷內存反向標記出可以回收的對象, 最後回收標記過的對象. 這種方式需要遍歷整個內存不僅效率低, 還容易出現內存碎片問題. 回收後的空餘內存會分散在各個角落, 如果有稍微大點的對象要申請內存可能需要再次GC纔能有足夠的空間.
  2. 複製算法. 優化了標記清除算法的效率問題和內存碎片問題, 例如複製算法將內存分成2份A區和B區. 先用A區來給對象分配內存, 觸發GC後直接將存活的對象複製到B區的連續內存中, 再回收整個A區回收的效率變高. 由於B區是連續內存, 再有對象申請內存時可以直接指針碰撞. 相遇於複製算法會犧牲一部分的內存容量來提高效率並解決內存碎片問題. 根據IBM的研究, 絕大多數的對象存活時間都很短, 所以沒有必要按照1:1的比例去劃分內存區. Hotspot虛擬機默認將新生代劃分爲三個區, Eden, Survivor, Survivor比例爲8:1:1. 在GC時將Eden和一個Survivor區的內存GC拷貝到另一個Survivor區, 這樣就只是浪費了10%的內存. 如果Survivor區不夠存放GC後的對象, 則向老年代申請分配擔保.
  3. 標記整理算法. 基於標記清除算法, 優化了內存碎片問題. 在標記後將存活對象往一個方向移動, 碰到可回收對象直接pass或覆蓋, 直到碰到邊界或不可回收對象, 最後將存活對象邊界外的內存全部回收. 通過整理內存移動存活對象的方式來解決內存碎片問題. 不同於複製算法適用於存活期較短的對象管理(直接清空幾個區來回收內存), 標記整理算法更適合管理生命週期較長的對象.
  4. 分代算法. 基於上面的基礎, 將內存分爲新生代和老年代. 根據不同的分代特性採用不同的GC算法. 在新生代每次GC都會回收大量的對象, 所以使用複製算法. 在老年代對象存活率高, 一般使用標記整理算法.

GC回收器

回收器是對象引用分析和GC回收算法的具體實現. 因爲算法有多種, 回收器作用場景也不止一個, 所以延伸出了多個版本的回收器.

  1. Serial. 單線程. 複製算法. GC時要暫停全部工作線程. 效率高但是GC暫停時間長, 在JVM Client下還可以在新生代使用.
  2. Serial Old. 單線程. 標記整理算法. JVM Client下在老年代使用. JVM Server下可以配合CMS作爲備選使用.
  3. ParNew. Serial的多線程版本. JVM Server下新生代使用.
  4. Parallel Scavenger. 多線程. 複製算法. 新生代使用, 可以通過JVM參數控制GC時CPU的吞吐量.
  5. Parallel Old. 同上, 標記整理算法.
  6. CMS(Concurrent Mark Sweep). 併發老年代. 標記清除算法. 以縮短GC時暫停工作線程時間爲主. 併發收集低停頓. 由於用了標記清除算法, 所以會有內存碎片問題. 並且由於GC時一些步驟是和工作線程並行的, 會出現標記過程中產生新的垃圾, 這些浮動垃圾要等下次GC才能被回收(爲了浮動垃圾還要預留內存, 所以CMS觸發GC的時機是內存佔用xx%後).
    1. 初始標記. 暫停工作線程. 標記GC Root關聯的對象, 速度快.
    2. 併發標記. 並行工作線程. 繪製從GC Root的引用鏈. 耗時長.
    3. 重新標記. 暫停工作線程. 修正併發標記後, 一些標記對象引用發生變化的記錄.
    4. 併發清除. 並行工作線程.
  7. G1(Garbage First). 新的並行併發收集器, 想要取代CMS. 不同於CMS, G1可以分代收集, 新生代使用複製算法, 老年代使用標記整理算法. 避免了內存碎片. 另外G1還可以設置GC的時間, G1將內存分區管理並維護每個區的性價比和回收時間, GC時按照優先級進行回收, 在時間範圍內優先回收高性價比的內存區.
    1. 初始標記. 同CMS,
    2. 併發標記. 同CMS
    3. 最終標記. 同CMS的重新標記.
    4. 篩選回收. 根據優先級, 性價比和GC時間來回收內存區.

識別到某個對象可以被回收, JVM也不會立刻就回收內存. 先對對象做一次標記, 並判斷當先對象是否調用過finalize方法. 如果調用過直接回收. 沒有調用過的話, 將對象放入F-Queue隊列中, 由JVM創建的低優先級Finalizer線程去調用隊列中對象的finalize方法. 稍後GC會對F-Queue中對象做第二次標記. 如果在第二次標記之前有引用鏈了, 就放棄回收. 否則就直接回收對象.

GC時一些步驟需要所有線程暫停運行防止出現引用不一致的情況. 通常需要判斷所有線程是否進入一個安全的狀態後先中斷, 才能計算引用關係. 線程在GC時的安全狀態, 主要有下面三個概念

  1. OopMap. JVM在類加載時計算出對象內部的引用情況. 給GC時用.
  2. Safe Point. GC時JVM不知道線程運行到哪個指令, 系統也不可能爲所有的指令上都附帶OopMap信息, 那樣開銷太大了. 所以JVM定義了一些合適的點, 用來存放OopMap信息. 例如方法跳轉, 循環條件跳轉, 異常跳轉等. 只有當線程運行到這些Safe Point上時, 纔會暫停等待GC.
  3. Safe Region. 由於Safe Point是線程主動執行才能觸發的, 那還有沒有獲取CPU時間片或者正在等待或阻塞的線程是不會運行到線程中的Safe Point的. 這裏新增一個Safe Region, 相當於是一個安全區域. 當GC時, 線程處於上述情況下時GC就會認爲當前線程也是安全的. 當線程退出Safe Region時會先判斷GC狀態, 再決定是掛起還是正常運行.

內存分配規則

  1. 大部分對象在新生代的Eden區分配內存. Eden: Survivor = 8:1:1.
  2. 大對象直接進入老年代. 如果過大的對象在新生代創建頻繁複制會擠佔其他新生代對象的空間, 從而頻繁GC. 所以當對象的體積超過JVM參數後, 直接在老年代中分配內存.
  3. 新生代自然生長到老年代. 每次GC對象的年齡會 + 1(Mark Word中). 達到最大15後就會從新生代轉移到老年代.
  4. 動態年齡判斷. 如果在新生生代, 某一個年齡段的對象佔用內存已經超過了Survivor區中的一半, 那麼這批對象和更老的對象直接升入老年代.
  5. 空間分配擔保. 如果新生代在GC複製內存時, Survivor區放不下了, 則將超出的對象直接放入老年代中.

轉載請註明出處:https://blog.csdn.net/l2show/article/details/104152298

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