1. 程式人生 > >Java程式設計之面向GC

Java程式設計之面向GC

      Java程式設計師在編碼過程中通常不需要考慮記憶體問題,JVM經過高度優化的GC機制大部分情況下都能夠很好地處理堆(Heap)的清理問題。以至於許多Java程式設計師認為,我只需要關心何時建立物件,而回收物件,就交給GC來做吧!甚至有人說,如果在程式設計過程中頻繁考慮記憶體問題,是一種退化,這些事情應該交給編譯器,交給虛擬機器來解決。這話其實也沒有太大問題,的確,大部分場景下關心記憶體、GC的問題,顯得有點“杞人憂天”了,曾經有一位資深開發人員說過:過早優化是萬惡之源。

      但是,什麼才是過早優化?事實上JVM的記憶體模型( JMM )理應是Java程式設計師的基礎知識,處理過幾次JVM線上記憶體問題之後就會很明顯感受到,很多系統問題,都是記憶體問題。對JVM記憶體結構感興趣的同學可以看下

JVM學習之路一(淺析Java虛擬機器結構與機制) 這篇文章,本文就不再贅述了,本文也並不關注具體的GC演算法,相關的文章在網上很多。

      另外,不要指望GC優化的這些技巧,可以對應用效能有成倍的提高,特別是對I/O密集型的應用,或是實際落在YoungGC上的優化,可能效果只是幫你減少那麼一點YoungGC的頻率。套用一位前輩的話講:優秀程式設計師的價值,不在於其所掌握的幾招屠龍之術,而是在細節中見真著。就像我師傅曾經說:If we could do things right for the first time, why not?(如果我們可以一次把事情做對,並且做好,在允許的範圍內儘可能追求卓越,為什麼不去做呢?)。下面關於面向GC的Java程式設計做出如下論述:

      一、GC分代的基本假設

      大部分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。而不可變物件的引用關係非常穩定,在掃描卡表時就不會掃到它們對應的項了。

     注意,這裡的不可變物件,不是指僅僅自身引用不可變的final物件,而是真正的Immutable Objects。

     四、引用置為null的傳說

     早期的很多Java資料中都會提到在方法體中將一個變數置為null能夠優化GC的效能,類似下面的程式碼:

<span style="font-size:18px;">List<String> list = new ArrayList<String>();
// some code
list = null; // help GC</span>
    事實上這種做法對GC的幫助微乎其微,有時候反而會導致程式碼混亂。我曾經看過一份關於這個問題的報告,原帖沒找到,結論基本就是:在一個非常大的方法體內,對一個較大的物件,將其引用置為null,某種程度上可以幫助GC。大部分情況下,這種行為都沒有任何好處。 所以,還是早點放棄這種“優化”方式吧,GC比我們想象的更聰明。

    五、手動檔的GC

    在很多Java資料上都有下面兩個小技巧:通過Thread.yield()讓出CPU資源給其它執行緒;通過System.gc()觸發GC。事實上JVM從不保證這兩件事,而System.gc()在JVM啟動引數中如果允許顯式GC,則會觸發FullGC,對於響應敏感的應用來說,幾乎等同於自殺。

    因此,強烈建議:Never use Thread.yield();Never use System.gc()。如果遇到在以下場景:使用了NIO或者NIO框架(Mina/Netty);使用了DirectByteBuffer分配位元組緩衝區;使用了MappedByteBuffer做記憶體對映。

    由於Native Memory只能通過FullGC(或是CMS GC)回收,所以除非你非常清楚這時真的有必要,否則不要輕易呼叫System.gc(),且行且珍惜。

    另外為了防止某些框架中的System.gc呼叫(例如NIO框架、Java RMI),建議在啟動引數中加上-XX:+DisableExplicitGC來禁用顯式GC。這個引數有個巨大的坑,如果你禁用了System.gc(),那麼上面的3種場景下的記憶體就無法回收,可能造成OOM,如果你使用了CMS GC,那麼可以用這個引數替代:-XX:+ExplicitGCInvokesConcurrent。

    六、指定容器初始化大小

    Java容器的一個特點就是可以動態擴充套件,所以通常我們都不會去考慮初始大小的設定,不夠了反正會自動擴容。但是擴容不意味著沒有代價,甚至是很高的代價。例如一些基於陣列的資料結構,例如StringBuilder、StringBuffer、ArrayList、HashMap等等,在擴容的時候都需要做ArrayCopy,對於不斷增長的結構來說,經過若干次擴容,會存在大量無用的老陣列,而回收這些陣列的壓力,全都會加在GC身上。這些容器的建構函式中通常都有一個可以指定大小的引數,如果對於某些大小可以預估的容器,建議加上這個引數。可是因為容器的擴容並不是等到容器滿了才擴容,而是有一定的比例,例如HashMap的擴容閾值和負載因子(loadFactor)相關。Google Guava框架對於容器的初始容量提供了非常便捷的工具方法,例如:

<span style="font-size:18px;">Lists.newArrayListWithCapacity(initialArraySize);

Lists.newArrayListWithExpectedSize(estimatedSize);

Sets.newHashSetWithExpectedSize(expectedSize);

Maps.newHashMapWithExpectedSize(expectedSize);</span>
    這樣我們只要傳入預估的大小即可,容量的計算就交給Guava處理。反例:如果採用預設無參建構函式,建立一個ArrayList,不斷增加元素直到OOM,那麼在此過程中會導致:多次陣列擴容,重新分配更大空間的陣列;多次陣列拷貝;記憶體碎片。

    七、物件池

    為了減少物件分配開銷,提高效能,可能有人會採取物件池的方式來快取物件集合,作為複用的手段。但是物件池中的物件由於在執行期長期存活,大部分會晉升到Old Generation,因此無法通過YoungGC回收,並且通常這沒有什麼效果。

    對於物件本身而言,如果物件很小,那麼分配的開銷本來就小,物件池只會增加程式碼複雜度;如果物件比較大,那麼晉升到Old Generation後,對GC的壓力就更大了;從執行緒安全的角度考慮,通常池都是會被併發訪問的,那麼你就需要處理好同步的問題,這又是一個大坑,並且同步帶來的開銷,未必比你重新建立一個物件小。

    對於物件池而言,唯一合適的場景就是當池中的每個物件的建立開銷很大時,快取複用才有意義,例如每次new都會建立一個連線,或是依賴一次RPC。例如:執行緒池、資料庫連線池、TCP連線池,即使你真的需要實現一個物件池,也請使用成熟的開源框架,例如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的時機,通常用於做一些清理工作

    當你需要實現一個快取時,可以考慮優先使用WeakHashMap,而不是HashMap,當然,更好的選擇是使用框架,例如Guava Cache。

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