最近學(xué)習(xí)Python的GC機(jī)制時(shí),想到了java的GC,忘得差不多了,(⊙﹏⊙)b??!這里便做一下回顧總結(jié)。推薦周志明譯本的《深入理解Java虛擬機(jī)》。
1. Java內(nèi)存模型

1.1 程序計(jì)數(shù)器
程序計(jì)數(shù)器,是一塊較小的內(nèi)存空間,它可以看作當(dāng)前線程所執(zhí)行的字節(jié)碼的行號(hào)指示器。字節(jié)碼解釋器工作時(shí)就是通過改變這個(gè)計(jì)數(shù)器的值,來獲取下一條需要執(zhí)行的字節(jié)碼指令,分支、循環(huán)、跳轉(zhuǎn)、異常處理、線程恢復(fù)等基礎(chǔ)功能都需要依賴計(jì)數(shù)器來完成。
這部分的內(nèi)存區(qū)域是線程私有的。JVM中的多線程是通過線程輪流切換,每個(gè)線程在CPU分配的時(shí)間片執(zhí)行的方式來實(shí)現(xiàn)的。任何一時(shí)刻,每個(gè)CPU內(nèi)核都只會(huì)執(zhí)行一個(gè)線程,線程切換的時(shí)候會(huì)保存上一個(gè)任務(wù)的狀態(tài),以便下次切換會(huì)這個(gè)任務(wù)時(shí)再加載這個(gè)任務(wù)。程序計(jì)數(shù)器的作用就是在做上下文切換的時(shí)候,可以讓程序恢復(fù)到正確的位置。
1.2 Java虛擬機(jī)棧
Java虛擬機(jī)棧也是線程私有的,它的生命周期和線程相同。虛擬機(jī)描述的是Java方法執(zhí)行的內(nèi)存模型:每個(gè)方法在執(zhí)行的時(shí)候都會(huì)創(chuàng)建一個(gè)棧幀,用于存儲(chǔ)局部變量表、操作數(shù)棧、返回值等信息。每一個(gè)方法從調(diào)用直至執(zhí)行完成的過程,就雪瑩這一個(gè)棧幀在虛擬機(jī)棧中入棧到出棧的過程。
通常會(huì)粗粒度的把Java內(nèi)存劃分為堆內(nèi)存(Heap)和棧內(nèi)存(Stack),這里的棧內(nèi)存講的就是虛擬機(jī)棧(局部變量表部分)。
局部變量存放了編譯器可知的各種基本數(shù)據(jù)類型、引用類型。64位的long和double類型數(shù)據(jù)會(huì)占用2個(gè)局部變量空間,其余數(shù)據(jù)類型只占用1個(gè)。局部變量表所需要的內(nèi)存空間在編譯期間完成分配,當(dāng)進(jìn)入一個(gè)方法時(shí),這個(gè)方法在棧中分配多大的空間是確定的,在方法運(yùn)行期間不會(huì)改變局部變量表的大小。
1.3 本地方法棧
本地方法棧和虛擬機(jī)棧的作用是非常類似的,在HotSpot虛擬機(jī)中把這兩部分合到了一起。本地方法棧和虛擬機(jī)棧的區(qū)別是:虛擬機(jī)棧為虛擬機(jī)執(zhí)行Java方法(即字節(jié)碼)服務(wù),而本地方法棧則為虛擬機(jī)使用到的native方法服務(wù)。
1.4 Java堆
Java堆是JVM所管理的內(nèi)存中最大的一塊,它是被所有線程所共享的一塊內(nèi)存區(qū)域。在虛擬機(jī)啟動(dòng)時(shí)創(chuàng)建,此內(nèi)存區(qū)域的唯一目的就是存放對(duì)象實(shí)例,幾乎所有的對(duì)象實(shí)例都在這里分配內(nèi)存。
Java堆是GC管理的主要區(qū)域。從內(nèi)存回收的角度來看,由于現(xiàn)在基本上都采用分代回收算法,Java堆還可以分為新生代和老年代,后面小節(jié)會(huì)詳細(xì)介紹。
Java堆可以處于物理上不連續(xù)的內(nèi)存空間,只要邏輯上是連續(xù)的即可。在實(shí)現(xiàn)中,既可以實(shí)現(xiàn)成固定大小的,也可以是可擴(kuò)展的。可以通過-Xmx(最大值)和-Xms(最小值)配置。
1.5 方法區(qū)
方法區(qū)也是各個(gè)線程共享的內(nèi)存區(qū)域,它用于存儲(chǔ)已被虛擬機(jī)加載的類信息、常量、靜態(tài)變量、即時(shí)編譯后的代碼等數(shù)據(jù)。雖然JVM規(guī)范把方法區(qū)描述為堆的一部分,它卻有一個(gè)別名(Non-Heap)非堆。
有人稱方法區(qū)為永久代,但其實(shí)永久代只是HotSpot虛擬機(jī)對(duì)方法去這個(gè)概念的實(shí)現(xiàn)。在JDK1.8,永久代已被移除,用元空間代替,元空間不再使用虛擬機(jī)內(nèi)存,而直接使用本地的系統(tǒng)內(nèi)存。
方法區(qū)中有一部分叫做常量池,用來存放編譯期生成的各種字面量和符號(hào)引用,這部分內(nèi)容在類加載后放入方法區(qū)的常量池中。
1.6 直接內(nèi)存
主要用在NIO中,它提供了一個(gè)DirectByteBuffer對(duì)象,可以直接直接訪問系統(tǒng)內(nèi)存,可以避免在Java堆和Native堆中來回切換數(shù)據(jù)。
2. 對(duì)象創(chuàng)建過程
-
首先虛擬機(jī)會(huì)檢查常量池中類的信息,如果沒有,需要先加載類信息。檢查通過后,JVM將為新生對(duì)象分配內(nèi)存,對(duì)象所需的內(nèi)存大小在類加載完之后就可以確定,為對(duì)象分配空間的任務(wù)其實(shí)就是將一塊確定大小的內(nèi)存從Java堆中劃分出來。
分配內(nèi)存有兩種方式:
- 指針碰撞:假設(shè)堆中的內(nèi)存是絕對(duì)規(guī)整的,所有使用過的內(nèi)存都放在一遍,空閑的內(nèi)存放在另一邊,中間放著一個(gè)指針作為分界點(diǎn)的指示器,那么為新對(duì)象分配內(nèi)存時(shí)只需要將指針向空閑空間的那邊挪動(dòng)一段與對(duì)象大小相等的距離即可
- 空閑列表:假設(shè)堆中的內(nèi)存不是規(guī)整的,已使用的內(nèi)存和空閑的內(nèi)存相互交錯(cuò),虛擬機(jī)就必須維護(hù)一個(gè)列表,記錄哪些內(nèi)存時(shí)可用的,在分配的時(shí)候從列表中找到一塊足夠大的空間劃分給新的對(duì)象,并更新列表上的記錄
內(nèi)存分配完成后,JVM將分配到的內(nèi)存空間都初始化零值
undefined接下來JVM要對(duì)對(duì)象進(jìn)行必要的設(shè)置,例如對(duì)象是哪個(gè)類的實(shí)例,如何才能找到類的元數(shù)據(jù)信息、對(duì)象的哈希碼、對(duì)象的GC分代年齡,這些信息將保存在對(duì)象的對(duì)象頭中
undefined下面將是執(zhí)行init,根據(jù)編寫的代碼對(duì)對(duì)象進(jìn)行初始化,對(duì)象創(chuàng)建完成
3. Java引用類型
Java中將引用分為了四種類型:強(qiáng)引用(Strong Reference),軟引用(Soft Reference),弱引用(Weak Reference),虛引用(Phantom Reference)。
- 強(qiáng)引用:指的是類似
Object obj=new Object()這樣顯示聲明的對(duì)象引用,是最普遍存在的引用,只要強(qiáng)引用還在,GC永遠(yuǎn)不會(huì)回收掉被引用的對(duì)象,即使拋出OutOfMemmoryError,使程序終止。 - 軟引用:用來描述一些還有用但非必需的對(duì)象。對(duì)于軟引用關(guān)聯(lián)的對(duì)象,在系統(tǒng)即將發(fā)生OOM錯(cuò)誤之前,將會(huì)對(duì)這些對(duì)象進(jìn)行回收,如果這次回收還沒有足夠的內(nèi)存,才會(huì)拋出內(nèi)存溢出異常??梢允褂肧oftReference類來實(shí)現(xiàn)軟引用??梢允褂密浺脕順?gòu)建緩存。
- 弱引用:用來描述非必須對(duì)象,優(yōu)先級(jí)比軟引用要低,在垃圾收集器工作時(shí),無論當(dāng)前內(nèi)存是否足夠,都會(huì)回收掉只被弱引用關(guān)聯(lián)的對(duì)象
- 虛引用:最弱的一種引用關(guān)系,一個(gè)對(duì)象是否有虛引用的存在,不會(huì)對(duì)其生存時(shí)間構(gòu)成影響,也無法通過虛引用來獲取一個(gè)對(duì)象實(shí)例。為一個(gè)對(duì)象設(shè)置虛引用關(guān)聯(lián)的唯一目的就是能在這個(gè)對(duì)象被收集器回收時(shí)收到一個(gè)系統(tǒng)通知。
| 引用類型 | 被垃圾回收時(shí)間 | 用途 | 生存時(shí)間 |
|---|---|---|---|
| 強(qiáng)引用 | 從來不會(huì) | 對(duì)象的一般狀態(tài) | JVM停止運(yùn)行時(shí)終止 |
| 軟引用 | 在內(nèi)存不足時(shí) | 對(duì)象緩存 | 內(nèi)存不足時(shí)終止 |
| 弱引用 | 在垃圾回收時(shí) | 對(duì)象緩存 | gc運(yùn)行后終止 |
| 虛引用 | Unknown | Unknown | Unknown |
4. 垃圾檢測(cè)
垃圾回收(Garbage Collection)是JVM垃圾回收器提供的一種在空閑時(shí)間,不定時(shí)回收無任何引用對(duì)象占用的內(nèi)存空間的一種機(jī)制。那么如何判定一個(gè)對(duì)象已經(jīng)沒有任何引用了呢?
4.1 引用計(jì)數(shù)法
每個(gè)對(duì)象都有一個(gè)引用計(jì)數(shù)器,當(dāng)一個(gè)對(duì)象被創(chuàng)建初始化后,該數(shù)字就為1。每當(dāng)別的地方引用它時(shí),計(jì)數(shù)器就會(huì)加1。當(dāng)引用失效(如超出作用域,引用指向新的對(duì)象等),計(jì)數(shù)器就會(huì)減1。如果對(duì)象的引用計(jì)數(shù)為0,則就會(huì)被GC回收。
引用計(jì)數(shù)的優(yōu)點(diǎn)是執(zhí)行簡(jiǎn)單、判定效率高。缺點(diǎn)是無法解決對(duì)象之間的循環(huán)引用問題。
# python簡(jiǎn)單演示循環(huán)引用
class C():
def __init__(self):
print('內(nèi)存地址是:%s' % str(hex(id(self))))
def test():
c1 = C()
c2 = C()
c1.t = c2 # &c2的引用計(jì)數(shù)加1,變?yōu)?
c2.t = c1 # &c1的引用計(jì)數(shù)加1,變?yōu)?
del c1 # c1指向的對(duì)象引用計(jì)數(shù)減1,變?yōu)?
del c2 # c2指向的對(duì)象引用數(shù)減1,變?yōu)?
test()
# 即使是將當(dāng)前對(duì)象的引用刪除,由于原來c1和c2對(duì)象中還引用著彼此,所以引用計(jì)數(shù)都不為0,無法被GC回收
4.2 可達(dá)性分析算法
Java通常采用可達(dá)性分析(Reachability Analysis)來判定對(duì)象是否存活的。它是從離散數(shù)學(xué)中的圖論引入的。
基本思路是:先找到一組對(duì)象作為GC Roots(根節(jié)點(diǎn)),然后從根節(jié)點(diǎn)開始遍歷,遍歷結(jié)束后,如果發(fā)現(xiàn)某個(gè)對(duì)象與GC Roots沒有任何引用鏈相連(即該對(duì)象不可達(dá)),就證明該對(duì)象就是不可用的垃圾對(duì)象,GC會(huì)在接下來清除它們。

