JVM自动化的内存分配与内存回收
如何區分一個C++程序員和Java程序員?答案是看他吃飯完收不收拾碗筷,反正我是不收拾,哈哈哈哈哈哈。
Java技術體系中所提倡的自動內存管理最終可以歸結為自動化地解決了兩個問題:給對象分配內存以及回收分配給對象的內存。
一.內存回收
垃圾收集,Garbage Collection,GC,它1960年誕生于MIT的Lisp語言,歷史比Java本身還要久遠。
1. 哪些內存需要自動化回收?
再再再再次召喚Java程序員DNA中的那張圖
線程獨享的區域不用操心
JVM中的程序計數器、虛擬機棧、本地方法棧三個區域都是線程獨享的,它們的生命周期被限制在線程的生命周期中,而且每一個棧幀所需的內存大小在編譯期就可以確定(在你類結構確定的時候就可能已經確定了),所以它們內存的分配和回收都具有確定性,不需要過多的考慮內存回收問題。
線程共享的部分需要干預
但是Java堆和方法區不一樣,我們只有在程序的運行期間才能準確的知道程序會創建哪些對象,程序會創建多少對象,這部分內存分配和回收是動態的,GC關注的就是這部分內存的回收問題。
2. 什么時候進行回收?
一個對象什么時候應該被我們回收掉,很多人都會想到,那還用說,當然是它不再有用的時候,所以怎么判斷一個對象是否還會被用到?下面介紹兩個方法,引用計數法,可達性分析法
2.1.引用計數法
給對象添加一個引用計數器,每當有一個地方引用它,計數器就+1,引用失效時就-1,任何時刻,計數器為0的對象就是不可能再被利用的。
感覺這個方法很好啊,操作很簡單嘛,但是會不會有問題呢?
有,它難以解決對象之間相互循環引用問題
這三個對象ABC除了彼此的引用外,再沒有其他的引用,實際上這三個對象已經不可能被其他對象訪問了,也就是說它們不可能再被外部使用了,但是ABC的引用計數器都不為0,所以使用該算法無法保證GC可以順利回收它們,導致內存泄漏。
2.2.可達性分析法
更可靠的辦法時采用可達性分析法,這個算法的基本思路就是通過一系列成為GC Roots的對象作為起始點,從這些結點開始向下搜索,搜索所走過的路徑稱之為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連接,在圖論中,也就是說所有GC Roots到這個對象不可達,則表示此對象是不再有用的。
什么樣的而對象才是GC Roots對象?
A garbage collection root is an object that is accessible from outside the heap
在堆外可以訪問的對象可以是GC Roots對象
GC Roots對象一般包含以下幾種
- 虛擬機棧中引用的對象,也就是在局部變量表中引用的對象
- 方法區中類靜態屬性引用的對象(static field)
- 方法區中常量引用的對象 (static final field)
- 本地方法棧JNI引用的對象
即使是在可達性分析法中判定為不可達的對象,也不一定會被馬上清理,要真正宣告一個對象死亡,至少需要經歷兩次標記過程
枚舉GC Roots:
上面說過,GC Roots主要是在全局性的引用和執行上下文中,但是問題是現在有的應用僅僅方法區就有幾百兆,GC時要是逐個掃描里面的引用,系統必須停止所有Java線程的STW(Stop the world)時間就過長,需要找一個快捷的方式獲取GC Roots的位置。
HotSpot中,一組成為OopMap的數據結構用來實現這個目的,JRockit里叫做livemap,J9里叫做GC map。
在HotSpot中,對象的類型信息里有記錄自己的OopMap,記錄了在該類型的對象內什么偏移量上是什么類型的數據。所以從對象開始向外的掃描可以是準確的,這些數據是在類加載過程中計算得到的。
每個被JIT編譯過后的方法也會在一些特定的位置記錄下OopMap,記錄了執行到該方法的某條指令的時候,棧上和寄存器里哪些位置是引用。這樣GC在掃描棧的時候就會查詢這些OopMap就知道哪里是引用了。
安全點Safe point:
但是可能導致引用關系變化的指令太多了,如果為了保險JIT在每一條指令后都生成一個OopMap,那將花費大量的空間,而且很耗時。于是根據safepoint安全點把一個方法的代碼分成幾段,程序不是在所有地方都可以停下來進行GC,只有到達安全的才能暫停,這樣每一段代碼只對應一個OopMap。
這些特定的SafePoints位置主要在:
- 循環的末尾
- 方法臨返回前 / 調用方法的call指令后
- 可能拋異常的位置
對于Sefepoint,另一個需要考慮的問題是如何在GC發生時讓所有線程(這里不包括執行JNI調用的線程)都“跑”到最近的安全點上再停頓下來。
這里有兩種方案可供選擇:搶先式中斷(Preemptive Suspension)和主動式中斷(Voluntary Suspension)。
搶先式中斷不需要線程的執行代碼主動去配合,在GC發生時,首先把所有線程全部中斷,如果發現有線程中斷的地方不在安全點上,就恢復線程,讓它“繼續跑”到安全點上。現在幾乎沒有虛擬機實現采用搶先式中斷來暫停線程從而響應GC事件。
而主動式中斷的思想是當GC需要中斷線程的時候,不直接對線程操作,僅僅簡單地設置一個標志,各個線程執行時主動去輪詢這個標志,發現中斷標志為真時就自己中斷掛起。輪詢標志的地方和安全點是重合的,另外再加上創建對象需要分配內存的地方。
安全域Safe region:
使用Safepoint似乎已經完美地解決了如何進入GC的問題,但實際情況卻并不一定。Safepoint機制保證了程序執行時,在不太長的時間內就會遇到可進入GC的Safepoint。但是,程序“不執行”的時候呢?所謂的程序不執行就是沒有分配CPU時間,典型的例子就是線程處于Sleep狀態或者Blocked狀態,這時候線程無法響應JVM的中斷請求,“走”到安全的地方去中斷掛起,JVM也顯然不太可能等待線程重新被分配CPU時間。對于這種情況,就需要安全區域SafeRegion來解決。
安全區域是指在一段代碼片段之中,引用關系不會發生變化。在這個區域中的任意地方開始GC都是安全的。我們也可以把Safe Region看做是被擴展了的Safepoint。
在線程執行到Safe Region中的代碼時,首先標識自己已經進入了Safe Region,那樣,當在這段時間里JVM要發起GC時,就不用管已經標識為Safe Region狀態的線程了。在線程要離開Safe Region時,它要檢查系統是否已經完成了根節點枚舉(或者是整個GC過程),如果完成了,那線程就繼續執行,否則它就必須等待直到收到可以安全離開Safe Region的信號為止。
3. 如何回收?
確定了哪些垃圾需要被回收后,垃圾收集器就要開始工作了,但是又引出了新的問題,怎樣才能高效的回收垃圾?JVM中對如何進行垃圾回收沒有明確的規定,所以不同的廠商可以采用不同的方法進行垃圾回收
3.1.垃圾收集算法
3.1.1.標記-清除算法
標記-清除(Mark-Sweep)算法,算法分為標記和清除兩個階段:首先標記出所有活的對象,在標記完成后統一回收掉所有被未被標記的對象。之所以說它是最基礎的收集算法,是因為后續的收集算法都是基于這種思路并對其缺點進行改進而得到的。
它的主要缺點有兩個:一個是效率問題,標記和清除過程的效率都不高;另外一個是空間問題,標記清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致,當程序在以后的運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。
圖片來自阿里云大佬
3.1.2.復制算法
復制(Copying)的收集算法,它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。
這樣使得每次都是對其中的一塊進行內存回收,內存分配時也就不用考慮內存碎片等復雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。只是這種算法的代價是將內存縮小為原來的一半,持續復制長生存期的對象則導致效率降低。
3.1.3.標記-整理算法
復制收集算法在對象存活率較高時就要執行較多的復制操作,效率將會變低,所以在老年代一般不能直接選用這種算法。
根據老年代的特點,有人提出了另外一種標記-整理(Mark-Compact)算法,標記過程仍然與標記-清除算法一樣,但后續步驟不是直接對未被標記的可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內存
3.1.4分代收集算法
GC分代的基本假設:絕大部分對象的生命周期都非常短暫,存活時間短。當然這個假設也是成立的,據IBM一個正兒八經的調查可知98%的對象生命周期是非常短暫的。
分代收集(Generational Collection)算法,把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點采用最適當的收集算法。在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用復制算法,將對象從Eden區復制到Survivor區,只需要付出少量存活對象的復制成本就可以完成收集。而老年代中因為對象存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記-清理”或“標記-整理”算法來進行回收。
3.2.垃圾收集器
3.2.1 Serial收集器
Serial收集器是最基本、歷史最悠久的收集器了,是JDK 1.3.1之前新生代收集的唯一選擇,它每次只會使用一條線程去進行收集工作,STW時間會很長,但是它簡單高效,至今依然是虛擬在Client模式下的默認新生代收集器。
Serial + Serial Old運行圖:
3.2.2 ParNew收集器
ParNew收集器是Serial收集器的多線程版本,多個線程并行進行垃圾回收,可控參數,收集算法,對象分配規則、回收策略都和Serial收集器一模一樣。它雖然除了多線程之外相比于Serial收集器并無太多新穎之處,但它仍然是很多Server模式下虛擬機的首選新生代收集器。
ParNew + Serial Old運行圖:
3.2.3 Parallel Scavenge收集器
Parallel Scavenge收集器和ParNew收集器一樣,多線程并行收集,用于新生代,采用復制算法,它和ParNew收集器最大的區別在于Parallel Scavenge目標是達到可控制吞吐量。吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間)。
3.2.4 Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,使用標記-壓縮算法,也是單線程。它主要用于Client模式下的JVM,或于前面的Parallel Scavenge收集器搭配使用,或者在CMS收集器Concurrent Mode Failure時使用。
Serial + Serial Old運行圖:
3.2.5 Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多線程和標記-壓縮算法。
Parallel Scavenge + Parallel Old運行圖
3.2.6 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的老年代收集器。目前很大一部分的Java應用都集中在互聯網站或B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。
從名字上就可以看出CMS收集器是基于標記-清除算法實現的,它的運作過程相對于前面幾種收集器來說要更復雜一些,整個過程分為4個步驟,包括:
- 初始標記(CMS initial mark)
- 并發標記(CMS concurrent mark)
- 重新標記(CMS remark)
- 并發清理(CMS concurrent sweep)
其中初始標記、重新標記這兩個步驟仍然需要“Stop The World”。
初始標記:
初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快
并發標記:
并發標記階段就是進行GC Roots Tracing的過程,因為是并發運行的,在運行期間會發生新生代的對象晉升到老年代、或者是直接在老年代分配對象、或者更新老年代對象的引用關系等等,對于這些對象,都是需要進行重新標記的,否則有些對象就會被遺漏,發生漏標的情況。為了提高重新標記的效率,該階段會把上述對象所在的Card標識為Dirty,后續只需掃描這些Dirty Card的對象,避免掃描整個老年代;并發標記階段只負責將引用發生改變的Card標記為Dirty狀態,不負責處理
重新標記
重新標記階段則是為了修正并發標記期間,因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段會導致第二次stop the word,停頓時間一般會比初始標記階段稍長一些,但遠比并發標記的時間短。
并發清理
并發清理這個階段主要是清除那些沒有標記的對象并且回收空間,由于CMS并發清理階段用戶線程還在運行著,伴隨程序運行自然就還會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之后,CMS無法在當次收集中處理掉它們,只好留待下一次GC時再清理掉。這一部分垃圾就稱為“浮動垃圾”。
由于整個過程中耗時最長的并發標記和并發清除過程中,收集器線程都可以與用戶線程一起工作,所以總體上來說,CMS收集器的內存回收過程是與用戶線程一起并發地執行。CMS雖然可以做到并發收集、低停頓,但是仍然有三個明顯缺點。
- 無法處理浮動垃圾,可能出現Concurrent Mode Failure導致另一次Full GC的產生。
- 對CPU資源敏感并發階段會降低吞吐量
- 基于標記-清除算法,會產生大量內存碎片
3.2.7 G1收集器
G1是目前技術發展的最前沿成果之一,HotSpot開發團隊賦予它的使命是未來可以替換掉JDK1.5中發布的CMS收集器。與CMS收集器相比G1收集器有以下特點:
-
空間整合,G1收集器采用標記壓縮算法,不會產生內存空間碎片。分配大對象時不會因為無法找到連續空間而提前觸發下一次GC。
-
可預測停頓,這是G1的另一大優勢,降低停頓時間是G1和CMS的共同關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為N毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已經是實時Java(RTSJ)的垃圾收集器的特征了。
上面提到的垃圾收集器,收集的范圍都是整個新生代或者老年代,而G1不再是這樣。使用G1收集器時,Java堆的內存布局與其他收集器有很大差別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔閡了,它們都是一部分(可以不連續)Region的集合。
G1中每個Region大小是固定相等的,Region的大小可以通過參數-XX:G1HeapRegionSize設定,取值范圍從1M到32M,且是2的指數。如果不設定,那么G1會根據Heap大小自動決定。
G1的運作大致可分為如下幾步:
- 初始標記
- 并發標記
- 最終標記
- 篩選回收
二.內存分配
Eden Space:
由于大多數對象生命周期都很短,所以大多數對象都被分配在年輕代的Eden Space中(Eden Space of the young generation),當Eden Space空間不足時,JVM就會發起一次Minor GC/Young GC,Eden Space將會被清理,大部分對象都將被垃圾收集器回收掉,剩下的部分對象會被復制到Survivor Space中的From Space區中,如果From Space內存也不足,則會直接被復制至Old Space(老年代)中。Eden Space空間占整個年輕代空間的8/10左右,可通過參數設置。
Survivor Space:
Survivor Space的作用主要類似于年輕代和老年代之間的緩沖區,這部分又被分為From Space和To Space,分別在Eden Space和From Space經歷了一次Minor GC的對象將會進入到To Space中,如果To Space的空間不足,則會直接進入到Old Space中。From Space和To Space各占年輕代的1/10。
為什么需要Survivor Space?
假設沒有這個緩沖區,只要在Eden區中經歷了一次Minor GC便可直接進入老年代,那么毫無疑問老年代很快就會被填滿,但問題是,可能一個經歷了Minor GC的存活對象只能再經歷個兩三次Minor GC就會被回收了,所以只經歷一次Minor GC便直接進入老年代是很不明智的,Survivor Space這層緩沖保證只有經歷了16次Minor GC的對象才能進入老年代中。
為什么Survivor Space還需要分區?
Survivor Space分成From Space和To Space的主要是為了解決內存碎片問題。
現在假設Survivor Space不進行分區,從Eden Space復制過來的對象只能利用標志-清除算法進行收集了,但是那樣會很快造成嚴重的內存碎片化。如果分為From Space和To Space我們就可以進行復制算法進行收集,每次Minor GC后,From Space 和 To Space的身份交換,這樣就總可以保證有一個區是空的,復制算法總可以交替進行。
Old Space:
占了堆內存2/3的老年代到底是何方神圣?為何只有Major GC/Full GC才能降伏得了它?
老年代,Old Generation,它所擁有的空間大,發動一次針對它的GC所需要的STW(Stop the world)時間就越長,所以只有發動Major GC/Full GC時,才對它進行清理,注意每一次Major GC會自帶進行一次Minor GC,于Major GC相比,Minor GC所花費的時間就非常少了。而且由于老年代所存放的對象生命周期長占用內存大,進行一次標記-清除或者復制算法的效率非常低,所以,在老年代中通常采取標記-壓縮算法進行垃圾收集。
老年代存放大的對象,這里指的是那些需要大的連續內存的對象,一個大對象可以無視其生命周期的長短直接進入到老年代中,這樣大對象就不用再Servivor Space進行復制操作,那將非常消耗資源。
老年代存放生命周期長的對象,每當一個對象經歷一次Minor GC,它的年齡就加一,當一個對象的年齡達到15歲時(豆蔻之余又二年就算老了,哎~),它們就會進入到老年代中,這里的年齡限制可通過JVM參數設置。
三.內存分析工具
可以使用JDK bin中自帶的JConsole來進行內存分析
在JDK 6~8中,bin文件中是自帶VisualVM工具的,但是到JDK 9之后就被移除了,需要的自己下載,下載地址,如果你是使用Idea IDE,可以直接下載一個VisualVM插件,更方便快捷。
還有一種工具叫APPDynamics,它雖然收費,但是界面簡潔,功能強大。
三種分析工具的對比如下,按需選擇:
總結
以上是生活随笔為你收集整理的JVM自动化的内存分配与内存回收的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 经典常用算法/常用算法思维---附伪代码
- 下一篇: B树、B+树其实很简单,看不懂你找我