Java高級開發必須懂的 —jvm調優案例分析與實戰


案例分析

高性能硬件上的程序部署策略

例 如 ,一個15萬PV/天左右的在線文檔類型網站最近更換了硬件系統,新的硬件爲4個CPU、16GB物理內存,操做系統爲64位CentOS 5.4 , Resin做爲Web服務器。整個服務器暫時沒有部署別的應用,全部硬件資源均可以提供給這訪問量並不算太大的網站使用。管理員爲 了儘可能利用硬件資源選用了64位的JDK 1 . 5 ,並經過-Xmx和-Xms參數將Java堆固定在12GB。使用一段時間後發現使用效果並不理想,網站常常不按期出現長時間失去響應的狀況。html

監控服務器運行情況後發現網站失去響應是由GC停頓致使的,虛擬機運行在Server模式 ,默認使用吞吐量優先收集器,回收12GB的堆 ,一次Full GC的停頓時間高達14秒。而且因爲程序設計的關係,訪問文檔時要把文檔從磁盤提取到內存中,致使內存中出現不少由文檔序列化產生的大對象,這些大對象不少都進入了老年代,沒有在Minor GC中清理掉。這種狀況下即便有12GB的 堆 ,內存也很快被消耗殆盡,由此致使每隔十幾分鍾出現十幾秒的停頓 ,令網站開發人員和管理員感到很沮喪。前端

這裏先不延伸討論程序代碼問題,程序部署上的主要問題顯然是過大的堆內存進行回收時帶來的長時間的停頓。硬件升級前使用32位系統1.5GB的 堆 ,用戶只感受到使用網站比較緩慢 ,但不會發生十分明顯的停頓,所以才考慮升級硬件以提高程序效能,若是從新縮小給Java堆分配的內存,那麼硬件上的投資就顯得很浪費。java

在高性能硬件上部署程序,目前主要有兩種方式:程序員

  • 經過64位JDK來使用大內存。
  • 使用若干個32位虛擬機創建邏輯集羣來利用硬件資源。

此案例中的管理員採用了第一種部署方式。對於用戶交互性強、對停頓時間敏感的系統,能夠給Java虛擬機分配超大堆的前提是有把握把應用程序的Full GC頻率控制得足夠低, 至少要低到不會影響用戶使用,譬如十幾個小時乃至一天才出現一次Full GC ,這樣能夠經過在深夜執行定時任務的方式觸發Full GC甚至自動重啓應用服務器來保持內存可用空間在一個穩定的水平。面試

控制Full GC頻率的關鍵是看應用中絕大多數對象可否符合「朝生夕滅」的原則,即大多數對象的生存時間不該太長,尤爲是不能有成批量的、長生存時間的大對象產生,這樣才能保障老年代空間的穩定。算法

在大多數網站形式的應用裏,主要對象的生存週期都應該是請求級或者頁面級的,會話級和全局級的長生命對象相對不多。只要代碼寫得合理,應當都能實如今超大堆中正常使用而沒有Full GC ,這樣的話,使用超大堆內存時,網站響應速度纔會比較有保證。除此以外, 若是讀者計劃使用64位JDK來管理大內存,還須要考慮下面可能面臨的問題:shell

  • 內存回收致使的長時間停頓。
  • 現階段 ,64位JDK的性能測試結果廣泛低於32位JDK。

須要保證程序足夠穩定,由於這種應用要是產生堆溢出幾乎就沒法產生堆轉儲快照(由於要產生十幾GB乃至更大的Dump文件 ),哪怕產生了快照也幾乎沒法進行分析。數據庫

相同程序在64位JDK消耗的內存通常比32位JDK大 ,這是因爲指針膨脹,以及數據類型對齊補白等因素致使的。apache

上面的問題聽起來有點嚇人,因此現階段很多管理員仍是選擇第二種方式:使用若干個32位虛擬機創建邏輯集羣來利用硬件資源。具體作法是在一臺物理機器上啓動多個應用服務器進程 ,每一個服務器進程分配不一樣端口 ,而後在前端搭建一個負載均衡器 ,以反向代理的方式來分配訪問請求。讀者不須要太過在乎均衡器轉發所消耗的性能,即便使用64位JDK ,許多應用也不止有一臺服務器,所以在許多應用中前端的均衡器老是要存在的。緩存

考慮到在一臺物理機器上創建邏輯集羣的目的僅僅是爲了儘量利用硬件資源,並不須要關心狀態保留、熱轉移之類的高可用性需求,也不須要保證每一個虛擬機進程有絕對準確的 均衡負載,所以使用無Session複製的親合式集羣是一個至關不錯的選擇。咱們僅僅須要保障集羣具有親合性,也就是均衡器按必定的規則算法(通常根據SessionID分配)將一個固定的用戶請求永遠分配到固定的一個集羣節點進行處理便可,這樣程序開發階段就基本不用爲集羣環境作什麼特別的考慮了。

固然 ,不多有沒有缺點的方案,若是讀者計劃使用邏輯集羣的方式來部署程序,可能會遇到下面一些問題:

  • 儘可能避免節點競爭全局的資源,最典型的就是磁盤競爭,各個節點若是同時訪問某個磁盤文件的話(尤爲是併發寫操做容易出現問題),很容易致使IO異常。
  • 很難最高效率地利用某些資源池,譬如鏈接池,通常都是在各個節點創建本身獨立的鏈接池 ,這樣有可能致使一些節點池滿了而另一些節點仍有較多空餘。儘管能夠使用集中式的JNDI,但這個有必定複雜性而且可能帶來額外的性能開銷。
  • 各個節點仍然不可避免地受到32位的內存限制,在32位Windows平臺中每一個進程只能使用2GB的內存 ,考慮到堆之外的內存開銷,堆通常最多隻能開到1.5GB。在某些Linux或UNIX 系統(如Solaris ) 中 ,能夠提高到3GB乃至接近4GB的內存,但32位中仍然受最高4GB(232)內存的限制。
  • 大量使用本地緩存(如大量使用HashMap做爲K/V緩存 )的應用 ,在邏輯集羣中會形成較大的內存浪費,由於每一個邏輯節點上都有一份緩存,這時候能夠考慮把本地緩存改成集中式緩存。

