讀古今文學網 > Java程序員修煉之道 > 6.5 垃圾收集 >

6.5 垃圾收集

內存自動管理是Java平台最重要的組成部分之一。在出現Java和.NET這樣的托管平台之前,開發人員把大部分時間都用在追蹤不完善的內存處理引發的bug上了。

然而近年來,內存自動分配技術發展的如此先進可靠,已經變得讓人無法察覺了,因此大部分Java開發人員不知道Java平台的內存管理是如何完成的,不知道可以使用哪些選項,也不知道如何在框架限定內進行優化。

這說明Java的做法取得了成功。大多數開發者不知道內存和GC系統的細節是因為他們沒必要知道。虛擬機在這方面做得非常棒,在處理大多數應用時都不用特別調整,所有大多數應用從沒調整過。

本節我們將討論在確實需要做些調整的情況下你能做什麼。我們會給出基本原理,解釋為了運行Java進程該如何處理內存,並探索標記和清除集合的基礎,再討論兩個工具——jmapVisualVM。最後介紹兩個收集器——並發標記清除(Concurrent Mark-Sweep,簡稱CMS)和新的垃圾優先(Garbage First,簡稱G1)收集器。

也許你有個服務器端程序耗光了內存,或者承受著長時間中斷的痛苦。在6.5.3節討論jmap時,我們將會告訴你一個查看類是否佔用大量內存的簡單辦法。我們還會教你使用控制虛擬機內存配置的選項開關。

先從基本算法開始吧。

6.5.1 基本算法

標準的Java進程既有棧又有堆。棧保存原始型局部變量(引用型局部變量會指向以堆方式分配的內存)。堆保存要創建的對象。圖6-4展示了各種類型變量存儲的位置。

圖6-4 堆和棧中的變量

注意,對象的原始型域仍然分配在堆內的地址上。Java平台對堆內存回收和再利用的基本算法被稱為標記和清除,應用程序中代碼已經不再使用它了。

6.5.2 標記和清除

標記和清除是最簡單、也是出現最早的垃圾收集算法。業內還有其他內存自動管理技術,比如Perl和PHP等語言採用的引用計數1,有人說它更簡單,但它是不需做垃圾收集的方案。

1 引用計數就是為每個內存對像維護一個引用數值,當有新的引用指向該對像時則將其引用計數加一,銷毀時則減一。當引用計數為零時就收回該對像佔用的內存資源。這種方式雖然簡單,但存在兩個問題:每次內存對像被引用或引用被銷毀時必須修改引用計數,造成整體性能消耗;出現循環引用時難以處理。——譯者注

最簡單的標記和清除算法會暫停所有正在運行的線程,並從一組「活」對像——在任何用戶線程的任何堆棧幀中存在引用(不管是局部變量、方法參數、臨時變量,還是某些非常少見的情況)的對象——開始遍歷其引用樹,標記出遍歷路徑上的所有活對象。遍歷完成後,所有沒被標記的都被當做垃圾,可以回收(清除)。注意,被清除的內存不會還給操作系統,而是還給JVM。

Java平台對基本的標記清除方法進行了改進,採用「分代式垃圾收集」。在這種方法中,會根據Java對象的生命週期將堆內存劃分為不同的區域。在對象的生存期內,對它的引用可能指向內存中幾個不同區域(如圖6-5所示)。在垃圾收集過程中,可能會將對像移動到不同區域。

圖6-5 內存中的伊甸園、倖存者樂園、終身頤養園和PermGen區

這樣做是因為根據對系統運行時期的研究,發現對象的生存期或者較短,或者很長。Java平台把堆內存劃分為不同區域可以充分利用對像生命週期的這種特點。

出現時長不確定的暫停怎麼辦?

Java和.NET經常受到這樣的批評:標記和清除式的垃圾收集不可避免地會導致世界停轉(所有用戶線程都必須停止),而且這種暫停的時長是不確定的。

