Java 理论与实践: 垃圾收集简史
垃圾收集的好處是無可爭辯的 ―― 可靠性提高、使內(nèi)存管理與類接口設(shè)計(jì)分離,并使開發(fā)者減少了跟蹤內(nèi)存管理錯(cuò)誤的時(shí)間。著名的懸空指針和內(nèi)存泄漏問題在 Java 程序中再也不會(huì)發(fā)生了(Java 程序可能會(huì)出現(xiàn)某種形式的內(nèi)存泄漏,更精確地說是非故意的對(duì)象保留,但是這是一個(gè)不同的問題)。不過,垃圾收集不是沒有代價(jià)的 ―― 其中包括對(duì)性能的影響、暫停、配置復(fù)雜性和不確定的結(jié)束 (nondeterministic finalization)。
一個(gè)理想的垃圾收集實(shí)現(xiàn)應(yīng)該是完全不可見的 ―― 沒有垃圾收集暫停、沒有因?yàn)槔占a(chǎn)生的 CPU 時(shí)間損失、垃圾收集器不會(huì)與虛擬內(nèi)存或者緩存有負(fù)面的互動(dòng),并且堆不需要大于應(yīng)用程序的?駐留空間(即堆占用)。當(dāng)然,沒有十全十美的垃圾收集器,但是垃圾收集器在過去十年中已經(jīng)有了很大改進(jìn)。
選項(xiàng)與選擇
1.3 JDK 包括三種不同的垃圾收集策略,1.4.1 JDK 包括六種垃圾收集策略以及 12 種以上用于配置和優(yōu)化垃圾收集的命令行選項(xiàng)。它們有什么不同?為什么需要有這么多選項(xiàng)?
不同的垃圾收集實(shí)現(xiàn)使用不同的策略來識(shí)別和收回不可到達(dá)的對(duì)象,它們與用戶程序和調(diào)度器以不同的方式互動(dòng)。不同類型的應(yīng)用程序?qū)τ诶占胁煌囊?―― 實(shí)時(shí)應(yīng)用程序會(huì)將要求收集暫停的持續(xù)時(shí)間短并且有限制,而企業(yè)應(yīng)用程序可能允許更長時(shí)間和可預(yù)測性更低的暫停以獲得更高的吞吐能力。
回頁首
垃圾收集如何工作?
有幾種垃圾收集的基本策略:引用計(jì)數(shù)、標(biāo)記-清除、標(biāo)記-整理 (mark-compact) 和復(fù)制。此外,一些算法可以以?增量?方式完成它們的工作(不需要一次收集整個(gè)堆,使得收集暫停時(shí)間更短),一些算法可以在用戶程序運(yùn)行時(shí)運(yùn)行(?并發(fā)收集)。其他算法則必須在用戶程序暫停時(shí)一次進(jìn)行整個(gè)收集(即所謂的?stop-the-world收集器)。最后,還有混合型的收集器,如 1.2 和以后版本的 JDK 使用的分代收集器,它對(duì)堆的不同區(qū)域使用不同的收集算法。
在對(duì)垃圾收集算法進(jìn)行評(píng)價(jià)時(shí),我們可能要考慮以下所有標(biāo)準(zhǔn):
- 暫停時(shí)間。收集器是否停止所有工作來進(jìn)行垃圾收集?要停止多長時(shí)間?暫停是否有時(shí)間限制?
- 暫停的可預(yù)測性。垃圾收集暫停是否規(guī)劃為在用戶程序方便而不是垃圾收集器方便的時(shí)間發(fā)生?
- CPU 占用。總的可用 CPU 時(shí)間用在垃圾收集上的百分比是多少?
- 內(nèi)存大小。許多垃圾收集算法需要將堆分割成獨(dú)立的內(nèi)存空間,其中一些空間在某些時(shí)刻對(duì)用戶程序是不可訪問的。這意味著堆的實(shí)際大小可能比用戶程序的最大堆駐留空間要大幾倍。
- 虛擬內(nèi)存交互。在具有有限物理內(nèi)存的系統(tǒng)上,一個(gè)完整的垃圾收集在垃圾收集過程中可能會(huì)錯(cuò)誤地將非常駐頁面放到內(nèi)存中來進(jìn)行檢查。因?yàn)轫撁驽e(cuò)誤的成本很高,所以垃圾收集器正確管理引用的區(qū)域性 (locality) 是很必要的。
- 緩存交互。即使在整個(gè)堆可以放到主內(nèi)存中的系統(tǒng)上 ―― 實(shí)際上幾乎所有 Java 應(yīng)用程序都可以做到這一點(diǎn),垃圾收集也常常會(huì)有將用戶程序使用的數(shù)據(jù)沖出緩存的效果,從而影響用戶程序的性能。
- 對(duì)程序區(qū)域性的影響。雖然一些人認(rèn)為垃圾收集器的工作只是收回不可到達(dá)的內(nèi)存,但是其他人認(rèn)為垃圾收集器還應(yīng)該盡量改進(jìn)用戶程序的引用區(qū)域性。整理收集器和復(fù)制收集器在收集過程中重新安排對(duì)象,這有可能改進(jìn)區(qū)域性。
- 編譯器和運(yùn)行時(shí)影響。一些垃圾收集算法要求編譯器或者運(yùn)行時(shí)環(huán)境的重要配合,如當(dāng)進(jìn)行指針分配時(shí)更新引用計(jì)數(shù)。這增加了編譯器的工作,因?yàn)樗仨毶蛇@些簿記指令,同時(shí)增加了運(yùn)行時(shí)環(huán)境的開銷,因?yàn)樗仨殘?zhí)行這些額外的指令。這些要求對(duì)性能有什么影響呢?它是否會(huì)干擾編譯時(shí)優(yōu)化呢?
不管選擇什么算法,硬件和軟件的發(fā)展使垃圾收集更具有實(shí)用性。20 世紀(jì) 70 和 80 年代的經(jīng)驗(yàn)研究表明,對(duì)于大型 Lisp 程序,垃圾收集消耗 25% 到 40% 的運(yùn)行時(shí)。垃圾收集還不能做到完全不可見,這肯定還有很長的路要走。
回頁首
基本算法
所有垃圾收集算法所面臨的問題是相同的 ―― 找出由分配器分配的,但是用戶程序不可到達(dá)的內(nèi)存塊。不可到達(dá)是什么意思?可以以兩種方式之一訪問內(nèi)存塊 ―― 或者用戶程序在?根 (root)中有對(duì)這一內(nèi)存塊的引用,或者在另一個(gè)可到達(dá)的塊中有對(duì)這個(gè)塊的引用。在 Java 程序中,根是對(duì)靜態(tài)變量中或者活躍的堆棧框架上的本地變量中所包含的對(duì)象的引用。可到達(dá)的對(duì)象集是指向關(guān)系下根集的傳遞閉包。
引用計(jì)數(shù)
最直觀的垃圾收集策略是引用計(jì)數(shù)。引用計(jì)數(shù)很簡單,但是需要編譯器的重要配合,并且增加了賦值?函數(shù) (mutator)的開銷(這個(gè)術(shù)語是針對(duì)用戶程序的,是從垃圾收集器的角度來看的)。每一個(gè)對(duì)象都有一個(gè)關(guān)聯(lián)的引用計(jì)數(shù) ―― 對(duì)該對(duì)象的活躍引用的數(shù)量。如果對(duì)象的引用計(jì)數(shù)是零,那么它就是垃圾(用戶程序不可到達(dá)它),并可以回收。每次修改指針引用時(shí)(比如通過賦值語句),或者當(dāng)引用超出范圍時(shí),編譯器必須生成代碼以更新引用的對(duì)象的引用計(jì)數(shù)。如果對(duì)象的引用計(jì)數(shù)變?yōu)榱?#xff0c;那么運(yùn)行時(shí)就可以立即收回這個(gè)塊(并且減少被回收的塊所引用的所有塊的引用計(jì)數(shù)),或者將它放到遲延收集隊(duì)列中。
許多 ANSI C++ 庫類,比如?string?,使用了引用計(jì)數(shù)來提供垃圾收集的特性。通過重載賦值操作符并利用 C++ 作用域提供的確定性結(jié)束,C++ 程序可以將?string?類當(dāng)成是被收集的垃圾那樣使用。引用計(jì)數(shù)很簡單,很適用于增量收集,收集過程一般會(huì)得到好的引用區(qū)域性,但是出于幾個(gè)理由,它很少在生產(chǎn)垃圾收集器中使用,如它不能回收不可到達(dá)的循環(huán)結(jié)構(gòu)(彼此直接或者間接引用的幾個(gè)對(duì)象,如循環(huán)鏈接的列表或者包含指向父節(jié)點(diǎn)的反向指針的樹)。
跟蹤收集器
JDK 中的標(biāo)準(zhǔn)垃圾收集器都沒有使用引用計(jì)數(shù),相反,它們都使用某種形式的?跟蹤收集器 (tracing collector)。跟蹤收集器停止所有工作(盡管不需要在收集的整個(gè)過程中都這樣)并開始跟蹤對(duì)象,從根集開始沿著引用跟蹤,直到檢查了所有可到達(dá)的對(duì)象。可以在程序注冊(cè)表中、每一個(gè)線程堆棧中的(基于堆棧的)局部變量中以及靜態(tài)變量中找到根。
標(biāo)記-清除收集器
最早由 Lisp 的發(fā)明人 John McCarthy 于 1960 年提出的最基本的跟蹤收集器形式是?標(biāo)記―清除收集器,它停止所有工作,收集器從根開始訪問每一個(gè)活躍的節(jié)點(diǎn),標(biāo)記它所訪問的每一個(gè)節(jié)點(diǎn)。走過所有引用后,收集就完成了,然后就對(duì)堆進(jìn)行清除(即對(duì)堆中的每一個(gè)對(duì)象進(jìn)行檢查),所有沒有標(biāo)記的對(duì)象都作為垃圾回收并返回空閑列表。圖 1 展示了垃圾收集之前的堆,陰影塊是垃圾,因?yàn)橛脩舫绦虿荒艿竭_(dá)它們:
圖 1. 可到達(dá)和不可到達(dá)的對(duì)象
標(biāo)記-清除實(shí)現(xiàn)起來很簡單,可以容易地回收循環(huán)的結(jié)構(gòu),并且不像引用計(jì)數(shù)那樣增加編譯器或者賦值函數(shù)的負(fù)擔(dān)。但是它也有不足 ―― 收集暫停可能會(huì)很長,在清除階段整個(gè)堆都是可訪問的,這對(duì)于可能有頁面交換的堆的虛擬內(nèi)存系統(tǒng)有非常負(fù)面的性能影響。
標(biāo)記-清除的最大問題是,每一個(gè)活躍的(即已分配的)對(duì)象,不管是不是可到達(dá)的,在清除階段都是可以訪問的。因?yàn)楹芏鄬?duì)象都可能成為垃圾,這意思著收集器花費(fèi)大量精力去檢查并處理垃圾。標(biāo)記-清除收集器還容易使堆產(chǎn)生碎片,這會(huì)產(chǎn)生區(qū)域性問題并可以造成分配失敗,即使看來有足夠的自由內(nèi)存可用。
復(fù)制收集器
在另一種形式的跟蹤收集器 ――?復(fù)制收集器中,堆被分成兩個(gè)大小相等的半空間,其中一個(gè)包含活躍的數(shù)據(jù),另一個(gè)未使用。當(dāng)活躍的空間占滿以后,程序就會(huì)停止,活躍的對(duì)象被從活躍的空間復(fù)制到不活躍的空間中。空間的角色就會(huì)轉(zhuǎn)換,原來不活躍的空間成為了新的活躍空間。
復(fù)制收集的優(yōu)點(diǎn)是只訪問活躍的對(duì)象,這意味著不會(huì)檢查垃圾對(duì)象,也不需要將它們頁交換到內(nèi)存中或者送到緩存中。復(fù)制收集器的收集周期時(shí)間是由活躍對(duì)象的數(shù)量決定的。不過,復(fù)制收集器因?yàn)橐獙?shù)據(jù)從一個(gè)空間復(fù)制到另一個(gè)空間、調(diào)整所有引用以指向新備份而增加了成本。特別是,長壽的對(duì)象在每次收集時(shí)都要來回復(fù)制。
堆整理
復(fù)制收集器有另一個(gè)好處,活躍對(duì)象集會(huì)被整理到堆的底部。這不僅改進(jìn)了用戶程序的引用區(qū)域性并消除了堆碎片,而且極大地減少了對(duì)象分配的成本 ―― 對(duì)象分配變成了在堆頂部的指針上增加指針。不需要維護(hù)自由列表或者后備列表,或者使用性能最佳或者第一合適的算法 ―― 分配?N?字節(jié)就是在堆頂部指針上加?N?并返回前一個(gè)值這么簡單,如清單 1 所示:
清單 1. 復(fù)制收集器中廉價(jià)的內(nèi)存分配
void *malloc(int n) { if (heapTop - heapStart < n)doGarbageCollection();void *wasStart = heapStart;heapStart += n;return wasStart; }為非垃圾收集語言實(shí)現(xiàn)了復(fù)雜內(nèi)存管理方案的開發(fā)人員可能會(huì)對(duì)復(fù)制收集器中廉價(jià)的內(nèi)存分配感到吃驚 ―― 就是指針加法這么簡單。以前的 JVM 實(shí)現(xiàn)沒有使用復(fù)制收集器 ―― 這可能是對(duì)象分配是昂貴的這一想法是如此普遍的原因之一,開發(fā)人員仍然下意識(shí)地假設(shè)分配成本與其他語言(如 C)類似,而事實(shí)上在 Java 運(yùn)行時(shí)中可能要廉價(jià)得多。不但是分配成本減少了,而且對(duì)于在下次收集之前成為垃圾的對(duì)象,解除分配的成本為零,因?yàn)榧炔粫?huì)訪問也不會(huì)復(fù)制垃圾對(duì)象。
標(biāo)記-整理收集器
復(fù)制算法的性能很優(yōu)異,但是它有一個(gè)缺點(diǎn)是需要兩倍于標(biāo)記-清除收集器所需要的內(nèi)存。?標(biāo)記-整理?算法結(jié)合了標(biāo)記-清除和復(fù)制,避免了這個(gè)問題,代價(jià)是增加了一些收集復(fù)雜性。與標(biāo)記-清除類似,標(biāo)記-整理是兩階段過程,在標(biāo)記階段訪問并標(biāo)記每個(gè)活躍對(duì)象。然后,復(fù)制標(biāo)記的對(duì)象,使所有活躍對(duì)象被整理到堆的底部。如果每一次收集時(shí)進(jìn)行徹底的整理,那么得到的堆就類似于復(fù)制收集器的結(jié)果 ―― 在堆的活躍部分與自由部分有明確的界線,這樣分配成本與復(fù)制收集器相當(dāng)。長壽的對(duì)象趨向于沉在堆的底部,這樣就不會(huì)像在復(fù)制收集器中那樣反復(fù)復(fù)制它們。
回頁首
選擇哪一種呢?
那么 JDK 使用了哪種方式進(jìn)行垃圾收集呢?在某種意義上,使用了所有的方式。早期的 JDK 使用了單線程的標(biāo)記-清除或者標(biāo)記-清除-整理收集器。1.2 及以后的 JDK 使用了混合的方式,稱為?分代收集,其中根據(jù)對(duì)象的年齡將堆分為幾個(gè)部分,不同的代是用不同的收集算法收集的。
分代收集證明是非常高效的,盡管在運(yùn)行時(shí)它需要更多的簿記。在下一個(gè)月的?Java 理論與實(shí)踐?中,除了介紹 1.4.1 JVM 提供的所有其他垃圾收集選項(xiàng)之外,我們還將探討分代收集是如何工作的以及 1.4.1 JVM 是如何使用它的。在下下篇文章中,我們將分析垃圾收集對(duì)性能的影響,包括揭示與內(nèi)存管理有關(guān)的性能神話。
from:?http://www.ibm.com/developerworks/cn/java/j-jtp10283/
總結(jié)
以上是生活随笔為你收集整理的Java 理论与实践: 垃圾收集简史的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Spring注解标签详解@Autowir
- 下一篇: 浅谈JVM的实现与垃圾回收机制