介紹完這兩種部署方式,再從新回到這個案例之中,最後的部署方案調整爲創建5個32 位JDK的邏輯集羣,每一個進程按2GB內存計算(其中堆固定爲1.5GB ) ,佔用了 10GB內存。 另外創建一個Apache服務做爲前端均衡代理訪問門戶。考慮到用戶對響應速度比較關心,而且文檔服務的主要壓力集中在磁盤和內存訪問,CPU資源敏感度較低,所以改成CMS收集器進行垃圾回收。部署方式調整後,服務再沒有出現長時間停頓,速度比硬件升級前有較大提高。

集羣間同步致使的內存溢出

例如 ,有一個基於B/S的MIS系 統 ,硬件爲兩臺2個CPU、8GB內存的HP小型機,服務器是WebLogic 9.2 ,每臺機器啓動了3個WebLogic實 例 ,構成一個6個節點的親合式集羣。因爲是親合式集羣,節點之間沒有進行Sessurn同步,可是有一些需求要實現部分數據在各個節點間共享。開始這些數據存放在數據庫中,但因爲讀寫頻繁競爭很激烈,性能影響較大,後面 使用JBossCache構建了 一個全局緩存。全局緩存啓用後,服務正常使用了一段較長的時間, 但最近卻不按期地出現了屢次的內存溢出問題。

在內存溢出異常不出現的時候,服務內存回收情況一直正常,每次內存回收後都能恢復到一個穩定的可用空間,開始懷疑是程序某些不經常使用的代碼路徑中存在內存泄漏,但管理員反映最近程序並未更新、升級過,也沒有進行什麼特別操做。只好讓服務帶着-XX : +HeapDumpOnOutOfMemoryError參數運行了一段時間。在最近一次溢出以後,管理員發回了 heapdump文件 ,發現裏面存在着大量的org.jgroups.protocols.pbcast.NAKACK對象。

JBossCache是基於自家的JGroups進行集羣間的數據通訊,JGroups使用協議棧的方式來實現收發數據包的各類所需特性自由組合,數據包接收和發送時要通過每層協議棧的up()和down()方法,其中的NAKACK棧用於保障各個包的有效順序及重發。JBossCache協議棧如圖5-1所示。

因爲信息有傳輸失敗須要重發的可能性,在確認全部註冊在GMS ( Group Membership Service ) 的節點都收到正確的信息前,發送的信息必須在內存中保留。而此MIS的服務端中有一個負責安全校驗的全局Filter , 每鈑接收到請求時,均會更新一次最後操做時間,而且將這個時間同步到全部的節點去,使得一個用戶在一段時間內不能在多臺機器上登陸。在服務使用過程當中,每每一個頁面會產生數次乃至數十次的請求,所以這個過濾器致使集羣各個節點之間網絡交互很是頻繁。當網絡狀況不能知足傳輸要求時,重發數據在內存中不斷堆積,很快就產生了內存溢出。

這個案例中的問題,既有JBossCache的缺陷,也有MIS系統實現方式上缺陷。 JBossCache官方的maillist中討論過不少次相似的內存溢出異常問題,聽說後續版本也有了改進。而更重要的缺陷是這一類被集羣共享的數據要使用相似JBossCache這種集羣緩存來同步的話 ,能夠容許讀操做頻繁,由於數據在本地內存有一份副本,讀取的動做不會耗費多少資源 ,但不該當有過於頻繁的寫操做,那樣會帶來很大的網絡同步的開銷。

堆外內存致使的溢出錯誤

例如 ,一個學校的小型項目:基於B/S的電子考試系統,爲了實現客戶端能實時地從服務器端接收考試數據 , 系統使用了逆向AJAX技術(也稱爲Comet或者Server Side Push) ,選用CometD 1.1.1做爲服務端推送框架,服務器是Jetty 7.1 .4 ,硬件爲一臺普通PC機 , Core i5 CPU , 4GB內存,運行32位Windows操做系統。

測試期間發現服務端不定時拋出內存溢出異常,服務器不必定每次都會出現異常,但假如正式考試時崩潰一次,那估計整場電子考試都會亂套,網站管理員嘗試過把堆開到最大, 而32位系統最多到1.6GB就基本沒法再加大了,並且開大了基本沒效果,拋出內存溢出異常好像還更加頻繁了。加入-XX :+HeapDumpOnOutOfMemoryError,竟然也沒有任何反應,拋出內存溢出異常時什麼文件都沒有產生。無奈之下只好掛着jstat並一直緊盯屏幕,發現GC並不頻 繁 ,Eden區、Survivor區、老年代以及永久代內存所有都表示「情緒穩定,壓力不大」, 但就是照樣不停地拋出內存溢出異常,管理員壓力很大。最後 ,在內存溢出後從系統日誌中找到異常堆棧,如代碼清單5-1所示。

代碼清單5 - 1 異常堆找

[org.eclipse.jetty.util.log]handle failed java.lang.OutOfMemoryError:null at sun.raise.Unsafe.allocateMemory (Native Method )
at java.nio.DirectByteBuffer.<init> (DirectByteBuffer.java :99 )
at java.nio.ByteBuffer.allocateDirect (ByteBuffer.java :288 )
at org.eclipse.jetty.io.nio.DirectNIOBuffer.<init>
...
複製代碼