其實這個問題被誇大了。對於服務器端軟件來說,應用程序不會在意垃圾收集引起的暫停。為了避免暫停或完全收集而精心製作解決方案完全是憑空想像——除非經過認真分析,發現全內存收集時間真的存在問題,才應該避免。

1. 內存區域

JVM為存儲不同生命週期階段的對象將內存分成了幾個不同區域。

  • 伊甸園——伊甸園是對像最初降生的堆區域,並且對大多數對像來說,這裡是它們唯一存在過的區域。

  • 倖存者樂園——這裡通常有兩個空間(或者也可以認為是被分成兩半的一個空間)。從伊甸園倖存下來的對象會被挪到這裡。它們有時候被稱為從何而來和到哪裡去。除非正在執行垃圾收集,否則總有一個倖存者空間是空的,原因會在後面給出。

  • 終身頤養園——終身制空間(即老一代)是那些「足夠老」的倖存對象的歸宿(從倖存者空間挪過來的)。在年輕代收集過程中是不會碰終身制內存的。

  • PermGen——這是為內部結構分配的內存,比如類定義。PermGen不是嚴格的堆內存,並且普通的對象最後不會在這裡結束。

就像前面提到的,這些內存區域的垃圾收集方式也不盡相同。具體來說有兩種方式:年輕代收集和完全收集。

2.年輕代收集

年輕代收集只會清理「年輕的」空間(伊甸園和倖存者樂園)。其過程相當簡單。

  • 在標記階段發現的所有仍然存活的年輕對象都會被挪走:
    • 那些足夠老的對象(從次數足夠多的GC中倖存下來的)進入終身頤養園;
    • 剩下那些年輕的存活對像進入倖存者樂園裡空著的空間。
  • 最後,伊甸園和最近騰空的倖存者樂園就可以重用了,因為它們裡面已經全是垃圾了。

當伊甸園滿了的時候就會觸發一次年輕代收集。注意,標記階段必須遍歷整個生存對像圖。也就是說如果有個年輕對像被一個終身對像引用了,終身對像所持有的引用也必須被掃瞄到並標記上。否則只被終身對像引用的伊甸園對象可能會出問題。如果標記階段不是全遍歷,這個伊甸園對象就再也看不到了,而且不可能對它做出正確處理。

3.完全收集

當年輕代收集不能把對像放進終身頤養園時(空間不夠了),就會觸發一次完全收集。根據老年代所用的收集器,這可能會牽涉到老年代對象的內部遷移。這樣做是為了確保必要時能從老年代對像所佔的內存中給大的對象騰出足夠的空間。這被稱為壓縮。

4.安全點

要想做垃圾收集,至少得讓所有應用線程暫停一會兒。但是線程不可能為了GC說停就停。所以它們給執行GC留出了特定的位置——安全點。常見的安全點是方法被調用的地方(「調用點」),不過也有其他安全點。為了執行垃圾收集,所有應用程序線程都必須停在安全點上。

我們暫停一下,先介紹一個簡單的工具——jmap,它能幫你弄清楚程序運行時的內存使用情況,以及所有內存都用在哪裡。我們後續還會介紹一個更先進的GUI工具,但既然很多問題都可以用非常簡單的命令解決,你最好應該知道如何使用,而不是直接就使用GUI工具。

6.5.3 jmap

Oracle JVM自帶了一些簡單的工具,可以幫你瞭解運行中的進程。jmap是其中最簡單的一個,用來顯示Java進程的內存映射(它也能分析Java核心文件1,甚至能連到遠程調試服務器上)。讓我們回到電子商務服務器端應用程序的例子上,用jmap對它進行一番探索。

1 Java核心文件(Java core file)主要保存各應用線程在某一時刻的運行位置,即JVM執行到哪個類、哪個方法及哪一行上。它是一個文本文件,打開後可以看到每一個線程的執行棧,以及stack trace的顯示。一般Java程序遇到致命問題,在JVM死掉之前會產生兩個文件,其中就有Java核心文件,另一個是HeapDump文件。有時為了調試或查找性能問題也會手工生成這兩個文件。——譯者注