即使是循環(huán)引用的對(duì)象,如果與根節(jié)點(diǎn)沒有引用鏈,依然會(huì)被GC回收。
以下對(duì)象可以作為GC Roots:
- 虛擬機(jī)棧(棧幀中的本地變量表)中的引用的對(duì)象
- 方法區(qū)中的類靜態(tài)屬性以及常量引用的對(duì)象
- 本地方法棧中Native方法引用的對(duì)象
- 存活的線程
在使用可達(dá)性分析遍歷對(duì)象圖的時(shí)候,有幾個(gè)關(guān)鍵點(diǎn)需要注意:
- GC停頓:在整個(gè)分析期間不能出現(xiàn)對(duì)象引用關(guān)系還在不斷變化的情況,所以在GC進(jìn)行的時(shí)候必須要停頓所有的線程(Stop The World),停頓的位置稱為安全點(diǎn)(Safepoint),一般在循環(huán)的末尾、方法返回前、拋出異常的位置等。如果發(fā)生GC的時(shí)候,線程還沒有執(zhí)行到一個(gè)安全點(diǎn),線程繼續(xù)執(zhí)行,到達(dá)下一個(gè)安全點(diǎn)的的時(shí)候暫停,然后等待GC;
- finialize():在可達(dá)性分析中不可達(dá)的對(duì)象,真正宣判它的死亡,需要兩次標(biāo)記過程:
- 如果對(duì)象在進(jìn)行可達(dá)性分析過后沒有與GC Roots相連,那么它會(huì)被第一次標(biāo)記并且進(jìn)行第一次篩選,篩選的條件是此對(duì)象有沒有必要執(zhí)行finalize()方法。當(dāng)對(duì)象沒有覆蓋finalize方法或者已經(jīng)被JVM調(diào)用過,該對(duì)象會(huì)被視作“沒有必要執(zhí)行”。
- 如果對(duì)象被判定為有必要finalize()方法,那么這個(gè)對(duì)象將會(huì)被放置在一個(gè)F-Queue隊(duì)列中,并在稍后由一條由虛擬機(jī)自動(dòng)建立的、低優(yōu)先級(jí)的Finalizer線程去執(zhí)行finalize()方法。由于finalize()只會(huì)被系統(tǒng)調(diào)用一次,這是對(duì)象完成“自我救贖”的最后一次機(jī)會(huì)。稍后GC將對(duì)F-Queue中的對(duì)象進(jìn)行第二次小規(guī)模的標(biāo)記,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中讓該對(duì)象重新引用鏈上的任何一個(gè)對(duì)象建立關(guān)聯(lián)即可。而如果對(duì)象這時(shí)還沒有關(guān)聯(lián)到任何鏈上的引用,那它就會(huì)被回收掉。
- 建議盡量不要去使用finalize()方法。
5. 垃圾回收
5.1 標(biāo)記-清除(Mark-Sweep)