你們知道操做系統對每一個進程能管理的內存是有限制的,這臺服務器使用的32位

Windows平臺的限制是2GB ,其中劃了1.6GB給Java堆 ,而Direct Memory內存並不算入1.6GB的堆以內,所以它最大也只能在剩餘的0.4GB空間中分出一部分。在此應用中致使溢出的關鍵是:垃圾收集進行時,虛擬機雖然會對Direct Memory進行回收,可是Direct Memory卻不能像新生代、老年代那樣,發現空間不足了就通知收集器進行垃圾回收,它只能等待老年代滿了後Full GC , 而後「順便地」幫它清理掉內存的廢棄對象。不然它只能一直等到拋出內存溢出異常時,先catch掉 ,再在catch塊裏面「大喊聲:「System.gc()! 」。要是虛擬機仍是不聽 ( 譬如打開了-XX:+DisableExplicitGC開關),那就只能眼睜睜地看着堆中還有許多空閒內

存 ,本身卻不得不拋出內存溢出異常了。而本案例中使用的CometD 1.1.1框架,正好有大量 的NIO操做須要使用到Direct Memory內存。

從實踐經驗的角度出發,除了Java堆和永久代以外,咱們注意到下面這些區域還會佔用較多的內存,這裏全部的內存總和受到操做系統進程最大內存的限制。

  • Direct Memory : 可經過-XX : MaxDirectMemorySize調整大小,內存不足時拋出OutOfMemoryError或者OutOfMemoryError : Direct buffer memory。
  • 線程堆棧:可經過-Xss調整大小,內存不足時拋出StackOverflowError (縱向沒法分配, 即沒法分配新的棧幀)或者OutOfMemoryError : unable to create new native thread (橫向沒法分配 ,即沒法創建新的線程)。
  • Socket緩存區:每一個Socket鏈接都Receive和Send兩個緩存區,分別佔大約37KB和25KB內存,鏈接多的話這塊內存佔用也比較可觀。若是沒法分配,則可能會拋出IOException : Too many open files異常。
  • JNI代碼 :若是代碼中使用JNI調用本地庫,那本地庫使用的內存也不在堆中。
  • 虛擬機和GC:虛擬機、GC的代碼執行也要消耗必定的內存。

外部命令致使系統緩慢

這是一個來自網絡的案例:一個數字校園應用系統,運行在一臺4個CPU的Solaris 10操做系統上,中間件爲GlassFish服務器。系統在作大併發壓力測試的時候,發現請求響應時間比較慢 ,經過操做系統的mpstat工具發現CPU使用率很高 ,而且系統佔用絕大多數的CPU資源的程序並非應用系統自己。這是個不正常的現象,一般狀況下用戶應用的CPU佔用率應該佔主要地位,才能說明系統是正常工做的。

經過Solaris 10的Dtrace腳本能夠查看當前狀況下哪些系統調用花費了最多的CPU資源 ,Dtrace運行後發現最消耗CPU資源的居然是「fork」系統調用。衆所周知,「fork」系統調用 是Linuxffi來產生新進程的,在Java虛擬機中,用戶編寫的Java代碼最多隻有線程的概念,不該當有進程的產生。

這是個很是異常的現象。經過本系統的開發人員,最終找到了答案:每一個用戶請求的處 理都須要執行一個外部shell腳原本得到系統的一些信息。執行這個shell腳本是經過Java的 Runtime.getRuntime().exec() 方法來調用的。這種調用方式能夠達到目的,可是它在Java 虛擬機中是很是消耗資源的操做,即便外部命令自己能很快執行完畢,頻繁調用時建立進程 的開銷也很是可觀。Java虛擬機執行這個命令的過程是:首先克隆一個和當前虛擬機擁有同樣環境變量的進程,再用這個新的進程去執行外部命令,最後再退出這個進程。若是頻繁執 行這個操做,系統的消耗會很大,不只是CPU, 內存負擔也很重。

用戶根據建議去掉這個Shell腳本執行的語句,改成使用Java的API去獲取這些信息後, 系統很快恢復了正常。

服務器JVM進程崩潰

例如,一個基於B/S的MIS系統,硬件爲兩臺2個CRJ、8GB內存的HP系統,服務器是

WebLogic 9.2 。正常運行一段時間後,最近發如今運行期間頻繁出現集羣節點的虛擬機進程自動關閉的現象,留下了一個hs_err_pid###.log文件後 ,進程就消失了,兩臺物理機器裏的每一個節點都出現過進程崩潰的現象。從系統日誌中能夠看出, 每一個節點的虛擬機進程在崩潰前不久,都發生過大量相同的異常,見代碼清單5-2。

java.net.SocketException :Connection reset
at java.net.SocketInputStream.read(SocketInputStream.java:168)
at java.io.BufferedlnputStream. fill (BufferedlnputStream. java :218 )
at java.io.BufferedlnputStream.read(BufferedlnputStream.java:235)
at org.apache.axis.transport.http.HTTPSender.readHeadersFromSocket (HTTPSender.java :583 ) at org.apache,axis.transport.http.HTTPSender.invoke(HTTPSender.java:143)
...99 more
複製代碼

這是一個遠端斷開鏈接的異常,經過系統管理員瞭解到系統最近與一個OA門戶作了集成 ,在MIS系統工做流的待辦事項變化時,要經過Web服務通知0A門戶系統,把待辦事項的變化同步到OA門戶之中。經過SoapU測試了一下同步待辦事項的幾個Web服務,發現調用後居然須要長達3分鐘才能返回,而且返回結果都是鏈接中斷。

