天天看點

面向GC的Java程式設計

Java程式員在編碼過程中通常不需要考慮記憶體問題,JVM經過高度優化的GC機制大部分情況下都能夠很好地處理堆(Heap)的清理問題。以至于許多Java程式員認為,我隻需要關心何時建立對象,而回收對象,就交給GC來做吧!甚至有人說,如果在程式設計過程中頻繁考慮記憶體問題,是一種退化,這些事情應該交給編譯器,交給虛拟機來解決。

這話其實也沒有太大問題,的确,大部分場景下關心記憶體、GC的問題,顯得有點“杞人憂天”了,高老爺說過:

過早優化是萬惡之源。

但另一方面,**什麼才是“過早優化”?**

If we could do things right for the first time, why not?

另外,不要指望GC優化的這些技巧,可以對應用性能有成倍的提高,特别是對I/O密集型的應用,或是實際落在YoungGC上的優化,可能效果隻是幫你減少那麼一點YoungGC的頻率。

但我認為,**優秀程式員的價值,不在于其所掌握的幾招屠龍之術,而是在細節中見真著**,就像前面說的,**如果我們可以一次把事情做對,并且做好,在允許的範圍内盡可能追求卓越,為什麼不去做呢**?

大部分GC算法,都将堆記憶體做分代(Generation)處理,但是為什麼要分代呢,又為什麼不叫記憶體分區、分段,而要用面向時間、年齡的“代”來表示不同的記憶體區域?

GC分代的**基本假設**是:

絕大部分對象的生命周期都非常短暫,存活時間短。

而這些短命的對象,恰恰是GC算法需要首先關注的。是以在大部分的GC中,YoungGC(也稱作MinorGC)占了絕大部分,對于負載不高的應用,可能跑了數個月都不會發生FullGC。

基于這個前提,在編碼過程中,我們應該**盡可能地縮短對象的生命周期**。在過去,配置設定對象是一個比較重的操作,是以有些程式員會盡可能地減少new對象的次數,嘗試減小堆的配置設定開銷,減少記憶體碎片。

但是,短命對象的建立在JVM中比我們想象的性能更好,是以,不要吝啬new關鍵字,大膽地去new吧。

當然前提是不做無謂的建立,對象建立的速率越高,那麼GC也會越快被觸發。

結論:

配置設定小對象的開銷分享小,不要吝啬去建立。 GC最喜歡這種小而短命的對象。 讓對象的生命周期盡可能短,例如在方法體内建立,使其能盡快地在YoungGC中被回收,不會晉升(romote)到年老代(Old Generation)。

基于大部分對象都是小而短命,并且不存在多線程的資料競争。這些小對象的配置設定,會優先線上程私有的 TLAB 中配置設定,TLAB中建立的對象,不存在鎖甚至是CAS的開銷。

TLAB占用的空間在Eden Generation。

當對象比較大,TLAB的空間不足以放下,而JVM又認為目前線程占用的TLAB剩餘空間還足夠時,就會直接在Eden Generation上配置設定,此時是存在并發競争的,是以會有CAS的開銷,但也還好。

當對象大到Eden Generation放不下時,JVM隻能嘗試去Old Generation配置設定,這種情況需要盡可能避免,因為一旦在Old Generation配置設定,這個對象就隻能被Old Generation的GC或是FullGC回收了。

GC算法在掃描存活對象時通常需要從ROOT節點開始,掃描所有存活對象的引用,建構出對象圖。

不可變對象對GC的優化,主要展現在Old Generation中。

可以想象一下,如果存在Old Generation的對象引用了Young Generation的對象,那麼在每次YoungGC的過程中,就必須考慮到這種情況。

Hotspot JVM為了提高YoungGC的性能,避免每次YoungGC都掃描Old Generation中的對象引用,采用了 卡表(Card Table) 的方式。

簡單來說,當Old Generation中的對象發生對Young Generation中的對象産生新的引用關系或釋放引用時,都會在卡表中響應的标記上标記為髒(dirty),而YoungGC時,隻需要掃描這些dirty的項就可以了。

可變對象對其它對象的引用關系可能會頻繁變化,并且有可能在運作過程中持有越來越多的引用,特别是容器。這些都會導緻對應的卡表項被頻繁标記為dirty。

而不可變對象的引用關系非常穩定,在掃描卡表時就不會掃到它們對應的項了。

注意,這裡的不可變對象,不是指僅僅自身引用不可變的<code>final</code>對象,而是真正的**Immutable Objects**。

早期的很多Java資料中都會提到在方法體中将一個變量置為null能夠優化GC的性能,類似下面的代碼:

事實上這種做法對GC的幫助微乎其微,有時候反而會導緻代碼混亂。

我記得幾年前撒迦在HLL VM小組中詳細論述過這個問題,原帖我沒找到,結論基本就是:

在一個非常大的方法體内,對一個較大的對象,将其引用置為null,某種程度上可以幫助GC。 大部分情況下,這種行為都沒有任何好處。

是以,還是早點放棄這種“優化”方式吧。

GC比我們想象的更聰明。

在很多Java資料上都有下面兩個奇技淫巧:

通過<code>Thread.yield()</code>讓出CPU資源給其它線程。 通過<code>System.gc</code>()觸發GC。

