如果想了解Java內存模型參考:jvm內存模型-和內存分配以及jdk、jre、jvm是什么關系(阿里,美團,京東)
相信和小編一樣的程序猿們在日常工作或面試當中經常會遇到JVM的垃圾回收問題,有沒有在夜深人靜的時候詳細捋一捋JVM垃圾回收機制中的知識點呢?沒時間捋也沒關系,因為小編接下來會給你捋一捋。
一、 技術背景你要了解吧
按照套路是要先裝裝X,談談JVM垃圾回收的前世今生的。說起垃圾回收(GC),大部分人都把這項技術當做Java語言的伴生產物。事實上,GC的歷史比Java久遠,早在1960年Lisp這門語言中就使用了內存動態分配和垃圾回收技術。設計和優化C++這門語言的專家們要長點心啦~~
二、 哪些內存需要回收?
猿們都知道JVM的內存結構包括五大區域:程序計數器、虛擬機棧、本地方法棧、堆區、方法區。其中程序計數器、虛擬機棧、本地方法棧3個區域隨線程而生、隨線程而滅,因此這幾個區域的內存分配和回收都具備確定性,就不需要過多考慮回收的問題,因為方法結束或者線程結束時,內存自然就跟隨着回收了。而Java堆區和方法區則不一樣、不一樣!(怎么不一樣說的朗朗上口),這部分內存的分配和回收是動態的,正是垃圾收集器所需關注的部分。
垃圾收集器在對堆區和方法區進行回收前,首先要確定這些區域的對象哪些可以被回收,哪些暫時還不能回收,這就要用到判斷對象是否存活的算法!(面試官肯定沒少問你吧)
2.1 引用計數算法
2.1.1 算法分析
引用計數是垃圾收集器中的早期策略。在這種方法中,堆中每個對象實例都有一個引用計數。當一個對象被創建時,就將該對象實例分配給一個變量,該變量計數設置為1。當任何其它變量被賦值為這個對象的引用時,計數加1(a = b,則b引用的對象實例的計數器+1),但當一個對象實例的某個引用超過了生命周期或者被設置為一個新值時,對象實例的引用計數器減1。任何引用計數器為0的對象實例可以被當作垃圾收集。當一個對象實例被垃圾收集時,它引用的任何對象實例的引用計數器減1。
2.1.2 優缺點
優點:引用計數收集器可以很快的執行,交織在程序運行中。對程序需要不被長時間打斷的實時環境比較有利。
缺點:無法檢測出循環引用。如父對象有一個對子對象的引用,子對象反過來引用父對象。這樣,他們的引用計數永遠不可能為0。
2.1.3 是不是很無趣,來段代碼壓壓驚
public class abc_test { public static void main(String[] args) { // TODO Auto-generated method stub MyObject object1=new MyObject(); MyObject object2=new MyObject(); object1.object=object2; object2.object=object1; object1=null; object2=null; } } class MyObject{ MyObject object; }
這段代碼是用來驗證引用計數算法不能檢測出循環引用。最后面兩句將object1
和object2
賦值為null
,也就是說object1
和object2
指向的對象已經不可能再被訪問,但是由於它們互相引用對方,導致它們的引用計數器都不為0,那么垃圾收集器就永遠不會回收它們。
2.2 可達性分析算法
可達性分析算法是從離散數學中的圖論引入的,程序把所有的引用關系看作一張圖,從一個節點GC ROOT開始,尋找對應的引用節點,找到這個節點以后,繼續尋找這個節點的引用節點,當所有的引用節點尋找完畢之后,剩余的節點則被認為是沒有被引用到的節點,即無用的節點,無用的節點將會被判定為是可回收的對象。
在Java語言中,可作為GC Roots的對象包括下面幾種:(京東)
a) 虛擬機棧中引用的對象(棧幀中的本地變量表);
b) 方法區中類靜態屬性引用的對象;
c) 方法區中常量引用的對象;
d) 本地方法棧中JNI(Native方法)引用的對象。
這個算法的基本思路就是通過一系列的稱為“GC Roots”的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連(用圖論的話來說,就是從GC Roots到這個對象不可達)時,則證明此對象是不可用的。如圖所示,對象object 5、object 6、object 7雖然互相有關聯,但是它們到GC Roots是不可達的,所以它們將會被判定為是可回收的對象。
現在問題來了,可達性分析算法會不會出現對象間循環引用問題呢?答案是肯定的,那就是不會出現對象間循環引用問題。GC Root在對象圖之外,是特別定義的“起點”,不可能被對象圖內的對象所引用。
對象生存還是死亡(To Die Or Not To Die)
即使在可達性分析算法中不可達的對象,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程:如果對象在進行可達性分析后發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行finapze()方法。當對象沒有覆蓋finapze()方法,或者finapze()方法已經被虛擬機調用過,虛擬機將這兩種情況都視為“沒有必要執行”。程序中可以通過覆蓋finapze()來一場"驚心動魄"的自我拯救過程,但是,這只有一次機會呦。
/**
* 此代碼演示了兩點:
* 1.對象可以在被GC時自我拯救。
* 2.這種自救的機會只有一次,因為一個對象的finapze()方法最多只會被系統自動調用一次
* @author zzm
*/
pubpc class FinapzeEscapeGC { pubpc static FinapzeEscapeGC SAVE_HOOK = null; pubpc void isApve() { System.out.println("yes, i am still apve :)"); } @Override protected void finapze() throws Throwable { super.finapze(); System.out.println("finapze mehtod executed!"); FinapzeEscapeGC.SAVE_HOOK = this; } pubpc static void main(String[] args) throws Throwable { SAVE_HOOK = new FinapzeEscapeGC(); //對象第一次成功拯救自己 SAVE_HOOK = null; System.gc(); //因為finapze方法優先級很低,所以暫停0.5秒以等待它 Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isApve(); } else { System.out.println("no, i am dead :("); } //下面這段代碼與上面的完全相同,但是這次自救卻失敗了 SAVE_HOOK = null; System.gc(); //因為finapze方法優先級很低,所以暫停0.5秒以等待它 Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isApve(); } else { System.out.println("no, i am dead :("); } } }
運行結果為:
finapze mehtod executed!
yes, i am still apve :)
no, i am dead :(
2.3 Java中的引用你了解多少
無論是通過引用計數算法判斷對象的引用數量,還是通過可達性分析算法判斷對象的引用鏈是否可達,判定對象是否存活都與“引用”有關。在Java語言中,將引用又分為強引用、軟引用、弱引用、虛引用4種,這四種引用強度依次逐漸減弱。無論是通過引用計數算法判斷對象的引用數量,還是通過可達性分析算法判斷對象的引用鏈是否可達,判定對象是否存活都與“引用”有關。在JDK 1.2以前,Java中的引用的定義很傳統:如果reference類型的數據中存儲的數值代表的是另外一塊內存的起始地址,就稱這塊內存代表着一個引用。在JDK 1.2之后,Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4種,這4種引用強度依次逐漸減弱。
- 強引用
在程序代碼中普遍存在的,類似 Object obj = new Object()
這類引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。
- 軟引用
用來描述一些還有用但並非必須的對象。對於軟引用關聯着的對象,在系統將要發生內存溢出異常之前,將會把這些對象列進回收范圍之中進行第二次回收。如果這次回收后還沒有足夠的內存,才會拋出內存溢出異常。
- 弱引用
也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。在JDK 1.2之后,提供了WeakReference類來實現弱引用。比如 threadlocal
- 虛引用
也叫幽靈引用或幻影引用(名字真會取,很魔幻的樣子),是最弱的一種引用 關系。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。它的作用是能在這個對象被收集器回收時收到一個系統通知。。在JDK 1.2之后,提供了PhantomReference類來實現虛引用。
不要被概念嚇到,也別擔心,還沒跑題,再深入,可就不好說了。小編羅列這四個概念的目的是為了說明,無論引用計數算法還是可達性分析算法都是基於強引用而言的。
軟引用使用示例:
package jvm; import java.lang.ref.SoftReference; class Node { pubpc String msg = ""; } pubpc class Hello { pubpc static void main(String[] args) { Node node1 = new Node(); // 強引用 node1.msg = "node1"; SoftReference<Node> node2 = new SoftReference<Node>(node1); // 軟引用 node2.get().msg = "node2"; System.out.println(node1.msg); System.out.println(node2.get().msg); } }
輸出結果為:
node2 node2
2.4 對象死亡(被回收)前的最后一次掙扎
即使在可達性分析算法中不可達的對象,也並非是“非死不可”,這時候它們暫時處於“緩刑”階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程。
第一次標記:如果對象在進行可達性分析后發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記;
第二次標記:第一次標記后接着會進行一次篩選,篩選的條件是此對象是否有必要執行finalize()
方法。在finalize()
方法中沒有重新與引用鏈建立關聯關系的,將被進行第二次標記。
第二次標記成功的對象將真的會被回收,如果對象在finalize()
方法中重新與引用鏈建立了關聯關系,那么將會逃離本次回收,繼續存活。猿們還跟的上吧,嘿嘿。
2.5 方法區如何判斷是否需要回收
猿們,方法區存儲內容是否需要回收的判斷可就不一樣咯。方法區主要回收的內容有:廢棄常量和無用的類。對於廢棄常量也可通過引用的可達性來判斷,但是對於無用的類則需要同時滿足下面3個條件:
- 該類所有的實例都已經被回收,也就是Java堆中不存在該類的任何實例;
- 加載該類的
ClassLoader
已經被回收; - 該類對應的
java.lang.Class
對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
關於類加載的原理,也是阿里面試的主角,面試官也問過我比如:能否自己定義String,答案是不行,因為jvm在加載類的時候會執行雙親委派,
原理請參考:Java 類加載機制(阿里面試題)
講了半天,主角終於要粉墨登場了。
垃圾對象如何確定
Java堆中存放着幾乎所有的對象實例,垃圾收集器在對堆進行回收前,首先需要確定哪些對象還"活着",哪些已經"死亡",也就是不會被任何途徑使用的對象。
三、常用的垃圾收集算法
3.0 引用計數法
引用計數法實現簡單,效率較高,在大部分情況下是一個不錯的算法。其原理是:給對象添加一個引用計數器,每當有一個地方引用該對象時,計數器加1,當引用失效時,計數器減1,當計數器值為0時表示該對象不再被使用。需要注意的是:引用計數法很難解決對象之間相互循環引用的問題,主流Java虛擬機沒有選用引用計數法來管理內存。
public class abc_test { public static void main(String[] args) { // TODO Auto-generated method stub MyObject object1=new MyObject(); MyObject object2=new MyObject(); object1.object=object2; object2.object=object1; object1=null; object2=null; } } class MyObject{ MyObject object; }
3.1 標記-清除算法(Mark-Sweep)
這是最基礎的垃圾回收算法,之所以說它是最基礎的是因為它最容易實現,思想也是最簡單的。標記-清除算法分為兩個階段:標記階段和清除階段。標記階段的任務是標記出所有需要被回收的對象,清除階段就是回收被標記的對象所占用的空間。具體過程如下圖所示:
從圖中可以很容易看出標記-清除算法實現起來比較容易,但是有一個比較嚴重的問題就是容易產生內存碎片,碎片太多可能會導致后續過程中需要為大對象分配空間時無法找到足夠的空間而提前觸發新的一次垃圾收集動作。
標記-清除算法采用從根集合(GC Roots)進行掃描,對存活的對象進行標記,標記完畢后,再掃描整個空間中未被標記的對象,進行回收,如下圖所示。標記-清除算法不需要進行對象的移動,只需對不存活的對象進行處理,在存活對象比較多的情況下極為高效,但由於標記-清除算法直接回收不存活的對象,因此會造成內存碎片。
3.2 復制算法(Copying)
為了解決Mark-Sweep算法的缺陷,Copying算法就被提了出來。它將可用內存按容量划分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象復制到另外一塊上面,然后再把已使用的內存空間一次清理掉,這樣一來就不容易出現內存碎片的問題。具體過程如下圖所示:
這種算法雖然實現簡單,運行高效且不容易產生內存碎片,但是卻對內存空間的使用做出了高昂的代價,因為能夠使用的內存縮減到原來的一半。
很顯然,Copying算法的效率跟存活對象的數目多少有很大的關系,如果存活對象很多,那么Copying算法的效率將會大大降低。
復制算法的提出是為了克服句柄的開銷和解決內存碎片的問題。它開始時把堆分成 一個對象 面和多個空閑面, 程序從對象面為對象分配空間,當對象滿了,基於copying算法的垃圾 收集就從根集合(GC Roots)中掃描活動對象,並將每個 活動對象復制到空閑面(使得活動對象所占的內存之間沒有空閑洞),這樣空閑面變成了對象面,原來的對象面變成了空閑面,程序會在新的對象面中分配內存。
3.3 標記-整理算法(Mark-compact)
為了解決Copying算法的缺陷,充分利用內存空間,提出了Mark-Compact算法。該算法標記階段和Mark-Sweep一樣,但是在完成標記之后,它不是直接清理可回收對象,而是將存活對象都向一端移動(美團面試題目,記住是完成標記之后,先不清理,先移動再清理回收對象),然后清理掉端邊界以外的內存(美團問過)
標記-整理算法采用標記-清除算法一樣的方式進行對象的標記,但在清除時不同,在回收不存活的對象占用的空間后,會將所有的存活對象往左端空閑空間移動,並更新對應的指針。標記-整理算法是在標記-清除算法的基礎上,又進行了對象的移動,因此成本更高,但是卻解決了內存碎片的問題。具體流程見下圖:
3.4 分代收集算法 Generational Collection(分代收集)算法
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根據對象存活的生命周期將內存划分為若干個不同的區域。一般情況下將堆區划分為老年代(Tenured Generation)和新生代(Young Generation),在堆區之外還有一個代就是永久代(Permanet Generation)。老年代的特點是每次垃圾收集時只有少量對象需要被回收,而新生代的特點是每次垃圾回收時都有大量的對象需要被回收,那么就可以根據不同代的特點采取最適合的收集算法。
目前大部分垃圾收集器對於新生代都采取Copying算法,因為新生代中每次垃圾回收都要回收大部分對象,也就是說需要復制的操作次數較少,但是實際中並不是按照1:1的比例來划分新生代的空間的,一般來說是將新生代划分為一塊較大的Eden空間和兩塊較小的Survivor空間(一般為8:1:1),每次使用Eden空間和其中的一塊Survivor空間,當進行回收時,將Eden和Survivor中還存活的對象復制到另一塊Survivor空間中,然后清理掉Eden和剛才使用過的Survivor空間。
而由於老年代的特點是每次回收都只回收少量對象,一般使用的是Mark-Compact算法。
3.4.1 年輕代(Young Generation)的回收算法 (回收主要以Copying為主)
a) 所有新生成的對象首先都是放在年輕代的。年輕代的目標就是盡可能快速的收集掉那些生命周期短的對象。
b) 新生代內存按照8:1:1的比例分為一個eden區和兩個survivor(survivor0,survivor1)區。一個Eden區,兩個 Survivor區(一般而言)。大部分對象在Eden區中生成。回收時先將eden區存活對象復制到一個survivor0區,然后清空eden區,當這個survivor0區也存放滿了時,則將eden區和survivor0區存活對象復制到另一個survivor1區,然后清空eden和這個survivor0區,此時survivor0區是空的,然后將survivor0區和survivor1區交換,即保持survivor1區為空(美團面試,問的太細,為啥保持survivor1為空,答案:為了讓eden和survivor0 交換存活對象), 如此往復。當Eden沒有足夠空間的時候就會 觸發jvm發起一次Minor GC
c) 當survivor1區不足以存放 eden和survivor0的存活對象時,就將存活對象直接存放到老年代。若是老年代也滿了就會觸發一次Full GC(Major GC),也就是新生代、老年代都進行回收。
d) 新生代發生的GC也叫做Minor GC,MinorGC發生頻率比較高(不一定等Eden區滿了才觸發)。
3.4.2 年老代(Old Generation)的回收算法(回收主要以Mark-Compact為主)
a) 在年輕代中經歷了N次垃圾回收后仍然存活的對象,就會被放到年老代中。因此,可以認為年老代中存放的都是一些生命周期較長的對象。
b) 內存比新生代也大很多(大概比例是1:2),當老年代內存滿時觸發Major GC即Full GC,Full GC發生頻率比較低,老年代對象存活時間比較長,存活率標記高。
3.4.3 持久代(Permanent Generation)(也就是方法區)的回收算法
用於存放靜態文件,如Java類、方法等。持久代對垃圾回收沒有顯著影響,但是有些應用可能動態生成或者調用一些class,例如Hibernate 等,在這種時候需要設置一個比較大的持久代空間來存放這些運行過程中新增的類。持久代也稱方法區,具體的回收可參見上文2.5節。
再寫一遍:
方法區存儲內容是否需要回收的判斷可就不一樣咯。方法區主要回收的內容有:廢棄常量和無用的類。對於廢棄常量也可通過引用的可達性來判斷,但是對於無用的類則需要同時滿足下面3個條件:
- 該類所有的實例都已經被回收,也就是Java堆中不存在該類的任何實例;
- 加載該類的
ClassLoader
已經被回收; - 該類對應的
java.lang.Class
對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
5 新生代和老年代的區別(阿里面試官的題目):
**所謂的新生代和老年代是針對於分代收集算法來定義的,新生代又分為Eden和Survivor兩個區。加上老年代就這三個區。數據會首先分配到Eden區 當中(當然也有特殊情況,如果是大對象那么會直接放入到老年代(大對象是指需要大量連續內存空間的java對象)。),當Eden沒有足夠空間的時候就會 觸發jvm發起一次Minor GC。如果對象經過一次Minor GC還存活,並且又能被Survivor空間接受,那么將被移動到Survivor空 間當中。並將其年齡設為1,對象在Survivor每熬過一次Minor GC,年齡就加1,當年齡達到一定的程度(默認為15)時,就會被晉升到老年代 中了,當然晉升老年代的年齡是可以設置的。如果老年代滿了就執行:Full GC 因為不經常執行,因此采用了 Mark-Compact算法清理
其實新生代和老年代就是針對於對象做分區存儲,更便於回收等等**
猿們加油跟上,離offer不遠啦!!!
四、常見的垃圾收集器
下面一張圖是HotSpot虛擬機包含的所有收集器,圖是借用過來滴:
- Serial收集器(復制算法)
新生代單線程收集器,標記和清理都是單線程,優點是簡單高效。是client級別默認的GC方式,可以通過-XX:+UseSerialGC
來強制指定。 - Serial Old收集器(標記-整理算法)
老年代單線程收集器,Serial收集器的老年代版本。 - ParNew收集器(停止-復制算法)
新生代收集器,可以認為是Serial收集器的多線程版本,在多核CPU環境下有着比Serial更好的表現。 - Parallel Scavenge收集器(停止-復制算法)
並行收集器,追求高吞吐量,高效利用CPU。吞吐量一般為99%, 吞吐量= 用戶線程時間/(用戶線程時間+GC線程時間)。適合后台應用等對交互相應要求不高的場景。是server級別默認采用的GC方式,可用-XX:+UseParallelGC
來強制指定,用-XX:ParallelGCThreads=4
來指定線程數。 - Parallel Old收集器(停止-復制算法)
Parallel Scavenge收集器的老年代版本,並行收集器,吞吐量優先。 - CMS(Concurrent Mark Sweep)收集器(標記-清理算法)
高並發、低停頓,追求最短GC回收停頓時間,cpu占用比較高,響應時間快,停頓時間短,多核cpu 追求高響應時間的選擇。 - CMS 和G1的垃圾回收器的原理,阿里的面試官也問過,我專門做了專題:
- 參考:圖解 CMS 垃圾回收機制原理,-阿里面試題
- 參考:CMS收集器和G1收集器優缺點-阿里面試題
- 參考:G1 垃圾收集器入門
五、GC是什么時候觸發的(面試最常見的問題之一)
由於對象進行了分代處理,因此垃圾回收區域、時間也不一樣。GC有兩種類型:Scavenge GC和Full GC。
5.1 Scavenge GC
一般情況下,當新對象生成,並且在Eden申請空間失敗時,就會觸發Scavenge GC,對Eden區域進行GC,清除非存活對象,並且把尚且存活的對象移動到Survivor區。然后整理Survivor的兩個區。這種方式的GC是對年輕代的Eden區進行,不會影響到年老代。因為大部分對象都是從Eden區開始的,同時Eden區不會分配的很大,所以Eden區的GC會頻繁進行。因而,一般在這里需要使用速度快、效率高的算法,使Eden去能盡快空閑出來。
5.2 Full GC
對整個堆進行整理,包括Young、Tenured和Perm。Full GC因為需要對整個堆進行回收,所以比Scavenge GC要慢,因此應該盡可能減少Full GC的次數。在對JVM調優的過程中,很大一部分工作就是對於Full GC的調節。有如下原因可能導致Full GC:
a) 年老代(Tenured)被寫滿;
b) 持久代(Perm)被寫滿;
c) System.gc()被顯示調用;
d) 上一次GC之后Heap的各域分配策略動態變化;