因爲MS系統的用戶多,待辦事項變化很快,爲了避免被OA系統速度拖累,使用了異步的方式調用Web服務,但因爲兩邊服務速度的徹底不對等,時間越長就累積了越多Web服務沒有調用完成,致使在等待的線程和Socket鏈接愈來愈多,最終在超過虛擬機的承受能力後使得虛擬機進程崩潰。解決方法:通知OA門戶方修復沒法使用的集成接口,並將異步調用改成生產者/消費者模式的消息隊列實現後,系統恢復正常。

不恰當數據結構致使內存佔用過大

例如,有一個後臺RPC服務器,使用64位虛擬機,內存配置爲-Xms4g-Xmx8g-Xmnlg, 使用ParNew+CMS的收集器組合。平時對外服務的Minor GC時間約在30毫秒之內,徹底能夠接受。但業務上須要每10分鐘加載一個約80MB的數據文件到內存進行數據分析,這些數據會在內存中造成超過100萬個HashMap<Long,Long>Entry,在這段時間裏面Minor GC就會形成超過500毫秒的停頓,對於這個停頓時間就接受不了了,具體狀況以下面GC日誌所示。

觀察這個案例,發現平時的Minor GC時間很短,緣由是新生代的絕大部分對象都是可清除的, 在Minor GC以後Eden和Survivor基本上處於徹底空閒的狀態。而在分析數據文件期間,800MB的Eden空間很快被填滿從而引起GC ,但Minor GC以後,新生代中絕大部分對象依然是存活的。咱們知道ParNew收集器使用的是複製算法,這個算法的高效是創建在大部分對象都「朝生夕滅」的特性上的,若是存活對象過多,把這些對象複製到Survivor並維持這些對象引用的正確就成爲一個沉重的負擔,所以致使GC暫停時間明顯變長。

若是不修改程序,僅從GC調優的角度去解決這個問題,能夠考慮將Survivor空間去掉(加入參數-XX : SurvivorRatio=6553六、 -XX : MaxTenuringThreshold=0或者-XX :+AlwaysTenure ) , 讓新生代中存活的對象在第一次Minor GC後當即進入老年代,等到Major GC的時候再清理它們。這種措施能夠治標,但也有很大反作用,治本的方案須要修改程序 ,由於這裏的問題產生的根本緣由是用HashMap< Long,Long> 結構來存儲數據文件空間效率過低。

下面具體分析一下空間效率。在HashMap<Long,Long> 結構中,只有Key和Value所存放的兩個長整型數據是有效數據,共16B ( 2x8B ) 。這兩個長整型數據包裝成java.lang.Long對象以後,就分別具備8B的MarkWord、8B的Klass指針 ,在加8B存儲數據的long值。在這兩個Long對贏組成Map.Entry以後 ,又多了 16B的對象頭,而後一個8B的next字段和4B的int型的hash字段 ,爲了對齊,還必須添加4B的空白填充,最後還有HashMap中對這個Entry的8B的引用 ,這樣增長兩個長整型數字,實際耗費的內存爲 (Long(24B)x2)+Entry(32B)+HashMap Ref(8B)=88B,空間效率爲16B/88B=18%,實在過低了。

由Windows虛擬內存致使的長時間停頓

例如 ,有一個帶心跳檢測功能的GUI桌面程序,每15秒會發送一次心跳檢測信號,若是對方30秒之內都沒有信號返回,那就認爲和對方程序的鏈接已經斷開。程序上線後發現心跳檢測有誤報的機率,查詢日誌發現誤報的緣由是程序會偶爾出現間隔約一分鐘左右的時間徹底無日誌輸出,處於停頓狀態。

由於是桌面程序,所需的內存並不大(-Xmx256m), 因此開始並無想到是GC致使的程序停頓,可是加入參數-XX : +PrintGCApplicationStoppedTime-XX : +PrintGCDateStamps- Xloggc : gclog.log後 ,從GC日誌文件中確認了停頓確實是由GC致使的,大部分GC時間都控制在100毫秒之內,但偶爾就會出現一次接近1分鐘的GC。

從GC日誌中找到長時間停頓的具體日誌信息(添加了-XX : +PrintReferenceGC參數), 找到的日誌片斷以下所示。從日誌中能夠看出,真正執行GC動做的時間不是很長,但從準備開始GC ,到真正開始GC之間所消耗的時間卻佔了絕大部分。

除GC日誌以外,還觀察到這個GUI程序內存變化的一個特色,當它最小化的時候,資源管理中顯示的佔用內存大幅度減少,可是虛擬內存則沒有變化,所以懷疑程序在最小化時它的工做內存被自動交換到磁盤的頁面文件之中了,這樣發生GC時就有可能由於恢復頁面文件的操做而致使不正常的GC停頓。

在MSDN上查證後確認了這種猜測,所以,在Java的GUI程序中要避免這種現象,能夠加入參數「-Dsun.awt.keepWorkingSetOnMinimize=true」來解決。這個參數在許多AWT的程序上都有應用,例如JDK自帶的Visual VM,用於保證程序在恢復最小化時可以當即響應。在這個案例中加入該參數後,問題獲得解決。

實戰:Eclipse運行速度調優

調優前的程序運行狀態

筆者使用Eclipse做爲平常工做中的主要IDE工具 ,因爲安裝的插件比較大(如 Klocwork、 ClearCase LT等 )、代碼也不少,啓動Eclipse直到全部項目編譯完成須要四五分鐘。一直對開發環境的速度感受不滿意,趁着編寫這本書的機會,決定對Eclipse進行「動刀」調優。

筆者機器的Eclipse運行平臺是32位Windows 7系統,虛擬機爲HotSpot VM 1.5 b64。硬件爲ThinkPad X201 , Intel i5 CPU, 4GB物理內存。在初始的配置文件eclipse.ini中,除了指定JDK的路徑、設置最大堆爲512MB以及開啓了JMX管理 (須要在VisualVM中收集原始數據) 外,未作其餘任何改動,原始配置內容如代碼清單5-3所示。

