面向 GC 的 Java 編程
Java程序員在編碼過程中通常不需要考慮內存問題,JVM經過高度優(yōu)化的GC機制大部分情況下都能夠很好地處理堆(Heap)的清理問題。以至于許多Java程序員認為,我只需要關心何時創(chuàng)建對象,而回收對象,就交給GC來做吧!甚至有人說,如果在編程過程中頻繁考慮內存問題,是一種退化,這些事情應該交給編譯器,交給虛擬機來解決。
這話其實也沒有太大問題,的確,大部分場景下關心內存、GC的問題,顯得有點“杞人憂天”了,高老爺說過:
過早優(yōu)化是萬惡之源。
但另一方面,什么才是“過早優(yōu)化”?
If we could do things right for the first time, why not?
事實上JVM的內存模型( JMM )理應是Java程序員的基礎知識,處理過幾次JVM線上內存問題之后就會很明顯感受到,很多系統(tǒng)問題,都是內存問題。
對JVM內存結構感興趣的同學可以看下 淺析Java虛擬機結構與機制 這篇文章,本文就不再贅述了,本文也并不關注具體的GC算法,相關的文章汗牛充棟,隨時可查。
另外,不要指望GC優(yōu)化的這些技巧,可以對應用性能有成倍的提高,特別是對I/O密集型的應用,或是實際落在YoungGC上的優(yōu)化,可能效果只是幫你減少那么一點YoungGC的頻率。
但我認為,優(yōu)秀程序員的價值,不在于其所掌握的幾招屠龍之術,而是在細節(jié)中見真著,就像前面說的,如果我們可以一次把事情做對,并且做好,在允許的范圍內盡可能追求卓越,為什么不去做呢?
一、GC分代的基本假設
大部分GC算法,都將堆內存做分代(Generation)處理,但是為什么要分代呢,又為什么不叫內存分區(qū)、分段,而要用面向時間、年齡的“代”來表示不同的內存區(qū)域?
GC分代的基本假設是:
絕大部分對象的生命周期都非常短暫,存活時間短。
而這些短命的對象,恰恰是GC算法需要首先關注的。所以在大部分的GC中,YoungGC(也稱作MinorGC)占了絕大部分,對于負載不高的應用,可能跑了數(shù)個月都不會發(fā)生FullGC。
基于這個前提,在編碼過程中,我們應該盡可能地縮短對象的生命周期。在過去,分配對象是一個比較重的操作,所以有些程序員會盡可能地減少new對象的次數(shù),嘗試減小堆的分配開銷,減少內存碎片。
但是,短命對象的創(chuàng)建在JVM中比我們想象的性能更好,所以,不要吝嗇new關鍵字,大膽地去new吧。
當然前提是不做無謂的創(chuàng)建,對象創(chuàng)建的速率越高,那么GC也會越快被觸發(fā)。
結論:
分配小對象的開銷分享小,不要吝嗇去創(chuàng)建。
GC最喜歡這種小而短命的對象。
讓對象的生命周期盡可能短,例如在方法體內創(chuàng)建,使其能盡快地在YoungGC中被回收,不會晉升(romote)到年老代(Old Generation)。
二、對象分配的優(yōu)化
基于大部分對象都是小而短命,并且不存在多線程的數(shù)據(jù)競爭。這些小對象的分配,會優(yōu)先在線程私有的 TLAB 中分配,TLAB中創(chuàng)建的對象,不存在鎖甚至是CAS的開銷。
TLAB占用的空間在Eden Generation。
當對象比較大,TLAB的空間不足以放下,而JVM又認為當前線程占用的TLAB剩余空間還足夠時,就會直接在Eden Generation上分配,此時是存在并發(fā)競爭的,所以會有CAS的開銷,但也還好。
當對象大到Eden Generation放不下時,JVM只能嘗試去Old Generation分配,這種情況需要盡可能避免,因為一旦在Old Generation分配,這個對象就只能被Old Generation的GC或是FullGC回收了。
三、不可變對象的好處
GC算法在掃描存活對象時通常需要從ROOT節(jié)點開始,掃描所有存活對象的引用,構建出對象圖。
不可變對象對GC的優(yōu)化,主要體現(xiàn)在Old Generation中。
可以想象一下,如果存在Old Generation的對象引用了Young Generation的對象,那么在每次YoungGC的過程中,就必須考慮到這種情況。
Hotspot JVM為了提高YoungGC的性能,避免每次YoungGC都掃描Old Generation中的對象引用,采用了 卡表(Card Table) 的方式。
簡單來說,當Old Generation中的對象發(fā)生對Young Generation中的對象產生新的引用關系或釋放引用時,都會在卡表中響應的標記上標記為臟(dirty),而YoungGC時,只需要掃描這些dirty的項就可以了。
可變對象對其它對象的引用關系可能會頻繁變化,并且有可能在運行過程中持有越來越多的引用,特別是容器。這些都會導致對應的卡表項被頻繁標記為dirty。
而不可變對象的引用關系非常穩(wěn)定,在掃描卡表時就不會掃到它們對應的項了。
注意,這里的不可變對象,不是指僅僅自身引用不可變的final對象,而是真正的Immutable Objects。
四、引用置為null的傳說
早期的很多Java資料中都會提到在方法體中將一個變量置為null能夠優(yōu)化GC的性能,類似下面的代碼:
- List<String> list = new ArrayList<String>();
- // some code
- list = null; // help GC
事實上這種做法對GC的幫助微乎其微,有時候反而會導致代碼混亂。
我記得幾年前 @rednaxelafx 在HLL VM小組中詳細論述過這個問題,原帖我沒找到,結論基本就是:
在一個非常大的方法體內,對一個較大的對象,將其引用置為null,某種程度上可以幫助GC。
大部分情況下,這種行為都沒有任何好處。
所以,還是早點放棄這種“優(yōu)化”方式吧。
GC比我們想象的更聰明。
五、手動檔的GC
在很多Java資料上都有下面兩個奇技淫巧:
通過Thread.yield()讓出CPU資源給其它線程。
通過System.gc()觸發(fā)GC。
事實上JVM從不保證這兩件事,而System.gc()在JVM啟動參數(shù)中如果允許顯式GC,則會觸發(fā)FullGC,對于響應敏感的應用來說,幾乎等同于自殺。
So,讓我們牢記兩點:
Never use Thread.yield()。
Never use System.gc()。除非你真的需要回收Native Memory。
第二點有個Native Memory的例外,如果你在以下場景:
· 使用了NIO或者NIO框架(Mina/Netty)
· 使用了DirectByteBuffer分配字節(jié)緩沖區(qū)
· 使用了MappedByteBuffer做內存映射
由于Native Memory只能通過FullGC(或是CMS GC)回收,所以除非你非常清楚這時真的有必要,否則不要輕易調用System.gc(),且行且珍惜。
另外為了防止某些框架中的System.gc調用(例如NIO框架、Java RMI),建議在啟動參數(shù)中加上-XX:+DisableExplicitGC來禁用顯式GC。
這個參數(shù)有個巨大的坑,如果你禁用了System.gc(),那么上面的3種場景下的內存就無法回收,可能造成OOM,如果你使用了CMS GC,那么可以用這個參數(shù)替代:-XX:+ExplicitGCInvokesConcurrent。
關于System.gc(),可以參考 @bluedavy 的幾篇文章:
· CMS GC會不會回收Direct ByteBuffer的內存
· java.lang.OutOfMemoryError:Map failed
六、指定容器初始化大小
Java容器的一個特點就是可以動態(tài)擴展,所以通常我們都不會去考慮初始大小的設置,不夠了反正會自動擴容唄。
但是擴容不意味著沒有代價,甚至是很高的代價。
例如一些基于數(shù)組的數(shù)據(jù)結構,例如StringBuilder、StringBuffer、ArrayList、HashMap等等,在擴容的時候都需要做ArrayCopy,對于不斷增長的結構來說,經過若干次擴容,會存在大量無用的老數(shù)組,而回收這些數(shù)組的壓力,全都會加在GC身上。
這些容器的構造函數(shù)中通常都有一個可以指定大小的參數(shù),如果對于某些大小可以預估的容器,建議加上這個參數(shù)。
可是因為容器的擴容并不是等到容器滿了才擴容,而是有一定的比例,例如HashMap的擴容閾值和負載因子(loadFactor)相關。
Google Guava框架對于容器的初始容量提供了非常便捷的工具方法,例如:
- Lists.newArrayListWithCapacity(initialArraySize);
- Lists.newArrayListWithExpectedSize(estimatedSize);
- Sets.newHashSetWithExpectedSize(expectedSize);
- Maps.newHashMapWithExpectedSize(expectedSize);
這樣我們只要傳入預估的大小即可,容量的計算就交給Guava來做吧。
反例:
如果采用默認無參構造函數(shù),創(chuàng)建一個ArrayList,不斷增加元素直到OOM,那么在此過程中會導致:
多次數(shù)組擴容,重新分配更大空間的數(shù)組
多次數(shù)組拷貝
內存碎片
七、對象池
為了減少對象分配開銷,提高性能,可能有人會采取對象池的方式來緩存對象集合,作為復用的手段。
但是對象池中的對象由于在運行期長期存活,大部分會晉升到Old Generation,因此無法通過YoungGC回收。
并且通常……沒有什么效果。
對于對象本身:
如果對象很小,那么分配的開銷本來就小,對象池只會增加代碼復雜度。
如果對象比較大,那么晉升到Old Generation后,對GC的壓力就更大了。
從線程安全的角度考慮,通常池都是會被并發(fā)訪問的,那么你就需要處理好同步的問題,這又是一個大坑,并且同步帶來的開銷,未必比你重新創(chuàng)建一個對象小。
對于對象池,唯一合適的場景就是當池中的每個對象的創(chuàng)建開銷很大時,緩存復用才有意義,例如每次new都會創(chuàng)建一個連接,或是依賴一次RPC。
比如說:
· 線程池
· 數(shù)據(jù)庫連接池
· TCP連接池
即使你真的需要實現(xiàn)一個對象池,也請使用成熟的開源框架,例如Apache Commons Pool。
另外,使用JDK的ThreadPoolExecutor作為線程池,不要重復造輪子,除非當你看過AQS的源碼后認為你可以寫得比Doug Lea更好。
八、對象作用域
盡可能縮小對象的作用域,即生命周期。
如果可以在方法內聲明的局部變量,就不要聲明為實例變量。
除非你的對象是單例的或不變的,否則盡可能少地聲明static變量。
九、各類引用
java.lang.ref.Reference有幾個子類,用于處理和GC相關的引用。JVM的引用類型簡單來說有幾種:
· Strong Reference,最常見的引用
· Weak Reference,當沒有指向它的強引用時會被GC回收
· Soft Reference,只當臨近OOM時才會被GC回收
· Phantom Reference,主要用于識別對象被GC的時機,通常用于做一些清理工作
當你需要實現(xiàn)一個緩存時,可以考慮優(yōu)先使用WeakHashMap,而不是HashMap,當然,更好的選擇是使用框架,例如Guava Cache。
***,再次提醒,以上的這些未必可以對代碼有多少性能上的提升,但是熟悉這些方法,是為了幫助我們寫出更卓越的代碼,和GC更好地合作。
原文鏈接:http://blog.hesey.net/2014/05/gc-oriented-java-programming.html