第一部分:面試題
本次分享我們將嘗試回答以下問題:
- GC 是什麼? 爲什麼要有 GC?
- 簡單說一下java的垃圾回收機制。
- JVM的常見垃圾回收算法有哪些?
- 爲什麼要使用分代回收機制?
- 如何判斷一個對象是否存活?
- 如果對象的引用被置爲 null,垃圾收集器是否會立即釋放對象佔用的內存?
第二部分:深入原理
好,讓我們開始吧。還是那句話,如果時間不夠可以直接拉到最後看答案。
java垃圾回收的知識點雖然看起來難,但知識點非常集中,而且很好理解。不信?不信就往下看吧。
1. 所謂GC
GC就是垃圾收集的意思(Gabage Collection)。
我們在開發中會創建很多對象,這些對象一股腦的都扔進了堆裏(還記得jvm內存模型嗎?不記得的話翻翻前面的文章),如果這些對象只增加不減少,那麼堆空間很快就會被耗盡。所以我們需要把一些沒用的對象清理掉。
2.對象已死嗎
垃圾回收,就是要把那些不再使用的對象找出來然後清理掉,釋放其佔用的內存空間。
判斷一個對象是否還在使用,用咱們java圈子的行話講,就是判斷對象是否死亡(反之就是存活)。
在java中判斷對象死亡有兩種方式:
- 引用計數法
- 可達性分析法
下面我們詳細講講
2.1 引用計數法
引用計數法的思想十分樸素,它的做法是給對象添加一個引用計數器,每當有一個地方引用該對象,這個計數器就加1。當引用失效時,計數器就減1。如果計數器爲0了,說明該對象不再被引用,成爲死亡對象。
不過這種算法有一個致命缺點,就是無法處理對象相互引用的情況。
你看,假如有A、B兩個對象,它們互相引用,那麼對象中的引用計數器會始終大於0。
所以這種算法已經沒人用了。
2.2 可達性分析法
2.2.1 什麼是可達性
可達性分析法就是目前的主流算法,也是java正在使用的算法。
它的做法是,通過一系列被稱爲“GC Roots”的對象作爲起點,從這些起點開始往下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain)。當一個對象沒有和任何引用鏈相連,即稱爲該對象不可達(圖論的說法),認爲該對象死亡。
來看下面這張圖:
上圖中A、B、C都跟GC Roots有直接或間接的引用關係,所以是存活對象。而D、E、F雖然相互之間有引用,但是和GC Roots並無引用關係,所以是死亡對象。
2.2.2 哪些對象可作爲GC Roots
有四類對象可作爲可達性分析的GC Roots
- 棧(棧幀中的本地變量表)中引用的對象
- 方法區中類靜態屬性引用的對象
- 方法區中常量引用的對象
- 本地方法棧中JNI引用的對象
總而言之,GC Roots是所有Java線程中處於活躍狀態的棧幀,靜態引用等指向GC堆裏的對象的引用。換句話說,就是當前所有正在被調用的方法的引用類型的參數/局部變量/臨時值。
2.3 所謂引用
對象是否死亡,關鍵就在於引用。在java中,引用其實有四種:強引用、軟引用、弱引用、虛引用。
-
強引用
強引用就是我們日常開發中最常見的引用,例如
String str = new String("hello");
只要強引用還在,對象就不會被回收。
-
軟引用
軟引用需要專門聲明,例如
SoftReference<String> str = new SoftReference<String>("hello");
被軟引用關聯的對象在內存不足時會被回收。
這個特性特別適合用來做緩存。
-
弱引用
弱引用也需要專門聲明,例如
WeakReference<String> str = new WeakReference<String>("hello");
被弱引用關聯的對象每次GC時都會被回收。
弱引用最常見的用途是實現可自動清理的集合或者隊列。
-
虛引用
虛引用是最弱的引用,需要用PhantomReference來聲明,例如
PhantomReference<String> phantom = new PhantomReference<>(new String("hello"), new ReferenceQueue<>());
它完全不會影響對象的生存時間,唯一的作用是在對象被回收時發一個系統通知。
2.4 起死回生
對象在被判定爲死亡後,並不會立刻被回收,而是要經過一個過程纔會被回收。在這個回收過程中,死亡對象還有可能活過來,是不是很神奇?
來看圖:
上圖是對象被回收的過程。一個對象要被回收,至少要經過兩次標記。
如果對象在第二次標記之前重新連接上GC Roots,那麼它將在第二次標記中被移出回收隊列,從而復活。
還有一點需要注意的是,Finalizer線程是一個由虛擬機自動建立,且低優先級的線程。該線程觸發對象的finalize()方法之後,並不會阻塞等待方法執行結束。這樣做是爲了防止回收隊列被阻塞。
finalize()是Object中的方法,當垃圾回收器將要回收對象所佔內存之前被調用的方法。有些教材推薦用該方法來做“關閉外部資源”之類的工作,但是實際上該方法運行代價高昂,且不確定性很大,所以並不推薦使用。真要關閉外部資源,還不如用try-finally來處理。
3.方法區的回收
方法區不在堆內,會被垃圾回收嗎?
在jdk1.7中,方法區在永久代,而永久代本身就是垃圾回收概念下的產物,full gc時就會對方法區回收。
到了jdk1.8,雖然永久代被取消,但是新增了MaxMetaspaceSize參數,對於將死的類及類加載器的垃圾回收將在元數據使用達到“MaxMetaspaceSize”參數的設定值時進行。
所以,方法區會被回收。
4.垃圾回收算法
這一節我們來看下流行的垃圾回收算法,只說思想,不涉及實現細節。
我們需要了解的垃圾回收算法有以下幾種:
- 標記-清除算法
- 複製算法
- 標記-整理算法
- 分代回收算法
咱們一個個來看下。
4.1 標記-清除算法
標記-清除算是最基本的回收算法了。它的思想就是先標記,再清除。標記過程如2.4節所述,有兩次標記。
它的主要缺點有兩個:
- 效率不高
- 會產生大量內存碎片
內存碎片是指內存的空間比較零碎,缺少大段的連續空間。這樣假如突然來了一個大對象,會找不到足夠大的連續空間來存放,於是不得不再觸發一次gc。
4.2 複製算法
複製算法的思想是,把內存分成兩塊,假設分成A、B兩個區域吧。
每次對象過來之後,都放到A區域裏,當A區域滿了之後,把存活的對象複製到B區域,然後清空A區域。
接下來的對象就全部放到B區域,等B區域滿了,就把存活對象複製到A區域,然後清空B區域。
就這樣來回倒騰,完成垃圾回收。
優點是不會有空間碎片,缺點是每次只用得到一半內存。
缺點是在對象存活率較高的場景下(比如老年代那樣的環境),需要複製的東西太多,效率會下降。
4.3 標記-整理算法
標記-整理算法中的“標記”階段和“標記-清理”中的標記一樣。不同的是,死亡對象並不會直接清理,而是把他們在內存中都移動到一起,然後一起清理。
4.4 分代收集算法
分代收集算法其實沒什麼新東西,只是把對象按存活率分塊,然後選用合適的收集算法。
java中使用的就是分代收集算法。
存活率低的對象放在一起,稱爲年輕代,使用複製算法來收集。
存活率高的對象放在一起,稱爲老年代,使用標記-清除或者標記-整理算法。
5. HotSpot的枚舉GC Roots
前面我們說到了對象的可達性分析需要從GC Roots開始計算引用鏈。
然而可作爲GC Roots的對象非常多,一個個來計算將非常耗時。
而且在進行這項工作時,虛擬機必須停下來,就像時間停止那樣(Sun稱之爲Stop The World,哈哈,是不是很酷),以此保證分析結果的準確性。
我們的程序,特別是網站應用,基本是上是一刻不停的在運行的。如果出現長時間的停止,基本上是不可接受的。爲了解決這個問題,各個虛擬機都採取了一些措施,儘量減少停頓時間(是的,只能減少,停頓是不可能消除的)。
我們來看看現在最流行的Hotspot虛擬機是怎麼處理的。(還記得啥是HotSpot不?翻翻前幾篇文章)
5.1 OopMap
在HotSpot中,虛擬機把對象內的什麼偏移量上是什麼類型的數據的信息存在到一個叫做“OopMap”的數據結構中。這樣在計算引用鏈時直接查OopMap即可,不用到整個內存中去挨個找了,由此提高了分析速度。
5.2 安全點
然而,程序中的引用關係時時刻刻都在變化,如果每次變化都要記錄到OopMap中,也是一項很大的負擔。所以,只有在程序執行到了特定的位置,纔會去記錄到OopMap中。
這個“特定的位置”,就叫安全點。
這裏面還有個問題,就是如何保證在GC發生時,讓所有的線程正好到達安全點。
有兩種方式:
-
搶先式中斷(已經沒人用了)
搶先式中斷的思路是,先把所有線程中斷,如果有線程沒有跑到安全點上,就恢復該線程,讓它跑到安全點。
-
主動式中斷
主動式中斷的做法是,設置一箇中斷標誌,這個標誌和安全點是重合的。讓各個線程去輪詢這個標誌,發現需要中斷時,線程就自己中斷掛起。
5.3 安全區域
雖然安全點已經完美解決了如何保證在GC發生時,讓所有的線程正好到達安全點的問題。
但是有一些情況下,線程失去了行爲能力,比如線程處於sleep或者blocked狀態。這個時候線程無法去響應JVM的中斷請求,而JVM顯然也不肯能一直等待某幾個線程。該怎麼辦呢?
這種情況就需要“安全區域”來解決。
安全區域是指在一段代碼片段中,引用關係不會發生變化,這個區域中任意地方開始GC都是安全的。
6.垃圾收集器
前面咱們說的都是垃圾收集的方法和思路,垃圾收集器則是具體的實現。
先來看下hotSpot中垃圾收集器的總圖(到jdk1.8)
6.1 並行和併發
在開始講解之前,我們先了解一下什麼是並行和併發。
並行:垃圾收集器是多線程同時工作的,但是用戶線程仍然處於等待狀態。
併發:用戶線程和垃圾收集器線程同時執行(也有可能是交替執行)。
下面咱們說說幾個常用的使用方案
6.1 jdk1.8默認垃圾收集器
查看當前使用的垃圾收集器可以使用以下命令:
~ java -XX:+PrintCommandLineFlags -version
然後會看到以下內容:
-XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.8.0_151"
Java(TM) SE Runtime Environment (build 1.8.0_151-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.151-b12, mixed mode)
可見jdk1.8默認工作在Server模式下,默認使用ParallelGC垃圾收集器。
如果要看更詳細的信息,還可以使用以下命令:
java -XX:+PrintFlagsFinal -version | grep GC
這個命令打印的內容有點多,我們主要找值爲true的信息。默認情況會有以下兩行:
bool UseParallelGC := true
bool UseParallelOldGC = true
6.1.1 Parallel Scavenge收集器
從上面的總圖能看到,這是一個工作在年輕代的收集器,使用複製算法,是一個並行的多線程收集器。
它的目標是達到一個可控制的吞吐量。所謂吞吐量就是CPU用於運行用戶代碼的時間與CPU總消耗時間的比值。比如虛擬機總共運行了100分鐘,其中垃圾收集花了1分鐘,那吞吐量就是99%。
6.1.2 Parallel Old收集器
Parallel Old是一個工作在老年代的收集器,使用“標記-整理”算法。也是一個關注吞吐量的垃圾收集器。
6.2 web應用垃圾收集器方案
ParallelGC組合重視的是吞吐量,非常適合在後臺運算而不需要太多交互的場景。
對於需要大量交互的應用,比如web應用,則需要更短的停頓時間。
所以大多數web應用使用的是ParNew+CMS收集器方案。
6.2.1 ParNew收集器
parNew也是一個工作在年輕代的收集器,也使用複製算法,也是一個並行的多線程收集器。
爲什麼我要使用這麼多“也”……
好吧,parNew看起來和Parallel Scavenge一模一樣,但其實他們還是有區別的。
parNew是一個重視停頓時間收集器。
不過它最大的特點是:可以和CMS收集器組隊工作。
Parallel Scavenge就不行…...
6.2.2 CMS收集器
CMS是一款十分優秀的老年代垃圾收集器,響應速度快、停頓時間短,是現在大多數互聯網公司的選擇,大家要好好掌握。
CMS使用“標記-清除”算法,分爲4個步驟:
- 初始標記(STW)
- 併發標記
- 重新標記(STW)
- 併發清除
其中,初始標記很快,只是標記一下GC Roots能直接關聯到的對象。
併發標記和重新標記要Stop The World,併發標記就是在標記死亡對象,重新標記是爲了修正併發標記期間發生變動的那部分對象。
從耗時來看,併發標記>重新標記>初始標記。
併發清除和併發標記耗時最長,但收集器線程是和用戶線程一起併發執行的,所以沒有停頓。
CMS固然優秀,但也有一些缺點:
-
耗CPU資源
收集器線程和用戶線程併發工作,所以收集時會搶佔CPU資源
-
無法處理浮動垃圾
浮動垃圾是指在標記過程之後出現的垃圾。這部分垃圾在本次回收中無法處理,只能等下次。
-
產生碎片空間
使用“標記-清除”算法就會有這個問題。不過可以通過參數設置開啓碎片整理,比如3次回收後就來一次帶碎片整理的回收。
6.3 G1收集器
G1收集器是目前最新的垃圾收集器,到jdk1.7時達到可商用程度。
G1收集器可以同時hold住年輕代和老年代,不需要和別的收集器搭配使用。
G1收集器使用的也是分代算法,它的思路是,把內存空間分成一個個小格子,每個格子稱爲一個Region。如下圖:
優先回收價值大的Region。
年輕代使用併發複製算法,有STW。
老年代回收步驟大致可以分爲以下幾個:
- 初始標記(STW)
- 併發標記
- 最終標記(STW)
- 篩選回收(STW)
目前JDK1.9已經默認使用G1收集器,但是在JDK1.8版本中G1收集器似乎還有不少問題,使用的還不多。
7.內存分配策略
終於要放出這張圖了:
其實我在一開頭就像放這張圖,但是想着先講點前置知識,沒想到這一講,就叫講到這了…...
7.1 年輕代的策略
在年輕代分爲三個區域,Eden區、Survivor1區、Survivor2區。有時候Survivor1區、Survivor2區又叫from區和to區。
對象優先分配到Eden區。Eden區要滿的時候,會有一次複製回收,把存活的對象放到Survivor1區。
等Eden區再次要滿的時候,又會有一次複製回收,把Eden區和Survivor1區的存活對象放到Survivor2區。
然後如此循環。
7.2 大對象的策略
虛擬機提供了一個-XX:PretenureSizeThreshold參數,大於這個參數的對象會直接進入老年代,防止年輕代發生大量內存複製。
7.3 晉升策略
年輕代的對象沒熬過一次Minor GC,年齡就加一歲。默認15歲時,就會進入老年代。
不過這個條件並非絕對,如果Survivor中相同年齡的對象總和大於Survivor空間的一半,那麼年齡大於等於該年齡的對象可以直接晉升到老年代。
7.4 空間分配擔保
年輕代在Minor GC後會有對象進入老年代,在極端情況下,年輕代所有對象都存活並進入老年代。
所以在MinorGC之前,虛擬機會檢查老年代的連續內存空間是否大於年輕代所有對象總和。
如果空間不夠,那麼這次MinorGC是有風險的。
如果允許冒險,Minor GC會直接執行,如果失敗,會再發起一次full GC。
如果不允許冒險,則先執行一次full GC,再進行Minor GC。
第三部分:面試題答案
-
GC 是什麼? 爲什麼要有 GC?
GC就是垃圾回收,釋放掉沒用的對象佔用的空間,保證內存空間不被迅速耗盡。
-
簡單說一下java的垃圾回收機制。
java採用分代回收,分爲年輕代、老年代、永久代。年輕代又分爲E區、S1區、S2區。
到jdk1.8,永久代被元空間取代了。
年輕代都使用複製算法,老年代的收集算法看具體用什麼收集器。默認是PS收集器,採用標記-整理算法。
-
JVM的常見垃圾回收算法有哪些?
複製、標記清除、標記整理、分代回收
-
爲什麼要使用分代回收機制?
因爲沒有一種算法能適用所有場合。在對象存活率低的場景下,複製算法最合適。
對象存活率高時,標記清除或者標記整理算法最合適。
所以才需要分代來處理。
-
如何判斷一個對象是否存活?
現在主流使用的都是可達性分析法。從GC Roots對象計算引用鏈,能鏈上的就是存活的。
-
如果對象的引用被置爲 null,垃圾收集器是否會立即釋放對象佔用的內存?
不會。對象回收需要一個過程,這個過程中對象還能復活。而且垃圾回收具有不確定性,指不定什麼時候開始回收。