爲了要與調優後的結果進行量化對比,調優開始前筆者先作了一次初始數據測試。測試用例很簡單,就是收集從Eclipse啓動開始,直到全部插件加載完成爲止的總耗時以及運行狀態數據 ,虛擬機的運行數據經過VisualVM及其擴展插件VisualGC進行採集。測試過程當中反覆啓動數次Eclipse直到測試結果穩定後,取最後一次運行的結果做爲數據樣本(爲了不操做系統未能及時進行磁盤緩存而產生的影響),數據樣本如圖5-2所示。

Eclipse啓動的總耗時沒有辦法從監控工具中直接得到,由於VisualVM不可能知道Eclipse運行到什麼階段算是啓動完成。爲了測試的準確性,筆者寫了一個簡單的Echpse插件 ,用於統計Eclipse的啓動耗時。因爲代碼很簡單,而且本書不是Eclipse RCP開發的滅程,因此只列出代碼清單5-4供讀者參考,再也不延伸講解。

上述代碼打包成jar後放到Eclipse的plugins目錄,反覆啓動幾回後,插件顯示的平均時間穩定在15秒左右,如圖5-3所示。

根據VisualGC和Eclipse插件收集到的信息,總結原始配置下的測試結果以下。

  • 整個啓動過程平均耗時約15秒。
  • 最後一次啓動的數據樣本中,垃圾收集總耗時4.149秒 ,其中 :
    • Full GC被觸發了19次,共耗時3.166秒。
    • Minor GC被觸發了378次 ,共耗時0.983秒。
  • 加載類9115個 ,耗時4.114秒。
  • JIT編譯時間爲1.999秒。
  • 虛擬機512MB的堆內存被分配爲40MB的新生代(31.5的Eden空間和兩個4MB的Surviver空間)以及472MB的老年代。

客觀地說,因爲機器硬件還不錯(請讀者以2010年普通PC機的標準來衡量),15秒的啓動時間其實還在可接受範圍之內,可是從VisualGC中反映的數據來看,主要問題是非用戶程序時間(圖5-2中的Compile Time、 Class Load Time、 GC Time ) 很是之高,佔了整個啓動過程耗時的一半以上(這裏存在少量誇張成分,由於如JIT編譯等動做是在後臺線程完成的, 用戶程序在此期間也正常執行,因此並無佔用了一半以上的絕對時間)。虛擬機後臺佔用太多時間也直接致使Eclipse在啓動後的使用過程當中常常有不時停頓的感受,因此進行調優有較大的價值。

升級JDK 1.6的性能變化及兼容問題

對Eclipse進行調優的第一步就是先把虛擬機的版本進行升級,但願能先從虛擬機版自己上獲得一些「免費的」性能提高。

每次JDK的大版本發佈時,開發商確定都會宣稱虛擬機的運行速度比上一版本有了很大的提升 ,這雖然是個廣告性質的宣言,常常被人從升級列表或者技術白皮書中直接忽略過去 ,但從國內外的第三方評測數據來看,版本升級至少某些方面確實帶來了必定的性能改善,如下是一個第三方網站對JDK 1.五、1.六、1.7三個版本作的性能評測,分別測試瞭如下4 個用例:

  • 生成500萬個的字符串。
  • 500萬次ArrayList<String>數據插入,使用第一點生成的數據。
  • 生成500萬個HashMap<String,Integer> , 每一個鍵-值對經過併發線程計算,測試併發能力。
  • 打印500萬個ArrayList<String>中的值到文件,並重讀回內存。

三個版本的JDK分別運行這3個用例的測試程序,測試結果如圖5-4所示。

從這4個用例的測試結果來看, JDK 1.6比JDK 1.5有大約15%的性能提高,儘管對JDK僅測試這4個用例並不能說明什麼問題,須要經過測試數據來量化描述一個JDK比舊版提高了多少是很難作到很是科學和準確的(要作稍微靠譜一點的測試,能夠使用SPEQjvm200, 來完成 ,或者把相應版本的TCP中數萬個測試用例的性能數據對比一下可能更有說服力), 但我仍是選擇相信此次「軟廣告」性質的測試,把JDK版本升級到1.6 Update 21。

此次升級到JDK 1.6之 後 ,性能有什麼變化先暫且不談,在使用幾分鐘以後,發生了內存溢出,如圖5-5所示。

此次內存溢出徹底出乎筆者的意料以外:決定對Eclipse作調優是由於速度慢,但開發環境一直都很穩定,至少沒有出現過內存溢出的問題,而此次升級除了eclipse.ini中的JVM路徑改變了以外,還未進行任何運行參數的調整,進到Eclipse主界面以後隨便打開了幾個文件就拋出內存溢出異常了,難道JDK1.6Update21有哪一個API出現了嚴重的泄漏問題嗎?

事實上 ,並非JDK 1.6出現了什麼問題,根據前面章節中介紹的相關原理和工具,咱們要查明這個異常的緣由而且解決它一點也不困難。打開VisualVM ,監視頁籤中的內存曲線部分如圖5-6和圖5-7所示。

在Java堆中監視曲線中,「堆大小」的曲線與「使用的堆」的曲線一直都有很大的間隔距離 ,每當兩條曲線開始有互相靠近的趨勢時,「最大堆」的曲線就會快速向上轉向,而「使用的堆」的曲線會向下轉向。「最大堆」的曲線向上是虛擬機內部在進行堆擴容,運行參數中並無指定最小堆( -Xms ) 的值與最大堆( -Xmx ) 相等,因此堆容量一開始並無擴展到最大值,而是根據使用狀況進行伸縮擴展。「使用的堆」的曲線向下是由於虛擬機內部觸發了一次垃圾收集,一些廢棄對象的空間被回收後,內存用量相應減小,從圖形上看,Java堆運做是徹底正常的。但永久代的監視曲線就有問題了,「PermGen大小」的曲線與「使用的