1.默認視圖

jmap最簡單的用法是查看連接到進程裡的本地類庫。除非你的應用程序裡有很多JNI代碼,否則這種用法通常沒什麼用,但我們還是會演示一下,以免你忘了指定jmap選項時被它搞糊塗:

 $ jmap 19306
Attaching to process ID 19306, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 20.0-b11
0x08048000     46K           /usr/local/java/sunjdk/1.6.0_25/bin/java
0x55555000     108K          /lib/ld-2.3.4.so
... some entries omitted
0x563e8000     535K          /lib/libnss_db.so.2.0.0
0x7ed18000     94K           /usr/local/java/sunjdk/1.6.0_25/jre/lib/i386/libnet.so
0x80cf3000     2102K         /usr/local/kerberos/mitkrb5/1.4.4/lib/
         libgss_all.so.3.1
0x80dcf000     1440K         /usr/local/kerberos/mitkrb5/1.4.4/lib/libkrb5.so.3.2
  

一般用得比較多的是-heap-histo選項,下面我們就來討論這兩個選項。

2.堆視圖

使用-heap選項時,jmap會抓取進程當前的堆快照。在輸出結果中能看到構成Java進程堆內存的基本參數。

堆的大小是年輕代、老年代加上PermGen區的總和。但在年輕代內部有伊甸園和倖存者樂園,並且我們還沒告訴你這些區域的大小之間有什麼關係。這些區域的相對大小是由一個叫做倖存比例的數值決定的。

我們來看一些輸出樣例。你能在其中看到伊甸園、倖存者樂園(標籤為FromTo)、終身頤養園(Old Generation)以及一些相關信息:

$ jmap -heap 22186
Attaching to process ID 22186, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 20.0-b11

using thread-local object allocation.
Parallel GC with 13 thread(s)

Heap Configuration:
   MinHeapFreeRatio = 40
   MaxHeapFreeRatio = 70
   MaxHeapSize = 536870912 (512.0MB)
   NewSize = 1048576 (1.0MB)
   MaxNewSize = 4294901760 (4095.9375MB)
   OldSize = 4194304 (4.0MB)
   NewRatio = 2
   SurvivorRatio = 8  //伊甸園=(From+To)*倖存比例
   PermSize = 16777216 (16.0MB)
   MaxPermSize = 67108864 (64.0MB)
Heap Usage:
  PS Young Generation
  Eden Space:
    capacity = 163774464 (156.1875MB)  //伊甸園=(From+To)*倖存比例
    used = 58652576 (55.935455322265625MB)
    free = 105121888 (100.25204467773438MB)
    35.81301661289516% used
  From Space:
    capacity = 7012352 (6.6875MB)   //伊甸園=(From+To)*倖存比例
    used = 4144688 (3.9526824951171875MB)
    free = 2867664 (2.7348175048828125MB)
    59.10553263726636% used
  To Space:
    capacity = 7274496 (6.9375MB)   //伊甸園=(From+To)*倖存比例
    used = 0 (0.0MB)
    free = 7274496 (6.9375MB)
    0.0% used   //To空間當前為空
  PS Old Generation
    capacity = 89522176 (85.375MB)
    used = 6158272 (5.87298583984375MB)
    free = 83363904 (79.50201416015625MB)
    6.87904637170571% used
  PS Perm Generation
    capacity = 30146560 (28.75MB)
    used = 30086280 (28.69251251220703MB)
    free = 60280 (0.05748748779296875MB)
    99.80004352072011% used
  

儘管空間的基本構成可能會非常有用,但在這副圖裡看不到堆裡面有什麼。如果能看到是哪些對像佔用了內存中的空間,你就知道內存都到哪裡去了。jmap恰好提供了一個柱狀圖模式,可以讓你看到這些數據的簡單統計結果。

3.柱狀視圖