事實上JVM從不保證這兩件事,而<code>System.gc</code>()在JVM啟動參數中如果允許顯式GC,則會**觸發FullGC**,對于響應敏感的應用來說,幾乎等同于自殺。

So,讓我們牢記兩點:

Never use <code>Thread.yield()</code>。 Never use <code>System.gc</code>()。除非你真的需要回收Native Memory。

第二點有個Native Memory的例外,如果你在以下場景:

使用了NIO或者NIO架構(Mina/Netty)

使用了DirectByteBuffer配置設定位元組緩沖區

使用了MappedByteBuffer做記憶體映射

由于**Native Memory隻能通過FullGC(或是CMS GC)回收**,是以除非你非常清楚這時真的有必要,否則不要輕易調用<code>System.gc</code>(),且行且珍惜。

另外為了防止某些架構中的<code>System.gc</code>調用(例如NIO架構、Java RMI),建議在啟動參數中加上<code>-XX:+DisableExplicitGC</code>來禁用顯式GC。

這個參數有個巨大的坑,如果你禁用了<code>System.gc</code>(),那麼上面的3種場景下的記憶體就無法回收,可能造成OOM,如果你使用了CMS GC,那麼可以用這個參數替代:<code>-XX:+ExplicitGCInvokesConcurrent</code>。

關于<code>System.gc</code>(),可以參考畢玄的幾篇文章:

<a href="http://hellojava.info/?p=56">CMS GC會不會回收Direct ByteBuffer的記憶體</a>

<a href="http://hellojava.info/?p=323">說說在Java啟動參數上我犯的錯</a>

<a href="http://hellojava.info/?p=319">java.lang.OutOfMemoryError:Map failed</a>

Java容器的一個特點就是可以動态擴充,是以通常我們都不會去考慮初始大小的設定,不夠了反正會自動擴容呗。

但是擴容不意味着沒有代價,甚至是很高的代價。

例如一些基于數組的資料結構,例如<code>StringBuilder</code>、<code>StringBuffer</code>、<code>ArrayList</code>、<code>HashMap</code>等等,在擴容的時候都需要做ArrayCopy,對于不斷增長的結構來說,經過若幹次擴容,會存在大量無用的老數組,而回收這些數組的壓力,全都會加在GC身上。

這些容器的構造函數中通常都有一個可以指定大小的參數,如果對于某些大小可以預估的容器,建議加上這個參數。

可是因為容器的擴容并不是等到容器滿了才擴容,而是有一定的比例,例如<code>HashMap</code>的擴容門檻值和負載因子(loadFactor)相關。

Google Guava架構對于容器的初始容量提供了非常便捷的工具方法,例如:

這樣我們隻要傳入預估的大小即可,容量的計算就交給Guava來做吧。

反例:

如果采用預設無參構造函數,建立一個ArrayList,不斷增加元素直到OOM,那麼在此過程中會導緻: 多次數組擴容,重新配置設定更大空間的數組 多次數組拷貝 記憶體碎片

為了減少對象配置設定開銷,提高性能,可能有人會采取對象池的方式來緩存對象集合,作為複用的手段。

但是對象池中的對象由于在運作期長期存活,大部分會晉升到Old Generation,是以無法通過YoungGC回收。

并且通常……沒有什麼效果。

對于對象本身:

如果對象很小,那麼配置設定的開銷本來就小,對象池隻會增加代碼複雜度。 如果對象比較大,那麼晉升到Old Generation後,對GC的壓力就更大了。

從線程安全的角度考慮,通常池都是會被并發通路的,那麼你就需要處理好同步的問題,這又是一個大坑,并且**同步帶來的開銷,未必比你重新建立一個對象小**。

對于對象池,唯一合适的場景就是**當池中的每個對象的建立開銷很大**時,緩存複用才有意義,例如每次new都會建立一個連接配接,或是依賴一次RPC。

比如說:

線程池 資料庫連接配接池 TCP連接配接池

即使你真的需要實作一個對象池,也請使用成熟的開源架構,例如Apache Commons Pool。

另外,使用JDK的<code>ThreadPoolExecutor</code>作為線程池,不要重複造輪子,除非當你看過AQS的源碼後認為你可以寫得比Doug Lea更好。

盡可能縮小對象的作用域,即生命周期。

如果可以在方法内聲明的局部變量,就不要聲明為執行個體變量。 除非你的對象是單例的或不變的,否則盡可能少地聲明static變量。

<code>java.lang.ref.Reference</code>有幾個子類,用于處理和GC相關的引用。JVM的引用類型簡單來說有幾種:

Strong Reference,最常見的引用

Weak Reference,當沒有指向它的強引用時會被GC回收

Soft Reference,隻當臨近OOM時才會被GC回收

Phantom Reference,主要用于識别對象被GC的時機,通常用于做一些清理工作

當你需要實作一個緩存時,可以考慮優先使用<code>WeakHashMap</code>,而不是<code>HashMap</code>,當然,更好的選擇是使用架構,例如Guava Cache。

最後,再次提醒,以上的這些未必可以對代碼有多少性能上的提升,但是熟悉這些方法,是為了幫助我們寫出更卓越的代碼,和GC更好地合作。