PermGen」的曲線幾乎徹底重合在一塊兒,這說明永久代中沒有可回收的資源,因此 「使用的PermGen」 的曲線不會向下發展,永久代中也沒有空間能夠擴展,因此「PermGen大小」的曲線不能向上擴展。此次內存溢出很明顯是永久代致使的內存溢出。

再注意到圖5-7中永久代的最大容量: 「67 , 108 , 864個字節」 ,也就是64MB ,這剛好是JDK在未使用-XX : MaxPermSize參數明確指定永久代最大容量時的默認值,不管JDK 1.5仍是JDK 1.6,這個默認值都是64MB。對於Eclipse這種規模的Java程序來講,64MB的永久代內存空間顯然是不夠的,溢出很正常,那爲什麼在JDK 1.5中沒有發生過溢出呢?

在VisualVM的「概述-JVM參數」頁籤中,分別檢查使用JDK 1.5和JDK 1.6運行Eclipse時的 JVM參數,發現使用JDK 1.6時,只有如下3個JVM參數,如代碼清單5-5所示。

而使用JDK 1.5運行時 ,就有4條JVM參數 ,其中多出來的一條正好就是設置永久代最大容量的-XX : MaxPermSize=256M,如代碼清單5-6所示。

爲何會這樣呢?筆者從Eclipse的Bug List網站上找到了答案:使用JDK 1.5時之因此有永久代容量這個參數,是由於在eclipse.ini中存在「–launcher.XXMaxPermSize 256M」這項設置 ,當launcher——也就是Windows下的可執行程序eclipse.exe,檢測到假如是Eclipse運行在 Sun公司的虛擬機上的話,就會把參數值轉化爲-XX : MaxPermSize傳遞給虛擬機進程,由於 三大商用虛擬機中只有Sun系列的虛擬機纔有永久代的概念,也就是隻有HotSpot虛擬機須要設置這個參數,JRockit虛擬機和IBMJ9虛擬機都不須要設置。

在2009年4月2 0 日 ,Oracle公司正式完成了對Sun公司的收購,此後不管是網頁仍是具體程序產品,提供商都從Sun變爲了Oracle , 而eclipse.exe就是根據程序提供商判斷是否爲Sun的虛擬機,當JDK 1.6 Update 21 中java.exe、javaw.exe的「Company」屬性從「Sun Microsystems Inc.」變爲「Oracle Corporation」以後, Eclipse就徹底不認識這個虛擬機了,所以沒有把最大永久代的參數傳遞過去。

瞭解緣由以後,解決方法就簡單了,launcher不認識就只好由人來告訴它,即在 eclipse.ini中明確指定-XX : MaxPermSize=256M這個參數就能夠了。

編譯時間和類加載時間的優化

從Eclipse啓動時間上來看,升級到JDK 1.6所帶來的性能提高是……嗯?基本上沒有提高?屢次測試的平均值與JDK 1.5的差距徹底在實驗偏差範圍以內。

Sun JDK 1.6性能白皮書描述的衆多相對於JDK 1.5的提高不至於所有是廣告,雖然總啓動時間沒有減小,但在查看運行細節的時候,卻發現了一件很值得注意的事情:在JDK 1.6中啓動完Eclipse所消耗的類加載時間比JDK 1.5長了接近一倍,不要看反了,這裏寫的是JDK 1.6的類加載比JDK 1.5慢一倍,測試結果如代碼清單5-7所示,反覆測試屢次仍然是類似的結果。

在本例中,類加載時間上的差距並不能做爲一個具備廣泛性的測試結果去說明JDK 1.6 的類加載必然比JDK 1.5慢 ,筆者測試了本身機器上的Tomcat和GlassFish啓動過程,並未沒有出現相似的差距。在國內最大的Java社區中,筆者發起過關於此問題的討論 ,從參與者反饋的測試結果來看,此問題只在一部分機器上存在,並且JDK 1.6的各個Update版之間也存在很大差別。

屢次試驗後,筆者發如今機器上兩個JDK進行類加載時,字節碼驗證部分耗時差距尤爲 嚴重。考慮到實際狀況:Eclipse使用者甚多,它的編譯代碼咱們能夠認爲是可靠的,不須要在加載的時候再進行字節碼驗證,所以經過參數-Xverify : none禁止掉字節碼驗證過程也可做爲一項優化措施。加入這個參數後,兩個版本的JDK類加載速度都有所提升,JDK 1.6的類加載速度仍然比JDK 1.5慢 ,可是二者的耗時已經接近了許多,測試數據如代碼清單5-8所示。關於類與類加載的話題,譬如剛剛提到的字節碼驗證是怎麼回事,本書專門規劃了兩個章節進行詳細講解,在此再也不延伸討論。

在取消字節碼驗證以後,JDK 1.5的平均啓動降低到了13秒,而JDK 1.6的測試數據平均比JDK 1.5快1秒,降低到平均12秒左右,如圖5-8所示。在類加載時間仍然落後的狀況下,依然能夠看到JDK 1.6在性能上比JDK 1.5稍有優點,說明至少在Eclipse啓動這個測試用例上, 升級JDK版本確實能帶來一些「免費的」性能提高。