標(biāo)記-清除算法是最基礎(chǔ)的收集算法。它分為兩個(gè)階段:
- 標(biāo)記:標(biāo)記階段的任務(wù)就是標(biāo)記出所有需要被回收的對(duì)象
- 清除:回收被標(biāo)記對(duì)象所占用的內(nèi)存空間
優(yōu)點(diǎn):不需要移動(dòng)對(duì)象,僅需要對(duì)不存活的對(duì)象進(jìn)行處理,在對(duì)象存活率較高的場(chǎng)景下極為高效
缺點(diǎn):
- 效率問題:標(biāo)記和清除的效率都不高,需要維護(hù)一張空閑列表
- 空間問題:標(biāo)記清除后會(huì)產(chǎn)生大量不連續(xù)的內(nèi)存碎片,當(dāng)分配大對(duì)象時(shí),因?yàn)檎也坏阶銐虻倪B續(xù)內(nèi)存空間而不得不提前觸發(fā)另一次GC
5.2 標(biāo)記-整理(Mark-Compact)

與標(biāo)記-清除法類似,但標(biāo)記過后不是對(duì)可回收對(duì)象進(jìn)行清理, 而是將所有存活的對(duì)象都向一段,然后直接清理掉邊界以外的內(nèi)存。
優(yōu)點(diǎn):經(jīng)過整理過后,新對(duì)象的分配只需要指針碰撞即可完成,而且不會(huì)再有碎片問題
缺點(diǎn):需要將所有的對(duì)象都拷貝到一個(gè)新的地址,并且更新引用地址,GC停頓較長
5.3 復(fù)制(Copying)