柱狀視圖顯示了系統中每個類型的實例(還有一些內部實體)佔用的內存量。各個類型按使用內存多少排列,這樣就比較容易看到最大的內存豬。

當然,如果所有內存都交給了框架和平台類,這裡可能就沒你什麼事了。但如果真有一個你的類,有了這些信息便能更好地干預它的內存佔用。

小小的警告:jmap使用類型內部名稱。比如字符數組會寫成[C,類對象的數組會顯示[Ljava.lang.Class;

$ jmap -histo 22186 | head -30
num #instances #bytes class name
----------------------------------------------
1:    452779   31712472      [C
2:    76877    14924304      [B
3:    20817    12188728      [Ljava.lang.Object;
4:    2520     10547976      com.company.cache.Cache$AccountInfo
5:    439499   9145560       java.lang.String
6:    64466    7519800       [I
7:    64466    5677912       <constMethodKlass>   //VM內部對像和類型信息
8:    96840    4333424       <methodKlass>
9:    6990     3384504       <symbolKlass>
10:   6990     2944272       <constantPoolKlass>
11:   4991     1855272       <instanceKlassKlass>
12:   25980    1247040       <constantPoolCacheKlass>
13:   17250    1209984       java.nio.HeapCharBuffer
14:   13515    1173568       [Ljava.util.HashMap$Entry;
15:   9733     778640        java.lang.reflect.Method
16:   17842    713680        java.nio.HeapByteBuffer
17:   7433     713568        java.lang.Class
18:   10771    678664        [S
19:   1543     489368        <methodDataKlass>   //VM內部對像和類型信息
20:   10620    456136        [[I
21:   18285    438840        java.util.HashMap$Entry
22:   9985     399400        java.util.HashMap
23:   13725    329400        java.util.Hashtable$Entry
24:   9839     314848        java.util.LinkedHashMap$Entry
25:   9793     249272        [Ljava.lang.String;
26:   11927    241192        [Ljava.lang.Class;
27:   6903     220896        java.lang.ref.SoftReference
  

因為在柱狀圖模式下輸出的數據很多,所以上面只顯示了輸出內容的一部分。你可能要用grep或其他工具來查看柱狀圖視圖,找到感興趣的細節。

輸出中有很多佔用內存的[C實體。字符數組數據經常出現在String對像裡(字符串的內容就存在那裡),所以這不奇怪——大多數Java程序裡都有很多字符串。但從柱狀圖中還能看出其他有趣的事情。先來看看下面兩個。

前幾個實體裡唯一一個應用類是Cache$AccountInfo——其他全是平台或框架類型——所以它們是開發人員可以完整控制的最重要的類型。AccountInfo對像佔了很多空間——大概2 500個實體佔了10.5MB(或者每個賬號占4KB)。對於賬號細節來說這實在是很多。

這個信息真的非常有用。你已經知道代碼裡什麼占內存最多了。假如老闆現在過來告訴你,因為大規模促銷,一個月內系統客戶數可能要暴增10倍。你知道這可能會給系統增加很多壓力——AccountInfo對象可是個兇猛的大傢伙。雖然你有點擔心,但至少你已經開始分析這個問題了。

jmap輸出的信息可以作為潛在問題處理決策流程的輔助輸入。你是不是應該把賬號緩存分開,減少該類型保存的信息項,或者買更多的內存給服務器裝上。在做出任何決定之前,你還要做很多分析工作,但已經有個起點了。

柱狀圖模式下還能看到其他有意思的事情,這次指定-histo:live選項。這是告訴jmap只處理存活對象,而不是整個堆(jmap默認會處理所有對象,也包括還沒被收集的垃圾)。讓我們看看這次輸出什麼:

$ jmap -histo:live 22186 | head -7
num   #instances     #bytes         class name
----------------------------------------------
1:        2520            10547976      com.company.cache.Cache$AccountInfo
2:        32796           4919800       [I
3:        5392            4237628       [Ljava.lang.Object;
4:        141491          2187368       [C
  

注意輸出的變化——字符數據已經從31MB降到了2MB左右了,證明你第一次看到的String對像裡有將近三分之二都是等待回收的垃圾。然而賬號對像全是活的,進一步證明了它們是消耗內存的主要力量。

使用jmap時應該稍微謹慎點。進行該操作時JVM還在運行(如果你不走運,還有可能在讀取快照期間做了垃圾回收),所以你應該多運行幾次,特別是在你看到任何奇怪或太好的結果時。

4.產生離線導出文件

jmap能創建導出文件,像這樣:

jmap -dump:live,format=b,file=heap.hprof 19306
  

導出結果可以用來做離線分析,可以留給jmap以後自己用,也可以留給Oracle的jhat(Java堆分析工具)做高級分析。可惜我們沒辦法在這裡全面討論。

使用jmap可以看到一些基本設置和程序的內存佔用。然而要做性能調優,一般需要對GC子系統有更多控制,其標準方式是通過命令行參數,我們來看一些控制JVM的參數,用它們使JVM的行為更適用於你的應用程序。

6.5.4 與GC相關的JVM參數

JVM的參數非常多(最少上百個),用來定制JVM運行時的行為。本節我們會討論一些跟垃圾收集有關的選項,後續章節中還會討論其他選項。

非標準的JVM選項

-X:開頭的選項不是標準選項,在其他JVM上可能不可用。

-XX:開頭的是擴展選項,不要隨便使用。很多與性能相關的選項都是擴展選項。

有些選項相當於布爾型的參數,並且前面有+作為它的開關。還有帶參數的選項,比如-XX:CompileThreshold=1000(這個方法會在調用次數達到1000之後才被JIT編譯)。還有一些參數(包括很多標準參數)既沒有開關也不能帶參數。

表6-2中是基本的GC選項,還有這些選項的默認值(如果存在)。

表6-2 基本垃圾收集選項

選項效果 -Xms<幾MB>m 堆的初始大小(默認2MB) -Xmx<幾MB>m堆的最大大小(默認64MB) -Xmn<幾MB>m 堆中年輕代的大小 -XX:-DisableExplicitGC讓調用System.gc不產生任何作用

一個常用的小技巧是把-Xms-Xmx的大小設成一樣的。這樣進程就會用恰當的堆尺寸運行,沒必要在執行過程中調整大小(可能會引發意想不到的降速)。

表中最後一個選項輸出GC的標準信息到日誌中,我們在下一節會討論如何解釋這些信息。

6.5.5 讀懂GC日誌

為了充分利用垃圾收集,你需要經常看看子系統在做什麼。除了基本的verbose:gc標記,還有很多可以控制輸出信息的選項。

別拿GC日誌不當回事兒,你可能時不時地就會發現自己被輸出信息淹沒了。下一節討論VisualVM時你會發現,有一個可視化工具可以幫你看到VM的行為,這個工具非常有用。不管怎樣,會讀日誌以及瞭解影響GC的基本選項非常重要,因為有時候你可能沒法用GUI工具。最常用的GC日誌選項如表6-3所示。

表6-3 用於擴展日誌的額外選項

選項效果 -XX:+PrintGCDetails 關於GC更詳細的細節 -XX:+PrintGCDateStampsGC操作的時間戳 -XX:+PrintGCApplicationConcurrentTime 在應用線程仍然運行的情況下用在GC上的時間

這些選項組合在一起時,會產生下面這種日誌:

6.580: [GC [PSYoungGen: 486784K->7667K(499648K)]
1292752K->813636K(1400768K), 0.0244970 secs]
  

我們把它分解,看看每一部分是什麼意思:

<time>: [GC [<collector name>: <occupancy at start>
➥ -> <occupancy at end>(<total size>)] <full heap occupancy at start>
➥ -> <full heap occupancy at end>(<total heap size>), <pause time> secs
  

第一塊是GC的發生時間,從JVM啟動開始算,到發生時的秒數。然後是用來收集年輕代的收集器名稱(PSYoungGen)。接著是年輕代收集前後佔用的內存,以及年輕代的總大小。接著是反映完全收集情況的相同部分。

除了GC日誌選項,還有一個選項如果不經解釋可能會引起誤解。用選項-XX:+PrintGCApplicationStoppedTime產生的日誌是這樣的:

Application time: 0.9279047 seconds
Total time for which application threads were stopped: 0.0007529 seconds
Application time: 0.0085059 seconds
Total time for which application threads were stopped: 0.0002074 seconds
Application time: 0.0021318 seconds
  

這些並不一定指GC用了多長時間,而是指在一個從安全點開始的操作中,線程停了多長時間。這包括GC操作,但也包括其他安全點操作(比如Java 6中的偏向鎖操作1),所以沒有十足把握說這是指GC時長。

1 在Java 6之前,加鎖會導致一次原子CAS(Compare-And-Set)操作。對於沒有爭用的資源,該操作會造成無謂的開銷。為解決這一問題,Java 6中引入了偏向鎖技術,即偏向於第一個加鎖的線程,該線程後續加鎖操作不需要同步。其基本實現方式為:鎖最初為NEUTRAL狀態,當第一個線程加鎖時,將該鎖的狀態修改為BIASED,並記錄線程ID,這一線程在後續加鎖時若發現狀態是BIASED並且線程ID是當前線程ID,則只設置一下加鎖標誌,不需要進行CAS操作。其他線程若要加這個鎖,需要使用CAS操作將狀態替換為REVOKE,並等待加鎖標誌清零,以後該鎖的狀態就變成DEFAULT。這一功能可用-XX:-UseBiasedLocking命令禁止。——譯者注

所有這些信息對記錄日誌和事後分析都有用,但不容易做可視化處理。而很多開發人員在做初始分析時都喜歡使用GUI工具。好在HotSpot VM(標準的Oracle VM,稍後討論)自帶了一個非常實用的工具。

6.5.6 用VisualVM查看內存使用情況

VisualVM是Oracle JVM自帶的可視化工具。它是插件架構,採用標準配置,比JConsole用起來更方便。

圖6-6是標準的VisualVM匯總界面。啟動VisualVM並把它連接到本地運行的程序上,就能看到這樣的界面。(VisualVM也能連接到遠程應用上,但有些功能通過網絡不可用。)這個界面中VisualVM連接的是MacBook Pro上運行的Eclipse,你可以看到我們用來編寫本書代碼的Eclipse的設置。圖6-6如下所示。

圖6-6 VisualVM匯總界面

右側面板頂部有很多標籤。其中有擴展(Extension)、樣例(Sampler)、JConsole、MBeans和VisualVM插件。VisualVM插件為掌握Java運行時的動態情況提供了非常棒的工具。建議你在用VisualVM做任何實際工作前把這些插件都裝上。

圖6-7展示了內存佔用的「鋸齒」模式。這絕對是Java平台中內存佔用情況的經典表現。它表示對像被分配在伊甸園中,使用,然後在年輕代中被回收。

圖6-7 VisualVM總覽界面

每次年輕代收集之後,被佔用的內存量回落到基線水平。這個水平是終身制對像和倖存者對像合起來的用量,可以用它來確定Java進程的健康狀況。如果基線在進程工作時保持穩定或者逐漸遞減,則表明內存的使用情況非常健康。

如果基線水平上升,也不一定就是出錯了,可能只是有些對象的生存期很長,長到足夠轉入終身頤養園中。在這種情況下,最終會進行一次完全收集。完全收集會導致鋸齒模式再次出現,從而使內存佔用回落到基線水平。如果完全收集基線持續保持穩定,進程不會耗光內存。

鋸齒上斜坡的陡度是進程使用年輕代內存(通常是伊甸園)的頻率,這個概念很重要。降低年輕代收集的頻率基本上就是降低鋸齒的陡度。

內存使用情況的另外一種可視化方式如圖6-8所示。你能看到伊甸園、倖存者樂園(S0和S1)、終身頤養園及PermGen區。在程序運行時,你能看到各個空間的大小變化。特別是在年輕代收集之後,可以看到伊甸園變小,倖存者樂園中兩個空間的角色也互相轉換了。

圖6-8 VisualVM的可視化GC插件

探索內存系統和運行時環境有助於你理解代碼如何運行。相應地,這也表明VM提供的服務對性能影響很大,所以你絕對應該花時間研究一下VisualVM,尤其要結合XmxXms這些選項試一下。

下一節中,我們將要討論JVM中的一項新技術,這項技術會在執行過程中自動降低堆內存的佔用量。

6.5.7 逸出分析

本節介紹了JVM最近的一項修改,內容僅供參考。程序員不能直接控制或影響這項修改,並且在最近發佈的Java中,這項優化是默認的。因此本節中沒有太多關於這項修改的信息或例子。所以如果你想瞭解一下JVM提升自身性能的技巧,請繼續。如果沒興趣,可以跳到6.5.8節去研究並發的垃圾收集。

逸出分析乍一看是個相當出人意料的想法。其基本思路是分析方法並確認其中哪個局部變量(的引用類型)只用在方法內部,以及哪些變量不會傳入其他方法或從當前方法中返回。

這樣JVM就可以在當前方法的棧框架內部創建這個對象,而不再使用堆內存。這會減少程序年輕代收集的次數,從而提高性能。請參見圖6-9。

圖6-9 逸出分析避免了對象的堆分配

這就是說可以避免堆分配,因為在當前方法返回時,被局部變量佔用的內存就自動釋放了。用這種不牽扯堆分配的方式分配變量空間不會產生垃圾,當然就不需要收集垃圾。

逸出分析是減少JVM垃圾收集的新辦法。它能對線程的年輕代收集次數產生顯著影響。經實踐證明,它通常能對總體性能產生百分之幾的影響。雖然影響不是特別大,但也很有價值,特別是在進程的垃圾收集次數比較多的時候。

從Java6u23往後,逸出分析是默認打開的,所以新版Java的速度免費提升了。

現在我們去看另外一個對代碼有巨大影響的環節——收集策略的選擇。我們從一個經典的高性能選擇(並發標記清除)開始,然後看一看最新的收集器——垃圾優先。

選擇高性能收集器有很多原因。應用程序可能會從較短的GC暫停中受益,並且也願意運行更多線程(佔用CPU資源)來加快速度。或者你想控制GC暫停的頻度。除了基本的收集器,你還可以用選項迫使平台採用不同的收集策略。在接下來的兩節中,我們會介紹兩個把這種可能性變成現實的收集器。

6.5.8 並發標記清除

並發標記清除(CMS)收集器是Java 5推薦的高性能收集器,在Java 6中仍然保持了旺盛的生命力。可以通過下面幾個選項激活它,如表6-4所示。

表6-4 用於CMS收集器的選項

選項效果 -XX:+UseConcMarkSweepGC打開CMS收集 -XX:+CMSIncrementalMode增量模式(一般都需要) -XX:+CMSIncrementalPacing配合增量模式,根據應用程序的行為自動調整每次執行的垃圾回收任務的幅度(一般都需要) -XX:+UseParNewGC並發收集年輕代 -XX:ParallelGCThreads=<N>GC使用的線程數

這些選項會覆蓋垃圾收集的默認設置,為GC配置有N個並行線程的CMS垃圾收集器。這些線程會盡可能地在並發模式下完成GC工作。

這種並發方式是如何工作的呢?下面是與標記清除相關的三個重要事實:

  • 某種世界停轉(簡稱STW)的暫停是不可避免的;
  • GC子系統絕對不能漏掉存活對象,這樣做會導致JVM垮掉(或者更糟);
  • 只有所有應用線程都為整體收集暫停下來,才能保證收集所有的垃圾。

CMS利用了最後一點。它製造兩個非常短暫的STW暫停,並且在GC週期的剩餘時間和應用程序的線程一起運行。這表明它願意跟「偽陰性」妥協,由於競爭危害而無法標識某些垃圾(被漏掉的垃圾會在下一個GC週期中得到收集)。

CMS還要在運行時做複雜的記賬工作,記錄哪些是垃圾,哪些不是。這些額外的開銷是為了在不停止應用線程的情況下運行GC所付出的代價。CMS在有更多CPU核心的機器上會表現得更好,並且會製造更頻繁的短暫暫停。它的日誌輸出如下所示:

2010-11-17T15:47:45.692+0000: 90434.570: [GC 90434.570:
[ParNew: 14777K->14777K(14784K), 0.0000595 secs]90434.570:
[CMS: 114688K->114688K(114688K), 0.9083496 secs] 129465K->117349K(129472K),
[CMS Perm : 49636K->49634K(65536K)] icms_dc=100 , 0.9086004 secs]
[Times: user=0.91 sys=0.00, real=0.91 secs]
  

這些日誌和6.4.4節中基本的GC日誌差不多,但增加了CMSCMS Perm收集器部分。

最近幾年,CMS作為最佳高性能收集器的地位受到了挑戰,挑戰者是垃圾優先(G1)收集器。我們來看看這顆冉冉升起的新星,瞭解一下它的新穎方法,以及它能夠突破所有現存的Java收集器的原因。

6.5.9 新的收集器:G1

G1是Java平台中嶄新的收集器。本來想把它和Java 7一起發佈,但後來作為預發佈版本跟Java 6一起發佈了,到Java 7時就是成品了。它在Java 6中並沒有得到廣泛的應用,但隨著Java 7逐漸普及,有望讓G1成為高性能應用(也可能是所有應用)的默認選擇。

G1的核心思想是暫停目標(pause goal),也就是程序在執行時能為GC暫停多長時間(比如每5分鐘20ms)。G1會竭盡所能達成暫停目標。它和我們原來遇到的收集器完全不同,並且開發人員對GC如何執行有更多控制權。

G1不是真正的分代式垃圾收集器(儘管它仍然使用標記清除法)。相反,G1把堆分成大小相同的區域(比如每個1MB),不區分年輕區和年老區。暫停時,對像被撤到其他區域(就像伊甸園對像被挪到倖存者樂園一樣),清空的區域被放回到(空白區的)自由列表上。這種將堆劃分為大小相同區域的做法如圖6-10所示。

圖6-10 G1如何劃分堆空間

這個新的收集策略讓Java平台可以統計收集單個區域需用的平均時長。這樣你就可以在合理範圍內指定一個暫停目標。G1只會在有限的時間內收集盡可能多的區域(儘管在收集最後一個區域時所用的時間可能比預期的長)。

要打開G1,需要用到表6-5中的選項。

表6-5 G1收集器的選項

選項效果 -XX:+UseG1GC打開G1收集 -XX:MaxGCPauseMillis=50告訴G1它在一次收集中暫停的時間應該盡量保持在50ms以內 -XX:GCPauseIntervalMillis=200告訴G1它將兩次收集的時間間隔盡量保持在200ms以上

這些選項可以組合,比如設置最大暫停目標是50ms,暫停間隔不能少於200ms。當然,GC系統所能承受的壓力是有限的。必須有充足的暫停時間把垃圾取出來。每隔100年1ms的暫停目標肯定是不現實的。

G1可以支持的負載和應用類型範圍很廣。如果你的應用程序已經到了需要對GC調優的地步,G1會是一個不錯的選擇。

在下一節中,我們會介紹JIT編譯。對於很多或大多數程序來說,這是唯一一個可以為產生高性能代碼做出最大貢獻的因素。我們會學習JIT編譯的基礎知識,最後解釋一下如何打開JIT編譯的日誌,讓你能夠判斷正在編譯哪個方法。