前面說過,除了類加載時間之外,在VisualGC的監視曲線中顯示了兩項很大的非用戶程序耗時:編譯時間( Compile Time ) 和垃圾收集時間( GC Time )。垃圾收集時間讀者應該很是清楚了,而編譯時間是什麼呢?編 譯時間是指虛擬機的JIT編譯器( Just In Time Compiler ) 編譯熱點代碼( Hot Spot Code )的耗 時。咱們知道Java語言爲了實現跨平臺的特性,Java代碼編譯出來後造成的Class文件中存儲 的是字節碼( ByteCode ) ,虛擬機經過解釋方式執行字節碼命令,比起C/C++編譯成本地二 進制代碼來講,速度要慢很多。爲了解決程序解釋執行的速度問題, JDK 1.2之後,虛擬機內置了兩個運行時編譯器1 ,若是一段Java方法被調用次數達到必定程度,就會被斷定爲熱代碼交給JIT編譯器即時編譯爲本地代碼,提升運行速度(這就是HotSpot虛擬機名字的由來 )。甚至有可能在運行期動態編譯比C/C++的編譯期靜態譯編出來的代碼更優秀,由於運 行期能夠收集不少編譯器沒法知道的信息,甚至能夠採用一些很激進的優化手段,在優化條 件不成立的時候再逆優化退回來。因此Java程序只要代碼沒有問題(主要是泄漏問題,如內 存泄漏、鏈接泄漏),隨着代碼被編譯得愈來愈完全,運行速度應當是越運行越快的。 Java 的運行期編譯最大的缺點就是它進行編譯須要消耗程序正常的運行時間,這也就是上面所說 的「編譯時間」。

虛擬機提供了一個參數-Xint禁止編譯器運做,強制虛擬機對字節碼採用純解釋方式執行。若是讀者想使用這個參數省下Eclipse啓動中那2秒的編譯時間得到一個「更好看」的成績的話,那恐怕要失望了,加上這個參數以後,雖然編譯時間確實降低到0 , 但Eclipse啓動的總時間劇增到27秒。看來這個參數如今最大的做用彷佛就是讓用戶懷念一下JDK 1.2以前那使人心酸和心碎的運行速度。

與解釋執行相對應的另外一方面,虛擬機還有力度更強的編譯器:當虛擬機運行在-client 模式的時候,使用的是一個代號爲C1的輕量級編譯器,另外還有一個代號爲C2的相對重量級的編譯器能提供更多的化搶施,若是使用-server模夫的虛擬機啓動Eclipse將會使用到C2 編譯器 ,這時從VisualGC能夠看到啓動過程當中虛擬機使用了超過15秒的時間去進行代碼編譯。若是讀者的工做習慣是長時間不關閉Eclipse的話 ,C2編譯器所消耗的額外編譯時間最終仍是會在運行速度的提高之中賺回來,這樣使用-server模式也是一個不錯的選擇。不過至少在本次實戰中,咱們仍是繼續選用-cllent虛擬機來運行Eclipse。

調整內存設置控制垃圾收集頻率

三大塊非用戶程序時間中,還剩下GC時間沒有調整,而GC時間卻又是其中最重要的一塊 ,並不僅是由於它是耗時最長的一塊,更由於它是一個穩定持續的過程。因爲咱們作的測試是在測程序的啓動時間,因此類加載和編譯時間在這項測試中的影響力被大幅度放大了。 在絕大多數的應用中,不可能出現持續不斷的類被加載和卸載。在程序運行一段時間後,熱 點方法被不斷編譯,新的熱點方法數量也總會降低,可是垃圾收集則是隨着程序運行而不斷運做的 ,因此它對性能的影響才顯得尤其重要。

在Eclipse啓動的原始數據樣本中,短短15秒 ,類共發生了19次Full GC和378次Minor GC ,一共397次GC共形成了超過4秒的停頓,也就是超過1/4的時間都是在作垃圾收集,這個運行數據看起來實在太糟糕了。

首先來解決新生代中的Minor GC ,雖然GC的總時間只有不到1秒 ,但卻發生了378次之多。從VisualGC的線程監視中看到,Eclipse啓動期間一共發起了超過70條線程 ,同時在運行的線程數超過25條 ,每當發生一次垃圾收集動做,全部用戶線程都必須跑到最近的一個安全點(SafePoint)而後掛起線程等待垃圾回收。這樣過於頻繁的GC就會致使不少沒有必要的安全點檢測、線程掛起及恢復操做。

新生代GC頻繁發生,很明顯是因爲虛擬機分配給新生代的空間過小而致使的,Eden區加上一個Survivor區還不到35MB。所以頗有必要使用-Xmn參數調整新生代的大小。

再來看一看那19次Full GC,看起來19次並「很少」(相對於378次Minor GC來講),但總耗時爲3.166秒 ,佔了GC時間的絕大部分,下降GC時間的主要目標就要下降這部分時間。從 VisualGC的曲線圖上可能看得不夠精確,此次直接從GC日誌-中分析一下這些Full GC是如何 產生的,代碼清單5-9中是啓動最開始的2.5秒內發生的10次Full GC記錄。

括號中加粗的數字表明老年代的容量,這組GC日誌顯示了 10次Full GC發生的緣由所有都是老年代空間耗盡,每發生一次Full GC都伴隨着一次老年代空間擴容:1536KB-> 1664KB->2684KB……42056KB-> 46828KB,10次GC之後老年代容量從起始的1536KB擴大 到46828KB,當15秒後Eclipse啓動完成時,老年代容量擴大到了103428KB,代碼編譯開始後 ,老年代容量到達頂峯473MB,整個Java堆到達最大容量512MB。

日誌還顯示有些時候內存回收情況很不理想,空間擴容成爲獲取可用內存的最主要手段 ,譬如語句「Tenured : 25092K- >24656K ( 25108K ) ,0.1112429 secs」,表明老年代當前容量爲25108KB,內存使用到25092KB的時候發生Full G C,花費0.11秒把內存使用下降到24656KB,只回收了不到500KB的內存,此次GC基本沒有什麼回收效果,僅僅作了擴容,擴容過程相比起回收過程能夠看作是基本不須要花費時間的,因此說這0.11秒幾乎是白白浪費了。