該算法的提出是為了解決句柄開銷和內(nèi)存碎片問題。它將可用內(nèi)存分為大小相等的兩塊區(qū)域,每次只使用其中一塊。當(dāng)一塊中的內(nèi)存用完了,就將還存活的對(duì)象復(fù)制到另外一塊區(qū)域上面,然后將使用過的內(nèi)存空間一次性清理掉。
優(yōu)點(diǎn):
- 標(biāo)記和復(fù)制階可以同時(shí)執(zhí)行
- 每次是對(duì)整塊半?yún)^(qū)進(jìn)行回收,在對(duì)象存活率較低的場(chǎng)景下效率較高
- 分配對(duì)象時(shí)不用考慮碎片問題
缺點(diǎn):實(shí)際可用內(nèi)存縮小為原來的一半
5.4 分代回收(Generational Collection)
JVM中采用的是分代回收,它根據(jù)對(duì)象的存活周期將內(nèi)存區(qū)域分為新生代和老年代。
新生代中:對(duì)象生命周期短,每次GC時(shí)都有大批對(duì)象死去,只有少量存活,比較適用于復(fù)制方法。
老年代:對(duì)象存活時(shí)間極長,比較實(shí)用于標(biāo)記-清理或標(biāo)記-整理方法。
Python中也采用分代回收方法,將對(duì)象分為0、1、2代,可參照文章了解。
6. Java中的分代回收
JVM中的堆內(nèi)存按照GC的角度可分為新生代和和老年代,新生代又可以分為三個(gè)部分:Eden和兩個(gè)Survivor區(qū)(Survivor0、Survivor1)。