由上述分析能夠得出結論:Eclipse啓動時, Full GC大多數是因爲老年代容量擴展而致使的 ,由永久代空間擴展而致使的也有一部分。爲了不這些擴展所帶來的性能浪費,咱們能夠把-Xms和-XX : PermSize參數值設置爲-Xmx和-XX : MaxPermSize參數值同樣,這樣就強制 虛擬機在啓動的時候就把老年代和永久代的容量固定下來,避免運行時自動擴展。

根據分析,優化計劃肯定爲:把新生代容量提高到128MB,避免新生代頻繁GC ;把Java 堆、永久代的容量分別固定爲512MB和96MB,避免內存擴展。這幾個數值都是根據機器硬件、Eclipse插件和工程數量來決定的,讀者實踐的時候應根據VisualGC中收集到的實際數據進行設置。改動後的eclipse.ini配置如代碼清單5-10所示。

如今這個配置之下,GC次數已經大幅度下降,圖5-9是Eclipse啓動後1分鐘的監視曲線, 只發生了8次Minor GC和4次Full GC , 總耗時爲1.928秒。

這個結果已經算是基本正常,可是還存在一點瑕疵:從Old Gen的曲線上看,老年代直接固定在384MB ,而內存使用量只有66MB,而且一直很平滑,徹底不該該發生Full GC纔對,那4次Full GC是怎麼來的?使用jstat-gccause查詢一下最近一次GC的緣由,見代碼清單5-11。

從LGCC ( Last GC Cause ) 中看到,原來是代碼調用System.gc() 顯式觸發的G C ,在內存設置調整後,這種顯式GC已不符合們的指望,所以在eclipse.ini中加入參數-XX : +DisableExplicitGC屏蔽掉System.gc()。 再次測試發現啓動期間的Full GC已經徹底沒有了,只有6次Minor GC,耗時417毫秒,與調優前4.149秒的測試樣本相比,正好是十分之一。進行GC調優後Eclipse的啓動時間降低很是明顯,比整個GC時間下降的絕對值還大,如今啓動只須要7秒多,如圖5-10所示。

選擇收集器下降延遲

如今Eclipse啓動已經比較迅速了,但咱們的調優實戰尚未結束,畢竟Eclipse是拿來寫程序的,不是拿來測試啓動速度的。咱們不妨再在Eclipse中測試一個很是經常使用但又比較耗時的操做:代碼編譯。圖5-11是當前配置下Eclipse進行代碼編譯時的運行數據,從圖中能夠看出 ,新生代每次回收耗時約65毫秒,老年代每次回收耗時約725毫秒。對於用戶來講,新生代GC的耗時還好,65毫秒在使用中沒法察覺到,而老年代每次GC停頓接近1秒鐘 ,雖然比較長時間纔會出現一次,但停頓仍是顯得太長了一些。

推薦博客

程序員寫代碼以外,如何再賺一份工資?

再注意看一下編譯期間的CPU資源使用情況。圖5-12是Eclipse在編譯期間的CPU使用率曲線圖,整個編譯過程當中平均只使用了不到30%的CPU資源 ,垃圾收集的CPU使用率曲線更是幾乎與座標橫軸緊貼在一塊兒,這說明CPU資源還有不少可利用的餘地。

列舉GC停頓時間、CPU資源富餘的目的,都是爲了接下來替換掉Client模式的虛擬機中默認的新生代、老年代串行收集器作鋪墊。

Eclipse應當算是與使用者交互很是頻繁的應用程序,因爲代碼太多,筆者習慣在作全量編譯或者清理動做的時候,使用「Run in Backgroup」功能一邊編譯一邊繼續工做。很容易想到CMS是最符合這類場景的收集器。所以嘗試在 eclipse.ini 中再加入這兩個參數-XX : +UseConcMarkSweepGC、 -XX :

+UseParNewGC ( ParNew收集器是使用CMS收集器後的默認新生代收集器,寫上僅是爲了配置更加清晰),要求虛擬機在新生代和老年代分別使用ParNew和CMS收集器進行垃圾回收。指定收集器以後,再次測試的結果如圖5-13所示 ,與原來使用串行收集器對比,新生代停頓從每次65毫秒降低到了每次53毫 秒 ,而老年代的停頓時間更是從725毫秒大幅降低到了36毫秒。

固然 ,CMS的停頓階段只是收集過程當中的一小部分,並非真的把垃圾收集時間從725 毫秒變成36毫秒了。在GC日誌中能夠看到CMS與程序併發的時間約爲400毫秒,這樣收集器的運做結果就比較使人滿意了。

到此 ,對於虛擬機內存的調優基本就結束了,此次實戰能夠看作是一次簡化的服務端調優過程,由於服務端調優有可能還會存在於更多方面,如數據庫、資源池、磁盤I/O等 ,但對於虛擬機內存部分的優化,與此次實戰中的思路沒有什麼太大差異。即便讀者實際工做中接觸不到服務器,根據本身工做環境作一些試驗,總結幾個參數讓本身平常工做環境速度有較大幅度提高也是很划算的。最終eclipse.ini的配置如代碼清單5-12所示。

END

說到最後給你們免費分享一波福利吧!我本身收集了一些Java資料,裏面就包涵了一些BAT面試資料,以及一些 Java 高併發、分佈式、微服務、高性能、源碼分析、JVM等技術資料

資料獲取方式:請加羣BAT架構技術交流羣:171662117


相關文章
相關標籤/搜索