新生代GC:Minor GC,非常頻繁,回收速度比較快
老年代GC:Major GC,一般會(huì)伴隨著Minor GC,對(duì)整個(gè)堆內(nèi)存做一次GC,所以也稱Full GC,頻次較低,速度較慢
6.1 新生代
幾乎所有新創(chuàng)建的對(duì)象都是放在了年輕代。新生代在GC時(shí),采用的是復(fù)制算法,由于新生代中的對(duì)象生命周期大都很短,所以并不需要按照1:1的比例來劃分內(nèi)存空間,而是將內(nèi)存劃分為較大的Eden區(qū),和兩塊較小的Survivor區(qū),三者的比例一般是8:1:1,可以通過-XX:SurvivorRatio設(shè)置。
大部分對(duì)象是在Eden中生成。GC時(shí)大致的過程如下:
- 當(dāng)創(chuàng)建新的對(duì)象時(shí),如果Eden空間不足時(shí),會(huì)觸發(fā)一次Minor GC?;厥諘r(shí),先將Eden區(qū)的存活對(duì)象復(fù)制到S0區(qū)
- 當(dāng)再次觸發(fā)Minor GC時(shí),會(huì)將Eden和S0區(qū)存活的對(duì)象復(fù)制到S1區(qū),清空Eden和S0區(qū)
- 每次Minor GC時(shí),都會(huì)對(duì)Eden和其中一個(gè)Survivor區(qū)域操作,將存活的對(duì)象放入到另外一個(gè)Survivor區(qū)中,如此反復(fù)。
- 如果另外一塊Survior區(qū)沒有足夠空間存放上一次Mionr GC下存活的對(duì)象,這些對(duì)象將存放到老年代(這種稱之為分配擔(dān)保)
- 每當(dāng)對(duì)象在Survivor區(qū)經(jīng)歷一次GC存活下來,它的年齡將加1,如果年齡達(dá)到N(一般是15)歲,就會(huì)移動(dòng)到老年代中
6.2 老年代
老年代中存在的一般都是生命周期比較長的對(duì)象,它的空間也比新生代大很多(一般是2:1),一般采用的標(biāo)記-整理方法。需要注意的有以下幾點(diǎn):
如上一小節(jié)所說,在新生代長期存活的對(duì)象將會(huì)被放入到老年代中
大對(duì)象(很長的字符串、長數(shù)組等)直接進(jìn)入老年代,大對(duì)象可能導(dǎo)致內(nèi)存還有不少空間時(shí)就提前觸發(fā)Minor GC以獲取足夠的連續(xù)空間,也可以避免在Eden和Survivor區(qū)之間發(fā)生大量的內(nèi)存復(fù)制。大對(duì)象的閾值可以通過參數(shù)設(shè)置
Survivor空間中,如果相同年齡的對(duì)象大小總和,大于Survivor空間的一半,年齡大于等于該年齡的對(duì)象可以直接進(jìn)入老年代
當(dāng)老年代中的空間不足不足以存放即將升入老年代的對(duì)象時(shí),會(huì)觸發(fā)一次Full GC。
-
發(fā)生Minor GC之前,由于可能存在大量對(duì)象存活的情況(假如100%存活),虛擬機(jī)會(huì)檢查老年代中剩余空間是否大于新生代所有對(duì)象總空間,如果這個(gè)條件成立,Minor GC可以確保是安全的。如果不成立,虛擬機(jī)會(huì)查看HandlePromotionFailure設(shè)置值是否允許擔(dān)保失敗。如果允許,那么會(huì)繼續(xù)檢查老年代中最大的可用連續(xù)空間,是否大于歷次晉升到老年代對(duì)象的平均大小。如果大于,將嘗試Minor GC。如果小于、或者設(shè)置中不允許擔(dān)保失敗,或者在Minor GC時(shí)擔(dān)保失敗,則會(huì)發(fā)生一次Full GC。
總結(jié)
以上粗淺的介紹了JAVA中的GC機(jī)制。由于每次GC都會(huì)造成GC停頓,所以在開發(fā)過程中,盡可能減少GC的開銷。比如盡可能不要顯式調(diào)用System.gc()、字符串拼接時(shí)盡量使用StringBuffer、能使用基本類型的地方不要使用包裝類等。
