天天看點

JVM性能優化 JVM性能優化, Part 1 ―― JVM簡介  JVM性能優化, Part 2 ―― 編譯器 JVM性能優化, Part 3 垃圾回收  JVM 性能優化, Part 4: C4 垃圾回收 JVM性能優化, Part 5:Java的伸縮性 

JVM性能優化, Part 1 ―― JVM簡介 

衆所周知,Java應用程式是運作在JVM上的,但是你對JVM有所了解麼?作為這個系列文章的第一篇,本文将對經典Java虛拟機的運作機制做簡單介紹,内容包括“一次編寫,到處運作”的利弊、垃圾回收的基本原理、常用垃圾回收算法的示例和編譯器優化等。後續的系列文章将會JVM性能優化的内容進行介紹,包括新一代JVM的設計思路,以及如何支援當今Java應用程式對高性能和高擴充性的要求。

如果你是一名程式員,那麼毫無疑問,你肯定有過某種興奮的感覺,就像是當一束靈感之光照亮了你思考方向,又像是神經元最終建立連接配接,又像是你解放思想開拓了新的局面。就我個人來說,我喜歡這種學習新知識的感覺。我在工作時就常常會有這種感覺,我的工作會涉及到一些JVM的相關技術,這着實令我興奮,尤其是工作涉及到垃圾回收和JVM性能優化的時候。在這個系列中,我希望可以與你分享一些這方面的經驗,希望你也會像我一樣熱愛JVM相關技術。

這個系列文章主要面向那些想要裂解JVM底層運作原理的Java程式員。文章立足于較高的層面展開讨論,内容涉及到垃圾回收和在不影響應用程式運作的情況下對安全快速的釋放/配置設定記憶體。你将對JVM的核心子產品有所了解:垃圾回收、GC算法、編譯器行為,以及一些常用優化技巧。此外,還會讨論為什麼對Java做基準測試(benchmark)是件很困難的事,并提供一些建議來幫助做基準測試。最後,将會介紹一些JVM和GC的前沿技術,内容涉及到Azul的Zing JVM,IBM JVM和Oracle的Garbage First(G1)垃圾回收器。

希望在閱讀此系列文章後,你能對影響Java伸縮性的因素有所了解,并且知道這些因素是如何影響Java開發的,如何使Java難以優化的。希望會你有那種發自内心的驚歎,并且能夠激勵你為Java做一點事情:拒絕限制,努力改變。如果你還沒準備好為開源事業貢獻力量,希望本系列文章可以為你指明方向。

JVM職業生涯

在我職業生涯的早期,垃圾回收的問題曾經很難解決。垃圾回收問題和JVM的跨平台問題我更加為JVM和中間件的相關技術而着迷。我對JVM的熱情源于十年前在JRockit團隊工作的經曆,當時要編碼實作一種新的、能夠自動學習、自動調優的垃圾回收算法(參見相關資源)。從那個項目開始,我踏上了JVM技術之旅,期間在BEA System公司工作的很多年,與Intel公司和Sun公司有過合作關系,在Oracle收購BEA公司和Sun公司之後為Oracle工作了一年。另外,我的碩士論文深入分析了JRockit的試驗性特性,為Deterministic Garbage Collection算法打下了基礎。當我加入Azul公司的團隊後,我回到了熟悉的工作中,負責管理維護Zing JVM的垃圾回收算法。現在我的工作有了一點小變化,負責日程安排與資源管理,關注分布式的可伸縮資料處理架構,目前在Cloudera公司工作,負責開源項目Hadoop的開發。

Java的性能與“一次編寫,到處運作”的挑戰

有不少人認為,Java平台本身就挺慢。其主要觀點簡單來說就是,Java性能低已經有些年頭了 ―― 最早可以追溯到Java第一次用于企業級應用程式開發的時候。但這早就是老黃曆了。事實是,如果你對不同的開發平台上運作簡單的、靜态的、确定性任務的運作結果做比較,你就會發現使用經過機器級優化(machine-optimized)代碼的平台比任何使用虛拟環境進行運算的都要強,JVM也不例外。但是,在過去的10年中,Java的性能有了大幅提升。市場上不斷增長的需求催生了垃圾回收算法的出現和編譯技術的革新,在不斷探索與優化的過程中,JVM茁壯成長。在這個系列文章中,我将介紹其中的一些内容。

JVM技術中最迷人的地方也正是其最具挑戰性的地方:“一次編寫,到處運作”。JVM并不對具體的用例、應用程式或使用者負載進行優化,而是在應用程式運作過程中不斷收集運作時資訊,并以此為根據動态的進行優化。這種動态的運作時特性帶來了很多動态問題。在設計優化方案時,以JVM為工作平台的程式無法依靠靜态編譯和可預測的記憶體配置設定速率(predictable allocation rates)對應用程式做性能評估,至少在對生産環境進行性能評估時是不行的。

機器級優化過的代碼有時可以達到更好的性能,但它是以犧牲可移植性為代價的,在企業級應用程式中,動态負載和快速疊代更新是更加重要的。大多數企業會願意犧牲一點機器級優化代碼帶來的性能,以此換取Java平台的諸多優勢:

  • 編碼簡單,易于實作(意味着可以更快的推向市場)
  • 有很多非常有才的程式員
  • 使用Java API和标準庫實作快速開發
  • 可移植性 ―― 無需為每個平台都編寫一套代碼
從源代碼到位元組碼

作為一名Java程式員,你可以已經對編碼、編譯和運作這一套流程比較熟悉了。假如說,現在你寫了一個程式代碼MyApp.java,準備編譯運作。為了運作這個程式,首先,你需要使用JDK内建的Java語言編譯器,javac,對這個檔案進行編譯,它可以将Java源代碼編譯為位元組碼。javac将根據Java程式的源代碼生成對應的可執行位元組碼,并将其儲存為同名類檔案:MyApp.class。在經過編譯階段後,你就可以在指令行中使用java指令或其他啟動腳本載入可執行的類檔案來運作程式,并且可以為程式添加啟動參數。之後,類會被載入到運作時(這裡指的是正在運作的JVM),程式開始運作。

上面所描述的就是在運作Java應用程式時的表面過程,但現在,我們要深入挖掘一下,在調用Java指令時,到底發生了什麼?JVM到底是什麼?大多數程式員是通過不斷的調優,即使用相應的啟動參數,與JVM進行互動,使Java程式運作的更快,同時避免程式出現“out of memory”錯誤。但你是否想過,為什麼我們必須要通過JVM來運作Java應用程式呢?

什麼是JVM

簡單來說,JVM是用于執行Java應用程式和位元組碼的軟體子產品,并且可以将位元組碼轉換為特定硬體和特定作業系統的本地代碼。正因如此,JVM使Java程式做到了“一次編寫,到處運作”。Java語言的可移植性是得到企業級應用程式開發者青睐的關鍵:開發者無需因平台不同而把程式重新編寫一遍,因為有JVM負責處理位元組碼到本地代碼的轉換和平台相關優化的工作。

基本上來說,JVM是一個虛拟運作環境,對于位元組碼來說就像是一個機器一樣,可以執行任務,并通過底層實作執行記憶體相關的操作。

JVM也可以在運作java應用程式時,很好的管理動态資源。這指的是他可以正确的配置設定、回收記憶體,在不同的上維護一個具有一緻性的線程模型,并且可以為目前的CPU架構組織可執行指令。JVM解放了程式員,使程式員不必再關系對象的生命周期,使程式員不必再關心應該在何時釋放記憶體。而這,正是使用着類似C語言的非動态語言的程式員心中永遠的痛。

你可以将JVM當做是一種專為Java而生的特殊的作業系統,它的工作是管理運作Java應用程式的運作時環境。簡單來說,JVM就是運作位元組碼指令的虛拟執行環境,并且可以配置設定執行任務,或通過底層實作對記憶體進行操作。

JVM元件簡介

關于JVM内部原理與性能優化有很多内容可寫。作為這個系列的開篇文章,我簡單介紹JVM的内部元件。這個簡要介紹對于那些JVM新手比較有幫助,也是為後面的深入讨論做個鋪墊。

從一種語言到另一種 ―― 關于Java編譯器

編譯器

以一種語言為輸入,生成另一種可執行語言作為輸出。Java編譯器主要完成2個任務:

  1. 實作Java語言的可移植性,不必局限于某一特定平台;
  2. 確定輸出代碼可以在目标平台能夠有效率的運作。

編譯器可以是靜态的,也可以是動态的。靜态編譯器,如javac,它以Java源代碼為輸入,将其編譯為位元組碼(一種可以運作JVM中的語言)。*靜态編譯器*解釋輸入的源代碼,而生成可執行輸出代碼則會在程式真正運作時用到。因為輸入是靜态的,所有輸出結果總是相同的。隻有當你修改的源代碼并重新編譯時,才有可能看到不同的編譯結果。

動态編譯器,如使用Just-In-Time(JIT,即時編譯)技術的編譯器,會動态的将一種程式設計語言編譯為另一種語言,這個過程是在程式運作中同時進行的。JIT編譯器會收集程式的運作時資料(在程式中插入性能計數器),再根據運作時資料和目前運作環境資料動态規劃編譯方案。動态編譯可以生成更好的序列指令,使用更有效率的指令集合替換原指令集合,或剔除備援操作。收集到的運作時資料的越多,動态編譯的效果就越好;這通常稱為代碼優化或重編譯。

動态編譯使你的程式可以應對在不同負載和行為下對新優化的需求。這也是為什麼動态編譯器非常适合Java運作時。這裡需要注意的地方是,動态編譯器需要動用額外的資料結構、線程資源和CPU指令周期,才能收集運作時資訊和優化的工作。若想完成更進階點的優化工作,就需要更多的資源。但是在大多數運作環境中,相對于獲得的性能提升來說,動态編譯的帶來的性能損耗其實是非常小的 ―― 動态編譯後的代碼的運作效率可以比純解釋執行(即按照位元組碼運作,不做任何修改)快5到10倍。

記憶體配置設定與垃圾回收

記憶體配置設定

是以線程為機關,在“Java程序專有記憶體位址空間”中,也就是Java堆中配置設定的。在普通的用戶端Java應用程式中,記憶體配置設定都是單線程進行的。但是,在企業級應用程式和伺服器端應用程式中,單線程記憶體配置設定卻并不是個好辦法,因為它無法充分利用現代多核時代的并行特性。

并行應用程式設計要求JVM確定多線程記憶體配置設定不會在同一時間将同一塊位址空間配置設定給多個線程。你可以在整個記憶體空間中加鎖來解決這個問題,但是這個方法(即所謂的“堆鎖”)開銷較大,因為它迫使所有線程在配置設定記憶體時逐個執行,對資源利用和應用程式性能有較大影響。多核程式的一個額外特點是需要有新的資源配置設定方案,避免出現單線程、序列化資源配置設定的性能瓶頸。

常用的解決方案是将堆劃分為幾個區域,每個區域都有适當的大小,當然具體的大小需要根據實際情況做相應的調整,因為不同應用程式之間,記憶體配置設定速率、對象大小和線程數量的差别是非常大的。Thread Local Allocation Buffer(TLAB),有時也稱為Thraed Local Area(TLA),是線程自己使用的專用記憶體配置設定區域,在使用的時候無需擷取堆鎖。當這個區域用滿的時候,線程會申請新的區域,直到堆中所有預留的區域都用光了。當堆中沒有足夠的空間來配置設定記憶體時,堆就“滿”了,即堆上剩餘的空間裝不下待配置設定空間的對象。當堆滿了的時候,垃圾回收就開始了。

碎片化

使用TLAB的一個風險是,由于堆上記憶體碎片的增加,使用記憶體的效率會下降。如果應用程式建立的對象的大小無法填滿TLAB,而這塊TLAB中剩下的空間又太小,無法配置設定給新的對象,那麼這塊空間就被浪費了,這就是所謂的“碎片”。如果“碎片”周圍已配置設定出去的記憶體長時間無法回收,那麼這塊碎片研究長時間無法得到利用。

碎片化

是指堆上存在了大量的

碎片

,由于這些小碎片的存在而使堆無法得到有效利用,浪費了堆空間。為應用程式設定TLAB的大小時,若是沒有對應用程式中對象大小和生命周期和合理評估,導緻TLAB的大小設定不當,就會是使堆逐漸碎片化。随着應用程式的運作,被浪費的碎片空間會逐漸增多,導緻應用程式性能下降。這是因為系統無法為新線程和新對象配置設定空間,于是為防止出現OOM(out-of-memory)錯誤,而頻繁GC的緣故。

對于TLAB産生的空間浪費這個問題,可以采用“曲線救國”的政策來解決。例如,可以根據應用程式的具體環境調整TLAB的大小。這個方法既可以臨時,也可以徹底的避免堆空間的碎片化,但需要随着應用程式記憶體配置設定行為的變化而修改TLAB的值。此外,還可以使用一些複雜的JVM算法和其他的方法來組織堆空間來獲得更有效率的記憶體配置設定行為。例如,JVM可以實作空閑清單(free-list),空閑清單中儲存了堆中指定大小的空閑塊。具有類似大小空閑塊儲存在一個空閑清單中,是以可以建立多個空閑清單,每個空閑清單儲存某個範圍内的空閑塊。在某些事例中,使用空閑清單會比使用按實際大小配置設定記憶體的政策更有效率。線程為某個對象配置設定記憶體時,可以在空閑清單中尋找與對象大小最接近的空間塊使用,相對于使用固定大小的TLAB,這種方法更有利于避免碎片化的出現。

GC往事
早期的垃圾回收器有多個老年代,但實際上,存在多個老年代是弊大于利的。

另一種對抗碎片化的方法是建立一個所謂的年輕代,在這個專有的堆空間中,儲存了所有新建立的對象。堆空間中剩餘的空間就是所謂的老年代。老年代用于儲存具有較長生命周期的對象,即當對象能夠挺過幾輪GC而不被回收,或者對象本身很大(一般來說,大對象都具有較長的壽命周期)時,它們就會被儲存到老年代。為了讓你能夠更好的了解這個方法,我們有必要談談垃圾回收。

垃圾回收與應用程式性能

垃圾回收就是JVM釋放那些沒有引用指向的堆記憶體的操作。當垃圾回收首次觸發時,有引用指向的對象會被儲存下來,那些沒有引用指向的對象占用的空間會被回收。當所有可回收的記憶體都被回收後,這些空間就可以被配置設定給新的對象了。

垃圾回收不會回收仍有引用指向的對象;否則就會違反JVM規範。這個規則有一個例外,就是對軟引用或弱引用的使用,當垃圾回收器發現記憶體快要用完時,會回收隻有軟引用或弱引用指向的對象所占用的記憶體。我的建議是,盡量避免使用弱引用,因為Java規範中存在的模糊的表述可能會使你對弱引用的使用産生誤解。此外,Java本身是動态記憶體管理的,你沒必要考慮什麼時候該釋放哪塊記憶體。

對于垃圾回收來說,挑戰在于,如何将垃圾回收對應用程式造成的影響降到最小。如果垃圾回收執行的不充分,那麼應用程式遲早會發生OOM錯誤;如果垃圾回收執行的太頻繁,會對應用程式的吞吐量和響應時間造成影響,當然,這都不是好的影響。

GC算法

目前已經出現了很多垃圾回收算法。在這個系列文章中将對其中的一些進行介紹。概括來說,垃圾回收主要有兩種方式,引用計數(reference counting)和引用追蹤(reference tracing)。

  • 引用計數垃圾回收器會記錄指向某個對象的引用的數目。當指向某個對象引用數位0時,該對象占用的記憶體就可以被回收了,這是引用計數垃圾回收的一個主要優點。使用引用計數垃圾回收的需要克服的難點在于如何解決循環引用帶來的問題,以及如何保證引用計數的實效性。
  • 引用追蹤垃圾回收器會标記所有仍有引用指向的對象,并從已标記的對象出發,繼續标記這些對象指向的對象。當所有仍有引用指向的對象都被标記為“live”後,所有未标記的對象會被回收。這種方式可以解決循環引用結果帶來的問題,但是大多數情況下,垃圾回收器必須等待标記完全結束才能開始進行垃圾回收。

上面提到的兩種算法有多種不同的實作方法,其中最著名可算是标記或拷貝算法(marking or copying algorithm)和并行或并發算法(parallel or concurrent algorithm)。我将在後續的文章中對它們進行介紹。

分代垃圾回收的意思是,将堆劃分為幾個不同的區域,分别用于存儲新對象和老對象。其中“老對象”指的是挺過了幾輪垃圾回收而不死的對象。将堆空間分為年輕代和老年代,分别用于存儲新對象和老對象可以通過回收生命周期較短的對象,并将生命周期較長的對象從年輕代提升到老年代的方法來減少堆空間中的碎片,降低堆空間碎片化的風險。此外,使用年輕代還有一個好處是,它可以推出對老年代進行垃圾回收的需求(對老年代進行垃圾回收的代價比較大,因為老年代中那些生命周期較長的對象通常包含有更多的引用,周遊一次需要花費更多的時間),因那些生命周期較短的對通常會重用年輕代中的空間。

還有一個值得一提的算法改進是壓縮,它可以用來管理堆空間中的碎片。基本上将,壓縮就是将對象移動到一起,再釋放掉較大的連續空間。如果你對磁盤碎片和處理磁盤碎片的工具比較熟悉的話你就會了解壓縮的含義了,隻不過這裡的壓縮是工作在Java堆空間中的。我将在該系列後續的内容中對壓縮進行介紹。

結論:回顧與展望

JVM實作了可移植性(“一次編寫,到處運作”)和動态記憶體管理,這兩個特點也是其廣受歡迎,并且具有較高生産力的原因。

作為這個系列文章的第一篇,我介紹了編譯器如何将位元組碼轉換為平台相關指令的語言,以及如何

動态

優化Java程式的運作性能。不同的編譯器迎合了不同應用程式的需要。

此外,簡單介紹了記憶體配置設定和垃圾回收的一點内容,及其與Java應用程式性能的關系。基本上将,Java應用程式運作的速度越快,填滿Java堆所需的時間就越短,觸發垃圾回收的頻率也越高。這裡遇到的問題就是,在應用程式出現OOM錯誤之前,如何在對應用程式造成的影響盡可能小的情況下,回收足夠多的記憶體空間。将後續的文章中,我們将對傳統垃圾回收方法和現今的垃圾回收方法對JVM性能優化的影響做詳細讨論。

關于作者

Eva Andearsson對JVM技術、SOA、雲計算和其他企業級中間件解決方案有着10多年的從業經驗。在2001年,她以JRockit JVM開發者的身份加盟了創業公司Appeal Virtual Solutions(即BEA公司的前身)。在垃圾回收領域的研究和算法方面,EVA獲得了兩項專利。此外她還是提出了确定性垃圾回收(Deterministic Garbage Collection),後來形成了JRockit實時系統(JRockit Real Time)。在技術上,Eva與SUn公司和Intel公司合作密切,涉及到很多将JRockit産品線、WebLogic和Coherence整合的項目。2009年,Eva加盟了Azul System公,擔任産品經理。負責新的Zing Java平台的開發工作。最近,她改換門庭,以進階産品經理的身份加盟Cloudera公司,負責管理Cloudera公司Hadoop分布式系統,緻力于高擴充性、分布式資料處理架構的開發。

相關資源
  • “To Colelct or Not To Collect” (Eva Andreasson, Frank Hoffmann, Olof Lindholm; JVM-02: Proceedings of the Java Virtual Machine Research and Technology Symposium, 2002): 文章介紹了作者對自适應決策過程的研究,該過程用于确定應該使用哪種垃圾回收器技術,以及如何應用該技術。
  • “Reinforcement Learning for a dynamic JVM” (Eva Andreasson, KTH Royal Institute of Technology, 2002): 一篇碩士論文,介紹了如何運用增強學習(reinforcement learning)優化決策,以決定對于一個動态工作負載來說,何時開始垃圾回收的決策更加合适。
  • “Deterministic Garbage Collection: Unleash the Power of Java with Oracle JRockit Real Time” (An Oracle White Paper, August 2008): 介紹了更多JRockit實時(JRockit Real Time)系統中Deterministic Garbage Collection算法的内容。
  • “Why is Java faster when using a JIT vs. compiling to machine code?” (Stackoverflow, December 2009): 一個關于JIT的讨論。
  • Zing: Zing是一個完整實作了Java相關規範,具有高伸縮性的軟體平台,其中包含了應用程式級資源控制器、無損監控工具、以及診斷工具(這裡原文是’includes an application-aware resource controller and zero overhead, always-on production visibility and diagnostic tools’,Zing官網給出的描述是’Zing also includes a runtime monitoring and diagnostics tool called Zing Vision. It is a zero overhead, always-on production time monitoring, diagnostic and tuning tool instrumented into the Zing JVM.’,懷疑是本文作者将”vision”和”visibility”弄混了)。 Zing整合了業界領先技術,使得每個JVM執行個體可以擁有TB級的堆記憶體,使其在動态負載和極限記憶體配置設定情況下仍可以保持較高的吞吐量 。
  • “G1: Java’s Garbage First Garbage Collector” (Eric Bruno, Dr. Dobb’s, August 2009): 文章對GC做了回顧,并介紹了G1垃圾回收器。
  • Oracle JRockit: The Definitive Guide (Marcus Hirt, Marcus Lagergren; Packt Publishing, 2010): JRcokit權威指南。

英文原文:JVM performance optimization, Part 1,翻譯:ImportNew - 曹旭東

譯文連結:http://www.importnew.com/1774.html

JVM性能優化, Part 2 ―― 編譯器

ImportNew注:本文是JVM性能優化 – 第2篇 《JVM性能優化, Part 2 ―― 編譯器》第一篇 《JVM性能優化, Part 1 ―― JVM簡介 》

作為JVM性能優化系列文章的第2篇,本文将着重介紹Java編譯器,此外還将對JIT編譯器常用的一些優化措施進行讨論(參見“JVM性能優化,Part 1″中對JVM的介紹)。Eva Andreasson将對不同種類的編譯器做介紹,并比較用戶端、伺服器端和層次編譯産生的編譯結果在性能上的差別,此外将對通用的JVM優化做介紹,包括死代碼剔除、内聯以及循環優化。

Java編譯器存在是Java程式設計語言能獨立于平台的根本原因。軟體開發者可以盡全力編寫程式,然後由Java編譯器将源代碼編譯為針對于特定平台的高效、可運作的代碼。不同類型的編譯器适合于不同應用程式的需求,使編譯結果可以滿足期望的性能要求。對編譯器基本原理了解得越多,在優化Java應用程式性能時就越能得心應手。

什麼是編譯器

簡單來說,編譯器就是将一種程式設計語言作為輸入,輸出另一種可執行語言的工具。大家都熟悉的javac就是一個編譯器,所有标準版的JDK中都帶有這個工具。javac以Java源代碼作為輸入,将其翻譯為可由JVM執行的位元組碼。翻譯後的位元組碼存儲在.class檔案中,在啟動Java程序的時候,被載入到Java運作時中。

标準CPU并不能識别位元組碼,它需要被轉換為目前平台所能了解的本地指令。在JVM中,有專門的元件負責将位元組碼編譯為平台相關指令,實際上,這也是一種編譯器。有些JVM編譯器可以處理多層級的編譯工作,例如,編譯器在最終将位元組碼轉換為平台相關指令前,會為相關的位元組碼建立多層級的中間表示(intermediate representation)。

位元組碼與JVM

如果你想了解更多有關位元組碼與JVM的資訊,請閱讀 “Bytecode basics”(Bill Venners, JavaWorld)

以平台未知的角度看,我們希望盡可能的保持平台獨立性,是以,最後一級的編譯,也就是從最低級表示到實際機器碼的轉換,是與具體平台的處理器架構息息相關的。在最進階的表示上,會因使用靜态編譯器還是動态編譯器而有所差別。在這裡,我們可以選擇應用程式是以來的可執行環境,期望達到的性能要求,以及我們所面臨的資源限制。在本系列的第1篇文章的靜态編譯器與動态編譯器一節中,已經對此有過簡要介紹。我将在本文的後續章節中詳細介紹這部分内容。

靜态編譯器與動态編譯器

前文提到的javac就是使用靜态編譯器的例子。靜态編譯器解釋輸入的源代碼,并輸出程式運作時所需的可執行檔案。如果你修改了源代碼,那麼就需要使用編譯器來重新編譯代碼,否則輸出的可執行性檔案不會發生變化;這是因為靜态編譯器的輸入是靜态的普通檔案。

使用靜态編譯器時,下面的Java代碼

1 2 3

static

int

add7(

int

x ) {

return

x+

7

;

}

會生成類似如下的位元組碼:

1 2 3 4

iload0

bipush

7

iadd

ireturn

動态編譯器會動态的将一種程式設計語言編譯為另一種,即在程式運作時執行編譯工作。動态編譯與優化使運作時可以根據目前應用程式的負載情況而做出相應的調整。動态編譯器非常适合用于Java運作時中,因為Java運作時通常運作在無法預測而又會随着運作而有所變動的環境中。大部分JVM都會使用諸如Just-In-Time編譯器的動态編譯器。這裡面需要注意的是,大部分動态編譯器和代碼優化有時需要使用額外的資料結構、線程和CPU資源。要做的優化或位元組碼上下文分析越進階,編譯過程所消耗的資源就越多。在大多數運作環境中,相比于經過動态編譯和代碼優化所獲得的性能提升,這些損耗微不足道。

 JVM的多樣性與Java平台的獨立性

所有的JVM實作都有一個共同點,即它們都試圖将應用程式的位元組碼轉換為本地機器指令。一些JVM在載入應用程式後會解釋執行應用程式,同時使用性能計數器來查找“熱點”代碼。還有一些JVM會調用解釋執行的階段,直接編譯運作。資源密集型編譯任務對應用程式來說可能會産生較大影響,尤其是那些用戶端模式下運作的應用程式,但是資源密集型編譯任務可以執行一些比較進階的優化任務。更多相關内容請參見相關資源

如果你是Java初學者,JVM本身錯綜複雜結構會讓你暈頭轉向的。不過,好消息是你無需精通JVM。JVM自己會做好代碼編譯和優化的工作,是以你無需關心如何針對目标平台架構來編寫應用程式才能編譯、優化,進而生成更好的本地機器指令。

從位元組碼到可運作的程式

當你編寫完Java源代碼并将之編譯為位元組碼後,下一步就是将位元組碼指令編譯為本地機器指令。這一步會由解釋器或編譯器完成。

解釋

解釋是最簡單的位元組碼編譯形式。解釋器查找每條位元組碼指令對應的硬體指令,再由CPU執行相應的硬體指令。

你可以将解釋器想象為一個字典:每個單詞(位元組碼指令)都有準确的解釋(本地機器指令)。由于解釋器每次讀取一個位元組碼指令并立即執行,是以它就沒有機會對某個指令集合進行優化。由于每次執行位元組碼時,解釋器都需要做相應的解釋工作,是以程式運作起來就很慢。解釋執行可以準确執行位元組碼,但是未經優化而輸出的指令集難以發揮目标平台處理器的最佳性能。

編譯

另一方面,編譯執行應用程式時,*編譯器*會将加載運作時會用到的全部代碼。因為編譯器可以将位元組碼編譯為本地代碼,是以它可以擷取到完整或部分運作時上下文資訊,并依據收集到的資訊決定到底應該如何編譯位元組碼。編譯器是根據諸如指令的不同執行分支和運作時上下文資料等代碼資訊來指定決策的。

當位元組碼序列被編譯為機器代碼指令集合時,就可以對這個指令集合做一些優化操作了,優化後的指令集合會被存儲到成為code cache的資料結構中。當下一次執行這部分位元組碼序列時,就會執行這些經過優化後被存儲到code cache的指令集合。在某些情況下,性能計數器會失效,并覆寫掉先前所做的優化,這時,編譯器會執行一次新的優化過程。使用code cache的好處是優化後的指令集可以立即執行 —— 無需像解釋器一樣再經過查找的過程或編譯過程!這可以加速程式運作,尤其是像Java應用程式這種同一個方法會被多次調用應用程式。

優化

随着動态編譯器一起出現的是性能計數器。例如,編譯器會插入性能計數器,以統計每個位元組碼塊(對應與某個被調用的方法)的調用次數。在進行相關優化時,編譯器會使用收集到的資料來判斷某個位元組碼塊有多“熱”,這樣可以最大程度的降低對目前應用程式的影響。運作時資料監控有助于編譯器完成多種代碼優化工作,進一步提升代碼執行性能。随着收集到的運作時資料越來越多,編譯器就可以完成一些額外的、更加複雜的代碼優化工作,例如編譯出更高品質的目标代碼,使用運作效率更高的代碼替換原代碼,甚至是剔除備援操作等。

示例

考慮如下代碼:

1 2 3

static

int

add7(

int

x ) {

return

x+

7

;

}

這段代碼經過javac編譯後會産生如下的位元組碼:

1 2 3 4

iload0

bipush

7

iadd

ireturn

當調用這段代碼時,位元組碼塊會被動态的編譯為本地機器指令。當性能計數器(如果這段代碼應用了性能計數器的話)發現這段代碼的運作次數超過了某個門檻值後,動态編譯器會對這段代碼進行優化編譯。後帶的代碼可能會是下面這個樣子:

1 2

lea rax,[rdx+

7

]

ret

各擅勝場

不同的Java應用程式需要滿足不同的需求。相對來說,企業級伺服器端應用程式需要長時間運作,是以可以做更多的優化,而稍小點的用戶端應用程式可能要求快速啟動運作,占資源少。接下來我們考察三種編譯器設定及其各自的優缺點。

用戶端編譯器

即大家熟知的優化編譯器C1。在啟動應用程式時,添加JVM啟動參數“-client”可以啟用C1編譯器。正如啟動參數所表示的,C1是一個用戶端編譯器,它專為用戶端應用程式而設計,資源消耗更少,并且在大多數情況下,對應用程式的啟動時間很敏感。C1編譯器使用性能計數器來收集代碼的運作時資訊,執行一些簡單、無侵入的代碼優化任務。

伺服器端編譯器

對于那些需要長時間運作的應用程式,例如伺服器端的企業級Java應用程式來說,用戶端編譯器所實作的功能還略有不足,是以伺服器端的編譯會使用類似C2這類的編譯器。啟動應用程式時添加指令行參數“-server”可以啟用C2編譯器。由于大多數伺服器端應用程式都會長時間運作,是以相對于運作時間稍短的輕量級用戶端應用程式,在伺服器端應用程式中啟用C2編譯器可以收集到更多的運作時資料,也就可以執行一些更進階的編譯技術與算法。

提示:給伺服器端編譯器熱身

對于伺服器端編譯器來說,在應用程式開始運作之後,編譯器可能會在一段時間之後才開始優化“熱點”代碼,是以伺服器端編譯器通常需要經過一個“熱身”階段。在伺服器端編譯器執行性能優化任務之前,要確定應用程式的各項準備工作都已就緒。給予編譯器足夠多的時間來完成編譯、優化的工作才能取得更好的效果。(更多關于編譯器熱身與監控原理的内容請參見JavaWorld的文章”Watch your HotSpot compiler go“。)

在執行編譯任務優化任務時,伺服器端編譯器要比用戶端編譯器綜合考慮更多的運作時資訊,執行更複雜的分支分析,即對哪種優化路徑能取得更好的效果作出判斷。擷取的運作時資料越多,編譯優化所産生的效果越好。當然,要完成一些複雜的、進階的性能分析任務,編譯器就需要消耗更多的資源。使用了C2編譯器的JVM會消耗更多的資源,例如更多的線程,更多的CPU指令周期,以及更大的code cache等。

層次編譯

層次編譯綜合了伺服器端編譯器和用戶端編譯器的特點。Azul首先在其Zing JVM中實作了層次編譯。最近(就是Java SE 7版本),Oracle Java HotSpot VM也采用了這種設計。在應用程式啟動階段,用戶端編譯器最為活躍,執行一些由較低的性能計數器門檻值出發的性能優化任務。此外,用戶端編譯器還會插入性能計數器,為一些更複雜的性能優化任務準備指令集,這些任務将在後續的階段中由伺服器端編譯器完成。層次編譯可以更有效的利用資源,因為編譯器在執行一些對應用程式影響較小的編譯活動時仍可以繼續收集運作時資訊,而這些資訊可以在将來用于完成更進階的優化任務。使用層次編譯可以比解釋性的代碼性能計數器手機到更多的資訊。

Figure 1中展示了純解釋運作、用戶端模式運作、伺服器端模式運作和層次編譯模式運作下性能之間的差別。X軸表示運作時間(機關時間)Y軸表示性能(每機關時間内的操作數)。

JVM性能優化 JVM性能優化, Part 1 ―― JVM簡介  JVM性能優化, Part 2 ―― 編譯器 JVM性能優化, Part 3 垃圾回收  JVM 性能優化, Part 4: C4 垃圾回收 JVM性能優化, Part 5:Java的伸縮性 

Figure 1. Performance differences between compilers (click to enlarge)

編譯性能對比

相比于純解釋運作的的代碼,以用戶端模式編譯運作的代碼在性能(指機關時間執行的操作)上可以達到約5到10倍,是以而提升了應用程式的運作性能。其間的差別主要在于編譯器的效率、編譯器所作的優化,以及應用程式在設計實作時針對目标平台做了何種程度的優化。實際上,最後一條不在Java程式員的考慮之列。

相比于用戶端編譯器,使用伺服器端編譯器通常會有30%到50%的性能提升。在大多數情況下,這種程度的性能提升足以彌補使用伺服器端編譯所帶來的額外資源消耗。

層次編譯綜合了伺服器端編譯器和用戶端編譯器的優點,使用用戶端編譯模式實作快速啟動和快速優化,使用伺服器端編譯模式在後續的執行周期中完成進階優化的編譯任務。

常用編譯優化手段

到目前為止,已經介紹了優化代碼的價值,以及常用JVM編譯器是如何以及何時編譯代碼的。接下來,将用一些實際的例子做個總結。JVM所作的性能優化通常在位元組碼這一層級(或者是更底層的語言表示),但這裡我将使用Java程式設計語言對優化措施進行介紹。在這一節中,我無法涵蓋JVM中所作的所有性能優化,相反,我希望可以激發你的興趣,使你主動挖掘并學習編譯器技術中所包含了數百種進階優化技術(參見相關資源)。

死代碼剔除

死代碼剔除指的是,将用于無法被調用的代碼,即“死代碼”,從源代碼中剔除。如果編譯器在運作時發現某些指令是不必要的,它會簡單的将其從可執行指令集中剔除。例如,在Listing 1中,變量被賦予了确定值,卻從未被使用,是以可以在執行時将其完全忽略掉。在位元組碼這一層級,也就不會有将數值載入到寄存器的操作。沒有載入操作意味着可以更少的CPU時間,更好的運作性能,尤其是當這段代碼是“熱點”代碼的時候。

Listing 1中展示了示例代碼,其中被賦予了固定值的代碼從未被使用,屬于無用不必要的操作。

Listing 1. Dead code

1 2 3 4 5 6 7 8 9 10

int

timeToScaleMyApp(

boolean

endlessOfResources) {

int

reArchitect =

24

;

int

patchByClustering =

15

;

int

useZing =

2

;

if

(endlessOfResources)

return

reArchitect + useZing;

else

return

useZing;

}

在位元組碼這一層級,如果變量被載入但從未使用,編譯器會檢測到并剔除這個死代碼,如Listing 2所示。剔除死代碼可以節省CPU時間,進而提升應用程式的運作速度。

Listing 2. The same code following optimization

1 2 3 4 5 6 7 8 9 10

int

timeToScaleMyApp(

boolean

endlessOfResources) {

int

reArchitect =

24

;

//unnecessary operation removed here...

int

useZing =

2

;

if

(endlessOfResources)

return

reArchitect + useZing;

else

return

useZing;

}

備援剔除是一種類似的優化手段,通過剔除掉重複的指令來提升應用程式性能。

内聯

許多優化手段都試圖消除機器級跳轉指令(例如,x86架構的JMP指令)。跳轉指令會修改指令指針寄存器,是以而改變了執行流程。相比于其他彙編指令,跳轉指令是一個代價高昂的指令,這也是為什麼大多數優化手段會試圖減少甚至是消除跳轉指令。内聯是一種家喻戶曉而且好評如潮的優化手段,這是因為跳轉指令代價高昂,而内聯技術可以将經常調用的、具有不容入口位址的小方法整合到調用方法中。Listing 3到Listing 5中的Java代碼展示了使用内聯的用法。

Listing 3. Caller method

1 2 3

int

whenToEvaluateZing(

int

y) {

return

daysLeft(y) + daysLeft(

) + daysLeft(y+

1

);

}

Listing 4. Called method

1 2 3 4 5 6

int

daysLeft(

int

x){

if

(x ==

)

return

;

else

return

x -

1

;

}

Listing 5. Inlined method

1 2 3 4 5 6 7 8 9

int

whenToEvaluateZing(

int

y){

int

temp =

;

if

(y ==

) temp +=

;

else

temp += y -

1

;

if

(

==

) temp +=

;

else

temp +=

-

1

;

if

(y+

1

==

) temp +=

;

else

temp += (y +

1

) -

1

;

return

temp;

}

在Listing 3到Listing 5的代碼中,展示了将調用3次小方法進行内聯的示例,這裡我們認為使用内聯比跳轉有更多的優勢。

如果被内聯的方法本身就很少被調用的話,那麼使用内聯也沒什麼意義,但是對頻繁調用的“熱點”方法進行内聯在性能上會有很大的提升。此外,經過内聯處理後,就可以對内聯後的代碼進行進一步的優化,正如Listing 6中所展示的那樣。

Listing 6. After inlining, more optimizations can be applied

1 2 3 4 5

int

whenToEvaluateZing(

int

y){

if

(y ==

)

return

y;

else

if

(y == -

1

)

return

y -

1

;

else

return

y + y -

1

;

}

循環優化

當涉及到需要減少執行循環時的性能損耗時,循環優化起着舉足輕重的作用。執行循環時的性能損耗包括代價高昂的跳轉操作,大量的條件檢查,和未經優化的指令流水線(即引起CPU空操作或額外周期的指令序列)等。循環優化可以分為很多種,在各種優化手段中占有重要比重。其中值得注意的包括以下幾種:

  • 合并循環:當兩個相鄰循環的疊代次數相同時,編譯器會嘗試将兩個循環體進行合并。當兩個循環體中沒有互相引用的情況,即各自獨立時,可以同時執行(并行執行)。
  • 反轉循環:基本上将就是用do-while循環體換掉正常的while循環,這個do-while循環嵌套在if語句塊中。這個替換操作可以節省兩次跳轉操作,但是,會增加一個條件檢查的操作,是以增加的代碼量。這種優化方式完美的展示了以少量增加代碼量為代價換取較大性能的提升 —— 編譯器需要在運作時需要權衡這種得與失,并制定編譯政策。
  • 分塊循環:重新組織循環體,以便疊代資料塊時,便于緩存的應用。
  • 展開循環:減少判斷循環條件和跳轉的次數。你可以将之了解為将一些疊代的循環體“内聯”到一起,而無需跨越循環條件。展開循環是有風險的,它有可能會降低應用程式的運作性能,因為它會影響流水線的運作,導緻産生了備援指令。再強調一遍,展開循環是編譯器在運作時根據各種資訊來決定是否使用的優化手段,如果有足夠的收益的話,那麼即使有些性能損耗也是值得的。

至此,已經簡要介紹了編譯器對位元組碼層級(以及更底層)進行優化,以提升應用程式在目标平台的執行性能的幾種方式。這裡介紹的幾種優化手段是比較常用的幾種,隻是衆多優化技術中的幾種。在介紹優化方法時配以簡單示例和相關解釋,希望可以洗發你進行深度探索的興趣。更多相關内容請參見相關資源。

總結:回顧

為滿足不同需要而使用不同的編譯器。

  • 解釋是将位元組碼轉換為本地機器指令的最簡單方式,其工作方式是基于對本地機器指令表的查找。
  • 編譯器可以基于性能計數器進行性能優化,但是需要消耗更多的資源(如code cache,優化線程等)。
  • 相比于純解釋執行代碼,用戶端編譯器可以将應用程式的執行性能提升一個數量級(約5到10倍)。
  • 相比于用戶端編譯器,伺服器端編譯器可以将應用程式的執行性能提升30%到50%,但會消耗更多的資源。
  • 層次編譯綜合了用戶端編譯器和伺服器端編譯器的優點,既可以像用戶端編譯器那樣快速啟動,又可以像伺服器端編譯器那樣,在長時間收集運作時資訊的基礎上,優化應用程式的性能。

目前,已經出現了很多代碼優化的手段。對編譯器來說,一個主要的任務就是分析所有的可能性,權衡使用某種優化手段的利弊,在此基礎上編譯代碼,優化應用程式的性能。

關于作者

Eva Andearsson對JVM計數、SOA、雲計算和其他企業級中間件解決方案有着10多年的從業經驗。在2001年,她以JRockit JVM開發者的身份加盟了創業公司Appeal Virtual Solutions(即BEA公司的前身)。在垃圾回收領域的研究和算法方面,EVA獲得了兩項專利。此外她還是提出了确定性垃圾回收(Deterministic Garbage Collection),後來形成了JRockit實時系統(JRockit Real Time)。在技術上,Eva與SUn公司和Intel公司合作密切,涉及到很多将JRockit産品線、WebLogic和Coherence整合的項目。2009年,Eva加盟了Azul System公,擔任産品經理。負責新的Zing Java平台的開發工作。最近,她改換門庭,以進階産品經理的身份加盟Cloudera公司,負責管理Cloudera公司Hadoop分布式系統,緻力于高擴充性、分布式資料處理架構的開發。

相關資源

    • “JVM性能優化, Part 1 ——JVM簡介”(原文作者Eva Andreasson, 于2012年8約發表于JavaWorld)是該系列的第一篇,對經典JVM的工作原理做了簡單介紹,包括Java“一次編寫,到處運作”的優勢,垃圾回收基礎和一些常用的垃圾回收算法。
    • 更多有關HotSpot優化原理以及JVM熱身的内容請參見Vladimir Roubtsov與2003年4約發表于JavaWorld.com的文章“Watch your HotSpot compiler go”
    • 如果你想對JVM和位元組碼有更深入的了解,請參見Bill Venners在1996年發表于JavaWorld的文章“Bytecode basics”。文章對JVM中的位元組碼指令集做了介紹,内容包括原生類型操作、類型轉換以及棧上操作等。
    • 在Java平台的官方文檔中有對Java編譯器javac的較長的描述。
    • 更多有關JVM中JIT編譯器的内容,請參見IBM Research中有關Java JIT Compiler的内容。
    • 或者參見Oracle JRockit文檔中“Understanding Just-In-Time Compilation and Optimization”的相關内容.
    • Cliff Click博士在其部落格上有關于層次編譯的完整教程。
    • 更多有關使用性能計數器完成JVM性能優化的文章:“Using Platform-Specific Performance Counters for Dynamic Compilation” (作者Florian Schneider與Thomas R. Gross;由ACM Digital Lirary發表在第18屆Languages and Compilers for Parallel Computing會議上)
    • Oracle JRockit: The Definitive Guide (Marcus Hirt, Marcus Lagergren; Packt Publishing, 2010): Oracle JRockit權威指南

英文原文:javaworld,翻譯:ImportNew - 曹旭東

譯文連結: http://www.importnew.com/2009.html

JVM性能優化, Part 3 垃圾回收 

ImportNew注:本文是JVM性能優化 系列-第3篇-《JVM性能優化, Part 3 —— 垃圾回收》

第一篇 《JVM性能優化, Part 1 ―― JVM簡介 》

第二篇《JVM性能優化, Part 2 ―― 編譯器》

Java平台的垃圾回收機制大大提高的開發人員的生産力,但實作糟糕的垃圾回收器卻會大大消耗應用程式的資源。本文作為JVM性能優化系列的第3篇,Eva Andeasson将為Java初學者介紹Java平台的記憶體模型和GC機制。她将解釋為什麼碎片化(不是GC)是Java應用程式出現性能問題的主要原因,以及為什麼目前主要通過分代垃圾回收和壓縮,而不是其他最具創意的方法,來解決Java應用程式中碎片化的問題。

垃圾回收(GC)是旨在釋放不可達Java對象所占用的記憶體的過程,是Java virtual machine(JVM)中動态記憶體管理系統的核心組成部分。在一個典型的垃圾回收周期中,所有仍被引用的對象,即可達對象,會被保留。沒有被引用的Java對象所占用的記憶體會被釋放并回收,以便配置設定給新建立的對象。

為了更好的了解垃圾回收與各種不同的GC算法,你首先需要了解一些關于Java平台記憶體模型的内容。

垃圾回收與Java平台記憶體模型

當你在啟動Java應用程式時指定了啟動參數_-Xmx_(例如,java -Xmx2g MyApp),則相應大小的記憶體會被配置設定給Java程序。這塊記憶體即所謂的*Java堆*(或簡稱為*堆*)。這塊專用的記憶體位址空間用于存儲Java應用程式(有時是JVM)所建立的對象。随着Java應用程式的運作,會不斷的建立新對象并為之配置設定記憶體,Java堆(即位址空間)會逐漸被填滿。

最後,Java堆會被填滿,這就是說想要申請記憶體的線程無法獲得一塊足夠大的連續空閑空間來存放新建立的對象。此時,JVM判斷需要啟動垃圾回收器來回收記憶體了。當Java程式調用System.gc()方法時,也有可能會觸發垃圾回收器以執行垃圾回收的工作。使用System.gc()方法并不能保證垃圾回收工作肯定會被執行。在執行垃圾回收前,垃圾回收機制首先會檢查目前是否是一個“恰當的時機”,而“恰當的時機”指所有的應用程式活動線程都處于安全點(safe point),以便啟動垃圾回收。簡單舉例,為對象配置設定記憶體時,或正在優化CPU指令(參見本系列的前一篇文章)時,就不是“恰當的時機”,因為你可能會丢失上下文資訊,進而得到混亂的結果。

垃圾回收不應該回收目前有活動引用指向的對象所占用的記憶體;因為這樣做将違反JVM規範。在JVM規範中,并沒有強制要求垃圾回收器立即回收已死對象(dead object)。已死對象最終會在後續的垃圾回收周期中被釋放掉。目前,已經有多種垃圾回收的實作,它們都包含兩個溝通的假設。對垃圾回收來說,真正的挑戰在于辨別出所有活動對象(即仍有引用指向的對象),回收所有不可達對象所占用的記憶體,并盡可能不對正在運作的應用程式産生影響。是以,垃圾回收器運作的兩個目标:

  1. 快速釋放不可達對象所占用的記憶體,防止應用程式出現OOM錯誤。
  2. 回收記憶體時,對應用程式的性能(指延遲和吞吐量)的影響要緊性能小。

兩類垃圾回收

在本系列的第一篇文章中,我提到了2種主要的垃圾回收方式,引用計數(reference counting)和引用追蹤(tracing collector。譯者注,在第一篇中,給出的名字是“reference tracing”,這裡仍沿用之前的名字)。這裡,我将深入這兩種垃圾回收方式,并介紹用于生産環境的實作了引用追蹤的垃圾回收方式的相關算法。

     相關閱讀:JVM性能優化系列

  •            JVM性能優化,第一部分: 概述
  •            JVM性能優化,第二部分: 編譯器

引用計數垃圾回收器

引用計數垃圾回收器會對指向每個Java對象的引用數進行跟蹤。一旦發現指向某個對象的引用數為0,則立即回收該對象所占用的記憶體。引用計數垃圾回收的主要優點就在于可以立即通路被回收的記憶體。垃圾回收器維護未被引用的記憶體并不需要消耗很大的資源,但是保持并不斷更新引用計數卻代價不菲。

使用引用計數方式執行垃圾回收的主要困難在于保持引用計數的準确性,而另一個衆所周知的問題在于解決循環引用結構所帶來的麻煩。如果兩個對象互相引用,并且沒有其他存活東西引用它們,那麼這兩個對象所占用的記憶體将永遠不會被釋放,兩個對象都會因引用計數不為0而永遠存活下去。要解決循環引用帶來的問題需要,而這會使算法複雜度增加,進而影響應用程式的運作性能。

引用跟蹤垃圾回收

引用跟蹤垃圾回收器基于這樣一種假設,所有存活對象都可以通過疊代地跟蹤從已知存活對象集中對象發出的引用及引用的引用來找到。可以通過對寄存器、全局域、以及觸發垃圾回收時棧幀的分析來确定初始存活對象的集合(稱為“根對象”,或簡稱為“根”)。在确定了初始存活對象集後,引用跟蹤垃圾回收器會跟蹤從這些對象中發出的引用,并将找到的對象标記為“活的(live)”。标記所有找到的對象意味着已知存活對象的集合會随時間而增長。這個過程會一直持續到所有被引用的對象(是以是“存活的”對象)都被标記。當引用跟蹤垃圾回收器找到所有存活的對象後,就會開始回收未被标記的對象。

不同于引用計數垃圾回收器,引用跟蹤垃圾回收器可以解決循環引用的問題。由于标記階段的存在,大多數引用跟蹤垃圾回收器無法立即釋放“已死”對象所占用的記憶體。

引用跟蹤垃圾回收器廣泛用于動态語言的記憶體管理;到目前為止,在Java程式設計語言的視線中也是應用最廣的,并且在多年的商業生産環境中,已經證明其實用性。在本文餘下的内容中,我将從一些相關的實作算法開始,介紹引用跟蹤垃圾回收器,

引用跟蹤垃圾回收器算法

拷貝和*标記-清理*垃圾回收算法并非新近發明,但仍然是當今實作引用跟蹤垃圾回收器最常用的兩種算法。

拷貝垃圾回收器

傳統的拷貝垃圾回收器會使用一個“from”區和一個“to”區,它們是堆中兩個不同的位址空間。在執行垃圾回收時,from區中存活對象會被拷貝到to區。當from區中所有的存活對象都被拷貝到to後,垃圾回收器會回收整個from區。當再次配置設定記憶體時,會首先從to區中的空閑位址開始配置設定。

在該算法的早期實作中,from區和to區會在垃圾回收周期後進行交換,即當to區被填滿後,将再次啟動垃圾回收,這是to區會“變成”from區。如圖Figure 1所示。

JVM性能優化 JVM性能優化, Part 1 ―― JVM簡介  JVM性能優化, Part 2 ―― 編譯器 JVM性能優化, Part 3 垃圾回收  JVM 性能優化, Part 4: C4 垃圾回收 JVM性能優化, Part 5:Java的伸縮性 

Figure 1. A traditional copying garbage collection sequence

在該算法的近期實作中,可以将堆中任意位址空間指定為from區和to區,這樣就不再需要交換from區和to區,堆中任意位址空間都可以成為from區或to區。

拷貝垃圾回收器的一個優點是存活對象的位置會被to區中重新配置設定,緊湊存放,可以完全消除碎片化。碎片化是其他垃圾回收算法所要面臨的一大問題,這點會在後續讨論。

拷貝垃圾回收的缺陷

通常來說,拷貝垃圾回收器是“stop-the-world”式的,即在垃圾回收周期内,應用程式是被挂起的,無法工作。在“stop-the-world”式的實作中,所需要拷貝的區域越大,對應用程式的性能所造成的影響也越大。對于那些非常注重響應時間的應用程式來說,這是難以接受的。使用拷貝垃圾回收時,你還需要考慮一下最壞情況,即當from區中所有的對象都是存活對象的時候。是以,你不得不給存活對象預留出足夠的空間,也就是說to區必須足夠大,大到可以将from區中所有的對象都放進去。正是由于這個缺陷,拷貝垃圾回收算法在記憶體使用效率上略有不足。

标記-清理垃圾回收器

大多數部署在企業生産環境的商業JVM都使用了标記-清理(或标記)垃圾回收器,這種垃圾回收器并不會想拷貝垃圾回收器那樣對應用程式的性能有那麼大的影響。其中最著名的幾款是CMS、G1、GenPar和DeterministicGC(參見相關資源)。

标記-清理垃圾回收器會跟蹤引用,并使用标記位将每個找到的對象标記位“live”。通常來說,每個标記位都關聯着一個位址或堆上的一個位址集合。例如,标記位可能是對象頭(object header)中一位,一個位向量,或是一個位圖。

當所有的存活對象都被标記位“live”後,将會開始*清理*階段。一般來說,垃圾回收器的清理階段包含了通過再次周遊堆(不僅僅是标記位live的對象集合,而是整個堆)來定位記憶體位址空間中未被标記的區域,并将其回收。然後,垃圾回收器會将這些被回收的區域儲存到空閑清單(free list)中。在垃圾回收器中可以同時存在多個空閑清單——通常會按照儲存的記憶體塊的大小進行劃分。某些JVM(例如JRockit實時系統, JRockit Real Time System)在實作垃圾回收器時會給予應用程式分析資料和對象大小統計資料來動态調整空閑清單所儲存的區域塊的大小範圍。

當清理階段結束後,應用程式就可以再次啟動了。給新建立的對象配置設定記憶體時會從空閑清單中查找,而空閑清單中記憶體塊的大小需要比對于新建立的對象大小、某個線程中平均對象大小,或應用程式所設定的TLAB的大小。從空閑清單中為新建立的對象找到大小合适的記憶體區域塊有助于優化記憶體的使用,減少記憶體中的碎片。

關于TLAB

        更多關于TLAB和TLA(Thread Local Allocation Buffer和Thread Local Area)的内容,請參見ImportNew翻譯整理的第一篇《JVM性能優化, Part 1 ―― JVM簡介》。

标記-清理垃圾回收器的缺陷

标記階段的時長取決于堆中存活對象的總量,而清理階段的時長則依賴于堆的大小。由于在*标記*階段和*清理*階段完成前,你無事可做,是以對于那些具有較大的堆和較多存活對象的應用程式來說,使用此算法需要想辦法解決暫停時間(pause-time)較長這個問題。

對于那些記憶體消耗較大的應用程式來說,你可以使用一些GC調優選項來滿足其在某些場景下的特殊需求。很多時候,調優至少可以将标記-清理階段給應用程式或性能要求(SLA,SLA指定了應用程式需要達到的響應時間的要求,即延遲)所帶來的風險推後。當負載和應用程式發生改變後,需要重新調優,因為某次調優隻對特定的工作負載和記憶體配置設定速率有效。

标記-清理算法的實作

目前,标記-清理垃圾回收算法至少已有2種商業實作,并且都已在生産環境中被證明有效。其一是并行垃圾回收,另一個是并發(或多數時間并發)垃圾回收。

并行垃圾回收器

并行垃圾回收指的是垃圾回收是多線程并行完成的。大多數商業實作的并行垃圾回收器都是stop-the-world式的垃圾回收器,即在整個垃圾回收周期結束前,所有應用程式線程都會被挂起。挂起所有應用程式線程使垃圾回收器可以以并行的方式,更有效的完成标記和清理工作。并行使得效率大大提高,通常可以在像SPECjbb這樣的吞吐量基準測試中跑出高分。如果你的應用程式好似有限考慮吞吐量的,那麼并行垃圾回收是你最好的選擇。

對于大多數并行垃圾回收器來說,尤其是考慮到應用于生産環境中,最大的問題是,像拷貝垃圾回收算法一樣,在垃圾回收周期内應用程式無法工作。使用stop-the-world式的并行垃圾回收會對優先考慮響應時間的應用程式産生較大影響,尤其是當你有大量的引用需要跟蹤,而此時恰好又有大量的、具有複雜結構的對象存活于堆中的時候,情況将更加糟糕。(記住,标記-清理垃圾回收器回收記憶體的時間取決于跟蹤存活對象中所有引用的時間與周遊整個堆的時間之和。)以并行方式執行垃圾回收所導緻的應用程式暫停會一直持續到整個垃圾回收周期結束。

并發垃圾回收器

并發垃圾回收器更适用于那些對響應時間比較敏感的應用程式。并發指的是一些(或大多數)垃圾回收工作可以與應用程式線程同時運作。由于并非所有的資源都由垃圾回收器使用,是以這裡所面臨的問題如何決定何時開始執行垃圾回收,可以保證垃圾回收順利完成。這裡需要足夠的時間來跟蹤存活對象即的引用,并在應用程式出現OOM錯誤前回收記憶體。如果垃圾回收器無法及時完成,則應用程式就會抛出OOM錯誤。此外,一直做垃圾回收也不好,會不必要的消耗應用程式資源,進而影響應用程式吞吐量。要想在動态環境中保持這種平衡就需要一些技巧,是以設計了啟發式方法來決定何時開始垃圾回收,何時執行不同的垃圾回收優化任務,以及一次執行多少垃圾回收優化任務等。

并發垃圾回收器所面臨的另一個挑戰是如何決定何時執行一個需要完整堆快照的操作時安全的,例如,你需要知道是何時标記所有存活對象的,這樣才能轉而進入清理階段。在大多數并行垃圾回收器采用的stop-the-world方式中,*階段轉換(phase-switching)*并不需要什麼技巧,因為世界已靜止(堆上對象暫時不會發生變化)。但是,在并發垃圾回收中,轉換階段時可能并不是安全的。例如,如果應用程式修改了一塊垃圾回收器已經标記過的區域,可能會涉及到一些新的或未被标記的引用,而這些引用使其指向的對象成為存活狀态。在某些并發垃圾回收的實作中,這種情況有可能會使應用程式陷入長時間運作重标記(re-mark)的循環,是以當應用程式需要配置設定記憶體時無法得到足夠做的空閑記憶體。

到目前為止的讨論中,已經介紹了各種垃圾回收器和垃圾回收算法,他們各自适用于不同的場景,滿足不同應用程式的需求。各種垃圾回收方式不僅在算法上有所差別,在具體實作上也不盡相同。是以,在指令行中指定垃圾回收器之前,最好能了解應用程式的需求及其自身特點。在下一節中,将介紹Java平台記憶體模型中的陷阱,在這裡,陷阱指的是在動态生産環境中,Java程式員常常做出的一些中使性能更糟,而非更好的假設。

為什麼調優無法取代垃圾回收

大多數Java程式員都知道,如果有不少方法可以最大化Java程式的性能。而當今衆多的JVM實作,垃圾回收器實作,以及多到令人頭暈的調優選項都可能會讓開發人員将大量的時間消耗在無窮無盡的性能調優上。這種情況催生了這樣一種結論,“GC是糟糕的,努力調優以降低GC的頻率或時長才是王道”。但是,真這麼做是有風險的。

考慮一下針對指定的應用程式需求做調優意味着什麼。大多數調優參數,如記憶體配置設定速率,對象大小,響應時間,以及對象死亡速度等,都是針對特定的情況而來設定的,例如測試環境下的工作負載。例如。調優結果可能有以下兩種:

  1. 測試時正常,上線就失敗。
  2. 一旦應用程式本身,或工作負載發生改變,就需要全部重調。

調優是需要不斷往複的。使用并發垃圾回收器需要做很多調優工作,尤其是在生産環境中。為滿足應用程式的需求,你需要不斷挑戰可能要面對的最差情況。這樣做的結果就是,最終形成的配置非常刻闆,而且在這個過程中也浪費了大量的資源。這種調優方式(試圖通過調優來消除GC)是一種堂吉诃德式的探索——以根本不存在的理由去挑戰一個假想敵。而事實是,你針對某個特定的負載而垃圾回收器做的調優越多,你距離Java運作時的動态特性就越遠。畢竟,有多少應用程式的工作負載能保持不變呢?你所預估的工作負載的可靠性又有多高呢?

那麼,如果不從調優入手又該怎麼辦呢?有什麼其他的辦法可以防止應用程式出現OOM錯誤,并降低響應時間呢?這裡,首先要做的是明确影響Java應用程式性能的真正因素。

碎片化

影響Java應用程式性能的罪魁禍首并不是垃圾回收器本身,而是碎片化,以及垃圾回收器如何處理碎片。碎片是Java堆中空閑空間,但由于連續空間不夠大而無法容納将要建立的對象。正如我在本系列第2篇中提到的,碎片可能是TLAB中的剩餘空間,也可能是(這種情況比較多)被釋放掉的具有較長生命周期的小對象所占用的空間。

随着應用程式的運作,這種無法使用的碎片會遍布于整個堆空間。在某些情況下,這種狀态會因靜态調優選項(如提升速率和空閑清單等)更糟糕,以至于無法滿足應用程式的原定需求。這些剩下的空間(也就是碎片)無法被應用程式有效利用起來。如果你對此放任自流,就會導緻不斷垃圾回收,垃圾回收器會不斷的釋放記憶體以便建立新對象時使用。在最差情況下,甚至垃圾回收也無法騰出足夠的記憶體空間(因為碎片太多),JVM會強制抛出OOM(out of memory)錯誤當然,你也可以重新開機應用程式來消除碎片,這樣可以使Java堆煥然一新,于是就又可以為對象配置設定記憶體了。但是,重新啟動會導緻伺服器停機,另外,一段時間之後,堆将再次充滿碎片,你也不得不再次重新開機。

OOM錯誤(OutOfMemoryErrors)會挂起程序,日志中顯示的垃圾回收器很忙,是垃圾回收器努力釋放記憶體的标志,也說明了堆中碎片非常多。一些開發人員通過重新調優垃圾回收器來解決碎片化的問題,但我覺着在解決碎片問題成為垃圾回收的使命之前應該用一些更有新意的方法來解決這個問題。本文後面的内容将聚焦于能有效解決碎片化問題的方法:分代黛式垃圾回收和壓縮。

分代式垃圾回收

這個理論你可以已經聽說過,即在生産環境中,大部分對象的生命周期都很短。分代式垃圾回收就源于這個理論。在分代式垃圾回收中,堆被分為兩個不同的空間(或成為“代”),每個空間存放具有不同年齡的對象,在這裡,年齡是指該對象所經曆的垃圾回收的次數(也就是該對象挺過了多少次垃圾回收而沒有死掉)。

當新建立的對象所處的空間,即*年輕代*,被對象填滿後,該空間中仍然存活的對象會被移動到老年代。(譯者注,以HotSpot為例,這裡應該是挺過若幹次GC而不死的,才會被搬到老年代,而一些比較大的對象會直接放到老年代。)大多數的實作都将堆會分為兩代,年輕代和老年代。通常來說,分代式垃圾回收器都是單向拷貝的,即從年輕代向老年代拷貝,這點在早先曾讨論過。近幾年出現的年輕代垃圾回收器已經可以實作并行垃圾回收,當然也可以實作一些其他的垃圾回收算法實作對年輕代和老年代的垃圾回收。如果你使用拷貝垃圾回收器(可能具有并行收集功能)對年輕代進行垃圾回收,那垃圾回收是stop-the-world式的(參見前面的解釋)。

分代式垃圾回收的缺陷

在分代式垃圾回收中,老年代執行垃圾回收的平率較低,而年輕代中較高,垃圾回收的時間較短,侵入性也較低。但在某些情況下,年輕代的存在會是老年代的垃圾回收更加頻繁。典型的例子是,相比于Java堆的大小,年輕代被設定的太大,而應用程式中對象的生命周期又很長(又或者給年輕代對象提升速率設了一個“不正确”的值)。在這種情況下,老年代因太小而放不下所有的存活對象,是以垃圾回收器就會忙于釋放記憶體以便存放從年輕代提升上來的對象。但一般來說,使用分代式垃圾回收器可以使用應用程式的性能和系統延遲保持在一個合适的水準。

使用分代式垃圾回收器的一個額外效果是部分解決了碎片化的問題,或者說,發生最差情況的時間被推遲了。可能造成碎片的小對象被配置設定于年輕代,也在年輕代被釋放掉。老年代中的對象分布會相對緊湊一些,因為這些對象在從年輕代中提升上來的時候會被會緊湊存放。但随着應用程式的運作,如果運作時間夠長的話,老年代也會充滿碎片的。這時就需要對年輕代和老年代執行一次或多次stop-the-world式的全垃圾回收,導緻JVM抛出_OOM錯誤_或者表明提升失敗的錯誤。但年輕代的存在使這種情況的出現被推遲了,對某些應用程式來說,這就就足夠了。(在某些情況下,這種糟糕情況會被推遲到應用程式完全不關心GC的時候。)對大多數應用程式來說,對于大多數使用年輕代作為緩沖的應用程式來說,年輕代的存在可以降低出現stop-the-world式垃圾回收頻率,減少抛出OOM錯誤的次數。

 調優分代式垃圾回收

正如上面提到的,由于使用了分代式垃圾回收,你需要針對每個新版本的應用程式和不同的工作負載來調整年輕代大小和對象提升速度。我無法完整評估出固定運作時的代價:由于針對某個指定工作負載而設定了一系列優化參數,垃圾回收器應對動态變化的能力降低了,而變化是不可避免的。

對于調整年輕代大小來說,最重要的規則是要確定年輕代的大小不應該使因執行stop-the-world式垃圾回收而導緻的暫停過長。(假設年輕代中使用的并行垃圾回收器。)還要記住的是,你要在堆中為老年代留出足夠的空間來存放那些生命周期較長的對象。下面還有一些在調優分代式垃圾回收器時需要考慮的因素:

  1. 大多數年輕代垃圾回收都是stop-the-world式的,年輕代越大,相應的暫停時間越長。是以,對于那些受GC暫停影響較大的應用程式來說,應該仔細斟酌年輕代的大小。
  2. 你可以綜合考慮不同代的垃圾回收算法。可以在年輕代使用并行垃圾回收,而在老年代使用并行垃圾回收。
  3. 當提升失敗頻繁發生時,這通常說明老年代中的碎片較多。提升失敗指的是老年代中沒有足夠大的空間來存放年輕代中的存活對象。當出現提示失敗時,你可以微調對象提升速率(即調整對象提升時年齡),或者確定老年代垃圾回收算法會将對象進行壓縮(将在下一節讨論),并以一種适合目前應用程式工作負載的方式調整壓縮。你也可以增大堆和各個代的大小,但這會使老年代垃圾回收的暫停時間延長——記住,碎片化是不可避免的。
  4. 分代式垃圾回收最适用于那些具有大量短生命周期對象的應用程式,這些對象的生命周期短到活不過一次垃圾回收周期。在這種場景中,分代式垃圾回收可有效的減緩碎片化的趨勢,主要是将碎片化随帶來的影響推出到将來,而那時可能應用程式對此毫不關心。

壓縮

盡管分代式垃圾回收推出了碎片化和OOM錯誤出現的時機,但壓縮仍然是唯一真正解決碎片化的方法。*壓縮*是将對象移動到一起,以便釋放掉大塊連續記憶體空間的GC政策。是以,壓縮可以生成足夠大的空間來存放新建立的對象。

移動對象并修改相關引用是一個stop-the-world式的操作,這會對應用程式的性能造成影響。(隻有一種情況是個例外,将在本系列的下一篇文章中讨論。)存活對象越多,垃圾回收造成的暫停也越長。假如堆中的空間所剩無幾,而且碎片化又比較嚴重(這通常是由于應用程式運作的時間很長了),那麼對一塊存活對象多的區域進行壓縮可能會耗費數秒的時間。而如果因出現OOM而導緻應用程式無法運作,是以而對整個堆進行壓縮時,所消耗的時間可達數十秒。

壓縮導緻的暫停時間的長短取決于需要移動的存活對象所占用的記憶體有多大以及有多少引用需要更新。當堆比較大時,從統計上講,存活對象和需要更新的引用都會很多。從已觀察到的資料看,每壓縮1到2GB存活資料的需要約1秒鐘。是以,對于4GB的堆來說,很可能會有至少25%的存活資料,進而導緻約1秒鐘的暫停。

壓縮與應用程式記憶體牆

應用程式記憶體牆涉及到在GC暫停時間對應用程式的影響大到無法達到滿足預定需求之前所能設定的的堆的最大值。目前,大部分Java應用程式在碰到記憶體牆時,每個JVM執行個體的堆大小介于4GB到20GB之間,具體數值依賴于具體的環境和應用程式本身。這也是大多數企業及應用程式會部署多個小堆JVM而不是部署少數大堆(50到60GB)JVM的原因之一。在這裡,我們需要思考一下:現代企業中有多少Java應用程式的設計與部署架構受制于JVM中的壓縮?在這種情況下,我們接受多個小執行個體的部署方案,以增加管理維護時間為代價,繞開為處理充滿碎片的堆而執行stop-the-world式垃圾回收所帶來的問題。考慮到現今的硬體性能和企業級Java應用程式中對記憶體越來越多的通路要求,這種方案是在非常奇怪。為什麼僅僅隻能給每個JVM執行個體設定這麼小的堆?并發壓縮是一種可選方法,它可以降低記憶體牆帶來的影響,這将是本系列中下一篇文章的主題。

從已觀察到的資料看,每壓縮1到2GB存活資料的需要約1秒鐘。是以,對于4GB的堆來說,很可能會有至少25%的存活資料,進而導緻約1秒鐘的暫停。

總結:回顧

本文對垃圾回收做了總體介紹,目的是為了使你能了解垃圾回收的相關概念和基本知識。希望本文能激發你繼續深入閱讀相關文章的興趣。這裡所介紹的大部分内容,它們。在下一篇文章中,我将介紹一些較新穎的概念,并發壓縮,目前隻有Azul公司的Zing JVM實作了這一技術。并發壓縮是對GC技術的綜合運用,這些技術試圖重新建構Java記憶體模型,考慮當今記憶體容量與處理能力的不斷提升,這一點尤為重要。

現在,回顧一下本文中所介紹的關于垃圾回收的一些内容:

  1. 不同的垃圾回收算法的方式是為滿足不同的應用程式需求而設計。目前在商業環境中,應用最為廣泛的是引用跟蹤垃圾回收器。
  2. 并行垃圾回收器會并行使用可用資源執行垃圾回收任務。這種政策的常用實作是stop-the-world式垃圾回收器,使用所有可用系統資源快速完成垃圾回收任務。是以,并行垃圾回收可以提供較高的吞吐量,但在垃圾回收的過程中,所有應用程式線程都會被挂起,對延遲有較大影響。
  3. 并發垃圾回收器可以與應用程式并發工作。使用并發垃圾回收器時要注意的是,確定在應用程式發生OOM錯誤之前完成垃圾回收。
  4. 分代式垃圾回收可以推遲碎片化的出現,但并不能消除碎片化。它将堆分為兩塊空間,一塊用于存放“年輕對象”,另一塊用于存放從年輕代中存活下來的存活對象。對于那些使用了很多具有較短生命周期活不過幾次垃圾回收周期的Java應用程式來說,使用分代式垃圾回收是非常合适的。
  5. 壓縮是可以完全解決碎片化的唯一方法。大多數垃圾回收器在壓縮的時候是都stop-the-world式的。應用程式運作的時間越長,對象間的引就用越複雜,對象大小的異質性也越高。相應的,完成壓縮所需要的時間也越長。如果堆的大小較大的話也會對壓縮所占産生的暫停有影響,因為較大的堆就會有更多的活動資料和更多的引用需要處理。
  6. 調優可以推遲OOM錯誤的出現,但過度調優是無意義的。在通過試錯方式初始調優前,一定要明确生産環境負載的動态性,以及應用程式中的對象類型和對象間的引用情況。在動态負載下,過于刻闆的配置很容會失效。在設定非動态調優選項前一定要清楚這樣做後果。

下個月的_JVM性能調優系列_:深入了解C4垃圾回收器(Continuously Concurrent Compacting Collector)相關算法。

關于作者

Eva Andearsson對JVM計數、SOA、雲計算和其他企業級中間件解決方案有着10多年的從業經驗。在2001年,她以JRockit JVM開發者的身份加盟了創業公司Appeal Virtual Solutions(即BEA公司的前身)。在垃圾回收領域的研究和算法方面,EVA獲得了兩項專利。此外她還是提出了确定性垃圾回收(Deterministic Garbage Collection),後來形成了JRockit實時系統(JRockit Real Time)。在技術上,Eva與Sun公司和Intel公司合作密切,涉及到很多将JRockit産品線、WebLogic和Coherence整合的項目。2009年,Eva加盟了Azul System公司,擔任産品經理。負責新的Zing Java平台的開發工作。最近,她改換門庭,以進階産品經理的身份加盟Cloudera公司,負責管理Cloudera公司Hadoop分布式系統,緻力于高擴充性、分布式資料處理架構的開發。

英文原文:JVM performance optimization, Part 3,翻譯:ImportNew - 曹旭東

譯文連結:http://www.importnew.com/2233.html

【如需轉載,請在正文中标注并保留原文連結、譯文連結和譯者等資訊,謝謝合作!】

相關資源

JVM性能優化系列早期文章:

  • “JVM performance optimization, Part 1: Overview” (August 2012)
  • “JVM performance optimization, Part 2: Compilers” (September 2012)

JavaWorld中的相關文章:

  • “Java’s garbage-collected heap” (Bill Venners, 1996)
  • “Trash talk: How Java recycles memory” (Jeff Friesen, 2001)
  • “Generational garbage collection: Using the right HotSpot parameters” (Ken Gottry, 2002)
  • “Reading GC logs: Report back from a JavaOne 2011 presentation” (Dustin Marx, 2011)

垃圾回收的相關書籍:

  • Garbage Collection Algorithms Automatic Management (Richard Jones, Rafael D. Lins; Wiley, August 1996)
  • The Garbage Collection Handbook (Richard Jones, Eliot Moss; Chapman and Hall, August 2011).

JVM調優與GC算法相關文章:

  • “Understanding the IBM Software Developers Kit (SDK) for Java: Memory Management” (IBM Information Center).
  • “Java SE 6 HotSpot Virtual Machine Garbage Collection Tuning” (Oracle Technology Network).
  • “The Azul Garbage Collector” (Charles Humble, InfoQ, February 2012).

JVM 性能優化, Part 4: C4 垃圾回收

ImportNew注:本文是JVM性能優化 系列-第4篇。前3篇文章請參考文章結尾處的JVM優化系列文章。作為Eva Andreasson的JVM性能優化系列的第4篇,本文将對C4垃圾回收器進行介紹。使用C4垃圾回收器可以有效提升對低延遲有要求的企業級Java應用程式的伸縮性。

到目前為止,本系列的文章将stop-the-world式的垃圾回收視為影響Java應用程式伸縮性的一大障礙,而伸縮性又是現代企業級Java應用程式開發的基礎要求,是以這一問題亟待改善。幸運的是,針對此問題,JVM中已經出現了一些新特性,所使用的方式或是對stop-the-world式的垃圾回收做微調,或是消除冗長的暫停(這樣更好些)。在一些多核系統中,記憶體不再是稀缺資源,是以,JVM的一些新特性就充分利用多核系統的潛在優勢來增強Java應用程式的伸縮性。

在本文中,我将着重介紹C4算法,該算法是Azul System公司中無暫停垃圾回收算法的新成果,目前隻在Zing JVM上得到實作。此外,本文還将對Oracle公司的G1垃圾回收算法和IBM公司的Balanced Garbage Collection Policy算法做簡單介紹。希望通過對這些垃圾回收算法的學習可以擴充你對Java記憶體管理模型和Java應用程式伸縮性的了解,并激發你對這方面内容的興趣以便更深入的學習相關知識。至少,你可以學習到在選擇JVM時有哪些需要關注的方面,以及在不同應用程式場景下要注意的事項。

C4算法中的并發性

Azul System公司的C4(Concurrent Continuously Compacting Collector,譯者注,Azul官網給出的名字是Continuously Concurrent Compacting Collector)算法使用獨一無二而又非常有趣的方法來實作低延遲的分代式垃圾回收。相比于大多數分代式垃圾回收器,C4的不同之處在于它認為垃圾回收并不是什麼壞事(即應用程式産生垃圾很正常),而壓縮是不可避免的。在設計之初,C4就是要犧牲各種動态記憶體管理的需求,以滿足需要長時間運作的伺服器端應用程式的需求。

C4算法将釋放記憶體的過程從應用程式行為和記憶體配置設定速率中分離出來,并加以區分。這樣就實作了并發運作,即應用程式可以持續運作,而不必等待垃圾回收的完成。其中的并發性是關鍵所在,正是由于并發性的存在才可以使暫停時間不受垃圾回收周期内堆上活動資料數量和需要跟蹤與更新的引用數量的影響,将暫停時間保持在較低的水準。正如我在本系列第3篇中介紹的一樣,大多數垃圾回收器在工作周期内都包含了stop-the-world式的壓縮過程,這就是說應用程式的暫停時間會随活動資料總量和堆中對象間引用的複雜度的上升而增加。使用C4算法的垃圾回收器可以并發的執行壓縮操作,即壓縮與應用程式線程同時工作,進而解決了影響JVM伸縮性的最大難題。

實際上,為了實作并發性,C4算法改變了現代Java企業級架構和部署模型的基本假設。想象一下擁有數百GB記憶體的JVM會是什麼樣的:

  • 部署Java應用程式時,對伸縮性的要求無需要多個JVM配合,在單一JVM執行個體中即可完成。這時的部署是什麼樣呢?
  • 有哪些以往因GC限制而無法在記憶體存儲的對象?
  • 那些分布式叢集(如緩存伺服器、區域伺服器,或其他類型的伺服器節點)會有什麼變化?當可以增加JVM記憶體而不會對應用程式響應時間造成負面影響時,傳統的節點數量、節點死亡和緩存丢失的計算會有什麼變化呢?

C4算法的3的階段

C4算法的一個基本假設是“垃圾回收不是壞事”和“壓縮不可避免”。C4算法的設計目标是實作垃圾回收的并發與協作,剔除stop-the-world式的垃圾回收。C4垃圾回收算法包含一下3個階段:

  1. 标記(Marking) — 找到活動對象
  2. 重定位(Relocation) — 将存活對象移動到一起,以便可以釋放較大的連續空間,這個階段也可稱為“壓縮(compaction)”
  3. 重映射(Remapping) — 更新被移動的對象的引用。

下面的内容将對每個階段做詳細介紹。

C4算法中的标記階段

在C4算法中,标記階段(marking phase)使用了并發标記(concurrent marking)和引用跟蹤(reference-tracing)的方法來标記活動對象,這方面内容已經在本系列的第3篇中介紹過。

在标記階段中,GC線程會從線程棧和寄存器中的活動對象開始,周遊所有的引用,标記找到的對象,這些GC線程會周遊堆上所有的可達(reachable)對象。在這個階段,C4算法與其他并發标記器的工作方式非常相似。

C4算法的标記器與其他并發标記器的差別也是始于并發标記階段的。在并發标記階段中,如果應用程式線程修改未标記的對象,那麼該對象會被放到一個隊列中,以備周遊。這就保證了該對象最終會被标記,也因為如此,C4垃圾回收器或另一個應用程式線程不會重複周遊該對象。這樣就節省了标記時間,消除了遞歸重标記(recursive remark)的風險。(注意,長時間的遞歸重标記有可能會使應用程式因無法獲得足夠的記憶體而抛出OOM錯誤,這也是大部分垃圾回收場景中的普遍問題。)

JVM性能優化 JVM性能優化, Part 1 ―― JVM簡介  JVM性能優化, Part 2 ―― 編譯器 JVM性能優化, Part 3 垃圾回收  JVM 性能優化, Part 4: C4 垃圾回收 JVM性能優化, Part 5:Java的伸縮性 

Figure 1. Application threads traverse the heap just once during marking

如果C4算法的實作是基于髒卡表(dirty-card tables)或其他對已經周遊過的堆區域的讀寫操作進行記錄的方法,那垃圾回收線程就需要重新通路這些區域做重标記。在極端條件下,垃圾回收線程會陷入到永無止境的重标記中 —— 至少這個過程可能會長到使應用程式因無法配置設定到新的記憶體而抛出OOM錯誤。但C4算法是基于LVB(load value barrier)實作的,LVB具有自愈能力,可以使應用程式線程迅速查明某個引用是否已經被标記過了。如果這個引用沒有被标記過,那麼應用程式會将其添加到GC隊列中。一旦該引用被放入到隊列中,它就不會再被重标記了。應用程式線程可以繼續做它自己的事。

髒對象(dirty object)和卡表(card table)

由于某些原因(例如在一個并發垃圾回收周期中,對象被修改了),垃圾回收器需要重新通路某些對象,那麼這些對象髒對象(dirty object)。這這些髒對象,或堆中髒區域的引用,通過會記錄在一個專門的資料結構中,這就是卡表。

在C4算法中,并沒有重标記(re-marking)這個階段,在第一次便利整個堆時就會将所有可達對象做标記。因為運作時不需要做重标記,也就不會陷入無限循環的重标記陷阱中,由此而降低了應用程式因無法配置設定到記憶體而抛出OOM錯誤的風險。

C4算法中的重定位 —— 應用程式線程與GC的協作

C4算法中,*重定位階段(reloacation phase)*是由GC線程和應用程式線程以協作的方式,并發完成的。這是因為GC線程和應用程式線程會同時工作,而且無論哪個線程先通路将被移動的對象,都會以協作的方式幫助完成該對象的移動任務。是以,應用程式線程可以繼續執行自己的任務,而不必等待整個垃圾回收周期的完成。

正如Figure 2所示,碎片記憶體頁中的活動對象會被重定位。在這個例子中,應用程式線程先通路了要被移動的對象,那麼應用程式線程也會幫助完成移動該對象的工作的初始部分,這樣,它就可以很快的繼續做自己的任務。虛拟位址(指相關引用)可以指向新的正确位置,記憶體也可以快速回收。

JVM性能優化 JVM性能優化, Part 1 ―― JVM簡介  JVM性能優化, Part 2 ―― 編譯器 JVM性能優化, Part 3 垃圾回收  JVM 性能優化, Part 4: C4 垃圾回收 JVM性能優化, Part 5:Java的伸縮性 

Figure 2. A page selected for relocation and the empty new page that it will be moved to

如果是GC線程先通路到了将被移動的對象,那事情就簡單多了,GC線程會執行移動操作的。如果在重映射階段(re-mapping phase,後續會提到)也通路這個對象,那麼它必須檢查該對象是否是要被移動的。如果是,那麼應用程式線程會重新定位這個對象的位置,以便可以繼續完成自己任務。(對大對象的移動是通過将該對象打碎再移動完成的。如果你對這部分内容感興趣的話,推薦你閱讀一下相關資源中的這篇白皮書“C4: The Continuously Concurrent Compacting Collector”)

當所有的活動對象都從某個記憶體也中移出後,剩下的就都是垃圾資料了,這個記憶體頁也就可以被整體回收了。正如Figure 2中所示。

關于清理

在C4算法中并沒有清理階段(sweep phase),是以也就不需要這個在大多數垃圾回收算法中比較常用的操作。在指向被移動的對象的引用都更新為指向新的位置之前,from頁中的虛拟位址空間必須被完整保留。是以C4算法的實作保證了,在所有指向這個頁的引用處于穩定狀态前,所有的虛拟位址空間都會被鎖定。然後,算法會立即回收實體記憶體頁。

很明顯,無需執行stop-the-world式的移動對象是有很大好處的。由于在重定位階段,所有活動對象都是并發移動的,是以它們可以被更有效率的放入到相鄰的位址中,并且可以充分的壓縮。通過并發執行重定位操作,堆被壓縮為連續空間,也無需挂起所有的應用程式線程。這種方式消除了Java應用程式通路記憶體的傳統限制(更多關于Java應用程式記憶體模型的内容參見ImportNew編譯整理的第一篇《JVM性能優化, Part 1 ―― JVM簡介》)。

經過上述的過程後,如何更新引用呢?如何實作一個非stop-the-world式的操作呢?

C4算法中的重映射

在重定位階段,某些指向被移動的對象的引用會自動更新。但是,在重定位階段,那些指向了被移動的對象的引用并沒有更新,仍然指向原處,是以它們需要在後續完成更新操作。C4算法中的重映射階段(re-mapping phase)負責完成對那些活動對象已經移出,但仍指向那些的引用進行更新。當然,重映射也是一個協作式的并發操作。

Figure 3中,在重定位階段,活動對象已經被移動到了一個新的記憶體頁中。在重定位之後,GC線程立即開始更新那些仍然指向之前的虛拟位址空間的引用,将它們指向那些被移動的對象的新位址。垃圾回收器會一直執行此項任務,直到所有的引用都被更新,這樣原先虛拟記憶體空間就可以被整體回收了。

JVM性能優化 JVM性能優化, Part 1 ―― JVM簡介  JVM性能優化, Part 2 ―― 編譯器 JVM性能優化, Part 3 垃圾回收  JVM 性能優化, Part 4: C4 垃圾回收 JVM性能優化, Part 5:Java的伸縮性 

Figure 3. Whatever thread finds an invalid address enables an update to the correct new address

但如果在GC完成對所有引用的更新之前,應用程式線程想要通路這些引用的話,會出現什麼情況呢?在C4算法中,應用程式線程可以很友善的幫助完成對引用進行更新的工作。如果在重映射階段,應用程式線程通路了處于非穩定狀态的引用,它會找到該引用的正确指向。如果應用程式線程找到了正确的引用,它會更新該引用的指向。當完成更新後,應用程式線程會繼續自己的工作。

協作式的重映射保證了引用隻會被更新一次,該引用下的子引用也都可以指向正确的新位址。此外,在大多數其他GC實作中,引用指向的位址不會被存儲在該對象被移動之前的位置;相反,這些位址被存儲在一個堆外結構(off-heap structure)中。這樣,無需在對所有引用的更新完成之前,再花費精力保持整個記憶體頁完好無損,這個記憶體頁可以被整體回收。

C4算法真的是無暫停的麼?

在C4算法的重映射階段,正在跟蹤引用的線程僅會被中斷一次,而這次中斷僅僅會持續到對該引用的檢索和更新完成,在這次中斷後,線程會繼續運作。相比于其他并發算法來說,這種實作會帶來巨大的性能提升,因為其他的并發立即回收算法需要等到每個線程都運作到一個安全點(safe point),然後同時挂起所有線程,再開始對所有的引用進行更新,完成後再恢複所有線程的運作。

對于并發壓縮垃圾回收器來說,由于垃圾回收所引起的暫停從來都不是問題。在C4算法的重定位階段中,也不會有再出現更糟的碎片化場景了。實作了C4算法的垃圾回收器也不會出現背靠背(back-to-back)式的垃圾回收周期,或者是因垃圾回收而使應用程式暫停數秒甚至數分鐘。如果你曾經體驗過這種stop-the-world式的垃圾回收,那麼很有可能是你給應用程式設定的記憶體太小了。你可以試用一下實作了C4算法的垃圾回收器,并為其配置設定足夠多的記憶體,而完全不必擔心暫停時間過長的問題。

評估C4算法和其他可選方案

像往常一樣,你需要針對應用程式的需求選擇一款JVM和垃圾回收器。C4算法在設計之初就是無論堆中活動資料有多少,隻要應用程式還有足夠的記憶體可用,暫停時間都始終保持在較低的水準。正因如此,對于那些有大量記憶體可用,而對響應時間比較敏感的應用程來說,選擇實作了C4算法的垃圾回收器正是不二之選。

而對于那些要求快速啟動,記憶體有限的用戶端應用程式來說,C4就不是那麼适用。而對于那些對吞吐量有較高要求的應用程式來說,C4也并不适用。真正能夠發揮C4威力的是那些為了提升應用程式工作負載而在每台伺服器上部署了4到16個JVM執行個體的場景。此外,如果你經常要對垃圾回收器做調優的話,那麼不妨考慮一下使用C4算法。綜上所述,當響應時間比吞吐量占有更高的優先級時,C4是個不錯的選擇。而對那些不能接受長時間暫停的應用程式來說,C4是個理想的選擇。

如果你正考慮在生産環境中使用C4,那麼你可能還需要重新考慮一下如何部署應用程式。例如,不必為每個伺服器配置16個具有2GB堆的JVM執行個體,而是使用一個64GB的JVM執行個體(或者增加一個作為熱備份)。C4需要盡可能大的記憶體來保證始終有一個空閑記憶體頁來為新建立的對象配置設定記憶體。(記住,記憶體不再是昂貴的資源了!)

如果你沒有64GB,128GB,或1TB(或更多)記憶體可用,那麼分布式的多JVM部署可能是一個更好的選擇。在這種場景中,你可以考慮使用Oracle HotSpot JVM的G1垃圾回收器,或者IBM JVM的平衡垃圾回收政策(Balanced Garbage Collection Policy)。下面将對這兩種垃圾回收器做簡單介紹。

Gargabe-First (G1) 垃圾回收器

G1垃圾回收器是新近才出現的垃圾回收器,是Oracle HotSpot JVM的一部分,在最近的JDK1.6版本中首次出現(譯者注,該文章寫于2012-07-11)。在啟動Oracle JDK時附加指令行選項-XX:+UseG1GC,可以啟動G1垃圾回收器。

與C4類似,這款标記-清理(mark-and-sweep)垃圾回收器也可作為對低延遲有要求的應用程式的備選方案。G1算法将堆分為固定大小區域,垃圾回收會作用于其中的某些區域。在應用程式線程運作的同時,啟用背景線程,并發的完成标記工作。這點與其他并發标記算法相似。

G1增量方法可以使暫停時間更短,但更頻繁,而這對一些力求避免長時間暫停的應用程式來說已經足夠了。另一方面,正如在本系列的[Part 3][4]中介紹的,使用G1垃圾回收器需要針對應用程式的實際需求做長時間的調優,而其GC中斷又是stop-the-world式的。是以對那些對低延遲有很高要求的應用程式來說,G1并不是一個好的選擇。進一步說,從暫停時間總長來看,G1長于CMS(Oracle JVM中廣為人知的并發垃圾回收器)。

G1使用拷貝算法(在Part 3中介紹過)完成部分垃圾回收任務。這樣,每次垃圾回收器後,都會産生完全可用的空閑空間。G1垃圾回收器定義了一些區域的集合作為年輕代,剩下的作為老年代。

G1已經吸引了足夠多的注意,引起了不小的轟動,但是它真正的挑戰在于如何應對現實世界的需求。正确的調優就是其中一個挑戰 —— 回憶一下,對于動态應用程式負載來說,沒有永遠“正确的調優”。一個問題是如何處理與分區大小相近的大對象,因為剩餘的空間會成為碎片而無法使用。還有一個性能問題始終困擾着低延遲垃圾回收器,那就是垃圾回收器必須管理額外的資料結構。就我來說,使用G1的關鍵問題在于如何解決stop-the-world式垃圾回收器引起的暫停。Stop-the-world式的垃圾回收引起的暫停使任何垃圾回收器的能力都受制于堆大小和活動資料數量的增長,對企業級Java應用程式的伸縮性來說是一大困擾。

IBM JVM的平衡垃圾回收政策(Balanced Garbage Collection Policy)

IBM JVM的平衡垃圾回收(Balanced Garbage Collection BGC)政策通過在啟動IBM JDK時指定指令行選項-Xgcpolicy:balanced來啟用。乍一看,BGC很像G1,它也是将Java堆劃分成相同大小的空間,稱為區間(region),執行垃圾回收時會對每個區間單獨回收。為了達到最佳性能,在選擇要執行垃圾回收的區間時使用了一些啟發性算法。BGC中關于代的劃分也與G1相似。

IBM的平衡垃圾回收政策僅在64位平台得到實作,是一種NUMA架構(Non-Uniform Memory Architecture),設計之初是為了用于具有4GB以上堆的應用程式。由于拷貝算法或壓縮算法的需要,BGC的部分垃圾回收工作是stop-the-world式的,并非完全并發完成。是以,歸根結底,BGC也會遇到與G1和其他沒有實作并發壓縮選法的垃圾回收器相似的問題。

結論:回顧

C4是基于引用跟蹤的、分代式的、并發的、協作式垃圾回收算法,目前隻在Azul System公司的Zing JVM得到實作。C4算法的真正價值在于:

  • 消除了重标記可能引起的重标記無限循環,也就消除了在标記階段出現OOM錯誤的風險。
  • 壓縮,以自動、且不斷重定位的方式消除了固有限制:堆中活動資料越多,壓縮所引起的暫停越長。
  • 垃圾回收不再是stop-the-world式的,大大降低垃圾回收對應用程式響應時間造成的影響。
  • 沒有了清理階段,降低了在完成GC之前就因為空閑記憶體不足而出現OOM錯誤的風險。
  • 記憶體可以以頁為機關立即回收,使那些需要使用較多記憶體的Java應用程式有足夠的記憶體可用。

并發壓縮是C4獨一無二的優勢。使應用程式線程GC線程協作運作,保證了應用程式不會因GC而被阻塞。C4将記憶體配置設定和提供足夠連續空閑記憶體的能力完全區分開。C4使你可以為JVM執行個體配置設定盡可能大的記憶體,而無需為應用程式暫停而煩惱。使用得當的話,這将是JVM技術的一項革新,它可以借助于當今的多核、TB級記憶體的硬體優勢,大大提升低延遲Java應用程式的運作速度。

如果你不介意一遍又一遍的調優,以及頻繁的重新開機的話,如果你的應用程式适用于水準部署模型的話(即部署幾百個小堆JVM執行個體而不是幾個大堆JVM執行個體),G1也是個不錯的選擇。

對于動态低延遲啟發性自适應(dynamic low-latency heuristic adaption)算法而言,BGC是一項革新,JVM研究者對此算法已經研究了幾十年。該算法可以應用于較大的堆。而動态自調優算法( dynamic self-tuning algorithm)的缺陷是,它無法跟上突然出現的負載高峰。那時,你将不得不面對最糟糕的場景,并根據實際情況再配置設定相關資源。

最後,為你的應用程式選擇最适合的JVM和垃圾回收器時,最重要的考慮因素是應用程式中吞吐量和暫停時間的優先級次序。你想把時間和金錢花在哪?從純粹的技術角度說,基于我十年來對垃圾回收的經驗,我一直在尋找更多關于并發壓縮的革新性技術,或其他可以以較小代價完成移動對象或重定位的方法。我想影響企業級Java應用程式伸縮性的關鍵就在于并發性。

英文原文:JVM performance optimization, Part 4,翻譯:ImportNew - 曹旭東

譯文連結:http://www.importnew.com/2410.html

【如需轉載,請在正文中标注并保留原文連結、譯文連結和譯者等資訊,謝謝合作!】

JVM 性能優化系列

第一篇 《JVM性能優化, Part 1 ―― JVM簡介 》

第二篇《JVM性能優化, Part 2 ―― 編譯器》

第三篇《JVM性能優化, Part 3 —— 垃圾回收》

更多關于垃圾回收的文章

  • “C4: The Continuously Concurrent Compacting Collector” (Gil Tene, Balaji Iyengar and Michael Wolf; Proceedings of the International Symposium on Memory Management, 2011): Learn more about the C4 algorithm and shattered object moves.
  • “Garbage-first garbage collection” (David Detlefs, et al., 2004, Proceedings of the 4th international Symposium on Memory Management, 2004): Learn more about the G1 algorithm. (Paid access on the ACM website.)
  • “G1: Java’s Garbage First Garbage Collector” (Eric J. Bruno, Dr. Dobb’s, August 2009): A more in-depth overview and evaluation of G1.
  • The IBM Software Developers Kit (SDK) for Java documentation includes information about the Balanced Garbage Collection Policy.
  • “Java VM: IBM vs Sun” (Stackoverflow.com, December 2008): What factors might help you choose?

關于作者

Eva Andearsson對JVM計數、SOA、雲計算和其他企業級中間件解決方案有着10多年的從業經驗。在2001年,她以JRockit JVM開發者的身份加盟了創業公司Appeal Virtual Solutions(即BEA公司的前身)。在垃圾回收領域的研究和算法方面,EVA獲得了兩項專利。此外她還是提出了确定性垃圾回收(Deterministic Garbage Collection),後來形成了JRockit實時系統(JRockit Real Time)。在技術上,Eva與Sun公司和Intel公司合作密切,涉及到很多将JRockit産品線、WebLogic和Coherence整合的項目。2009年,Eva加盟了Azul System公司,擔任産品經理。負責新的Zing Java平台的開發工作。最近,她改換門庭,以進階産品經理的身份加盟Cloudera公司,負責管理Cloudera公司Hadoop分布式系統,緻力于高擴充性、分布式資料處理架構的開發。

JVM性能優化, Part 5:Java的伸縮性 

本文由 ImportNew - ImportNew讀者 翻譯自 Javaworld。如需轉載本文,請先參見文章末尾處的轉載要求。

ImportNew注: JVM性能優化系列文章前4篇由ImportNew翻譯(第一篇,第二篇,第三篇, 第四篇)。本文由新浪微網誌:吳傑 (@WildJay) 投稿至ImportNew。感謝吳傑! 如果你希望分享好的原創文章或者譯文,歡迎投稿到ImportNew。

很多程式員在解決JVM性能問題的時候,花開了很多時間去調優應用程式級别的性能瓶頸,當你讀完這本系列文章之後你會發現我可能更加系統地看待這類的問題。我說過JVM的自身技術限制了Java企業級應用的伸縮性。首先我們先列舉一些主導因素。

l  主流的硬體伺服器提供了大量的記憶體

l  分布式系統有大量記憶體的需求,而且該需求在持續增長

l  一個普通Java應用程式所持有的對空間大概在1GB~4GB,這遠遠低于一個硬體伺服器的記憶體管理能力以及一個分布式應用程式的記憶體需求量。這被稱之為Java記憶體牆,如下圖所示(圖中表述Java應用伺服器和正常Java應用的記憶體使用量的演變史)。

JVM性能優化 JVM性能優化, Part 1 ―― JVM簡介  JVM性能優化, Part 2 ―― 編譯器 JVM性能優化, Part 3 垃圾回收  JVM 性能優化, Part 4: C4 垃圾回收 JVM性能優化, Part 5:Java的伸縮性 

Java記憶體牆(1980~2010)   (圖檔來源:Azul Systems)

這給我們帶來了如下JVM性能課題:

1)    如果配置設定給應用程式的記憶體太小,将導緻記憶體不足。JVM 不能及時釋放記憶體空間給應用程式,最終将引發記憶體不足,或者JVM完全關閉。是以你必須提供更多的記憶體給應用程式。

2)    如果給對響應時間敏感的應用增加記憶體,如果不重新開機你的系統或者優化你的應用,Java堆最終會碎片化。當碎片發生時,可能會導緻應用中斷100毫秒~100秒,這取決與你的Java應用,Java堆的大小以及其他的JVM調優參數。

關于停頓的讨論大部分都集中在平均停頓或者目标停頓,很少涉及到堆壓縮時的最壞停頓時間,在生産環境中堆中每千兆位元組的有效資料的都将會發生大約1秒的停頓。

2~4秒的停頓對大多數企業應用來說都是不能接受的,是以盡管實際的Java應用執行個體可能需要更多的記憶體空間,但實際隻配置設定2~4GB的記憶體。在一些64位系統中帶有很多關于伸縮性的JVM調優項,使得這些系統可以運作16GB乃至20GB的堆空間,并能滿足典型響應時間的SLA。但是這些離現實較遠,JVM目前的技術無法在進行堆壓縮時避免停頓應用程式。Java應用開發人員苦于處理這兩個為我們大多數人所抱怨的任務。

l  架構/模組化在大量的執行個體池之上,随之而來的是複雜的監控和管理操作。

l  反複的JVM和應用程式調優以避免“stop the world“引起的停頓。大多數程式員希望停頓不要發生在系統峰值負載期間。我稱之為不可能的目标。

現在讓我們深入一點Java的可伸縮性問題。

過度供給或過度執行個體化Java部署

為了充分利用記憶體資源,普通的做法是将Java應用部署在多個應用伺服器執行個體上而不是一個或者少數應用伺服器執行個體上。當一台Server上運作16個應用伺服器執行個體可以充分利用所有的記憶體資源,但如此無法解決的是多執行個體的監控以及管理所帶來的成本,尤其是當你的應用部署在多個Server上。

另一個問題來了,峰值負載時的記憶體資源不是每天都需要的,這樣就形成了巨大的浪費。有些情況下,一台實體機上可能隻不是不超過3個“大應用伺服器執行個體”,這樣的部署更加不夠經濟也不夠環保,尤其在非峰值負載期間。

讓我們來比較一下這兩種部署架構,下圖中左邊是多而小的應用伺服器執行個體部署模式,右邊是少而大的應用伺服器執行個體部署模式。兩種模式處理同樣的負載,究竟哪一種部署架構更具經濟性。

JVM性能優化 JVM性能優化, Part 1 ―― JVM簡介  JVM性能優化, Part 2 ―― 編譯器 JVM性能優化, Part 3 垃圾回收  JVM 性能優化, Part 4: C4 垃圾回收 JVM性能優化, Part 5:Java的伸縮性 

大應用伺服器部署場景 (圖檔來源:Azul Systems)

如我之前說過的,并發壓縮使得大應用伺服器部署模式變得可行,而且可以突破JVM可伸縮性的限制。目前隻有Azul的Zing JVM可以提供并發壓縮的技術,另外Zing是Server側的JVM,我們很樂意看到越來越多的開發者在JVM層面去挑戰Java可伸縮性的問題。

由于性能調優仍然是我們解決Java可伸縮性問題的主要手段,我們先來看有哪些主要的調優參數以及通過它們能達到什麼樣的效果。

調優參數:一些事例

最著名的調優參數莫過于”-Xmx”了,通過該參數可以指定Java的堆空間大小,實際上可能不同的JVM執行結果不太一樣。

有的JVM包含了内部結構(如編譯器線程,垃圾回收器結構,代碼緩存等等)所需要的記憶體在“-Xmx”的設定中,而有的則不包含。是以使用者Java程序的大小不一定跟“-Xmx”的設定相吻合。

如果你的應用程式配置設定對象的速率,對象的生命周期,或者對象的大小超過了JVM記憶體相關配置,一旦達到最大可使用記憶體的門檻值将會發生記憶體溢出,使用者程序則會停止。

當你的應用程式糾結于記憶體的可用性時,最有效的方法就是通過”-Xmx”指定更大的記憶體去重新開機目前應用程序。為了避免頻繁的重新開機,大多數企業生産環境都傾向于指定峰值負載時所需要的記憶體,造成過度配置優化。

提示:生産環境負載的調整

Java開發人員易犯的常見錯誤是在實驗下的做的堆記憶體設定,在移植到生産環境是忘記重新調整。生産環境和實驗室環境是不一樣的,謹記根據生産環境的負載重新調整堆記憶體設定。

分代垃圾回收器調優

還有一些其他的優化選項”-Xns”和”-XX: NewSize”,用來調整年輕代的大小,用來指定堆中專門負責新對象配置設定的空間大小。

大多數開發者都試圖基于實驗室環境調整年輕代的大小,這意味着在生産負載下存在失敗的風險。一般新生代的大小設定為堆大小的三分之一至二分之一左右,但這不是一個準則,畢竟實際還要視應用程式邏輯而定。是以最好先調查清楚年輕代到年老代的蛻變率以及年老代對象的大小,在此基礎上(確定年老代的大小,年老代過小會頻繁促發GC導緻記憶體溢出錯誤)盡可能地調大年輕代的空間。

還有一個與年輕代相關的調優項”-XX:SurvivorRatio”,該選項用來指定年輕代中對象的生命周期,超過指定時長相關對象将被移至年老代。為了”正确”地設定該值,你需要知道年輕代空間回收的頻率,能夠估算到新對象在應用程式程序中被引用的時長,同時也取決于配置設定率。

并發垃圾回收調優

針對對停頓敏感的應用,建議使用并發垃圾回收,雖然并行的辦法能夠帶來非常好的吞吐量基準測試分數,但是并行GC不利于縮短響應時間。并發 GC 是目前唯一有效的實作一緻性和最少“stop the world”中斷的方法。不同的JVM提供不同的并發GC的設定,Oracle JVM(hotspot)提供”-XX:+UseConcMarkSweepGC”,今後G1将成為Oracle JVM預設的并發垃圾回收器。

性能調優并不是真正的解決辦法

或許你已經注意到上文中在讨論如何“正确“地設定調優此參數時,我刻意在”正确“二字上加了雙引号。那是因為就我個人經驗而言一旦涉及到性能參數調優,就沒有嚴格意義上的正确設定。每一個設定值都是針對特定的場景。考慮到應用場景會發生變化,JVM 性能調整充其量是一個權宜之計。

以堆的設定為例:如果2GB的堆可以應對20萬并發使用者,但是可能不能應付40萬的并發使用者。

我們再以”-XX:SurvivorRatio”為例:當設定符合一個負載持續增長最高至每毫秒10000個交易的場景,當壓力到達每毫秒50000個交易時又會發生什麼呢?

大多數企業級應用負載都是動态的,Java語言的動态記憶體管理以及動态編譯等技術使得Java更加适合企業級應用。我們來看看一下兩個配置清單。

清單1. 應用程式(1)的啟動選項

1 2 3 4 5 6

>java -Xmx12g -XX:MaxPermSize=64M -XX:PermSize=32M -XX:MaxNewSize=2g

-XX:NewSize=1g -XX:SurvivorRatio=16 -XX:+UseParNewGC

-XX:+UseConcMarkSweepGC -XX:MaxTenuringThreshold=0

-XX:CMSInitiatingOccupancyFraction=60 -XX:+CMSParallelRemarkEnabled

-XX:+UseCMSInitiatingOccupancyOnly -XX:ParallelGCThreads=12

-XX:LargePageSizeInBytes=256m …

清單2. 應用程式(2)的啟動選項

1 2 3 4 5

>java --Xms8g --Xmx8g --Xmn2g -XX:PermSize=64M -XX:MaxPermSize=256M

-XX:-OmitStackTraceInFastThrow -XX:SurvivorRatio=2 -XX:-UseAdaptiveSizePolicy -XX:+UseConcMarkSweepGC

-XX:+CMSConcurrentMTEnabled -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelSurvivorRemarkEnabled

-XX:CMSMaxAbortablePrecleanTime=10000 -XX:+UseCMSInitiatingOccupancyOnly

-XX:CMSInitiatingOccupancyFraction=63 -XX:+UseParNewGC --Xnoclassgc …

兩者的配置差別很大,因為他們是兩個不同應用程式。感覺根據各自的應用特設都做了”正确“的配置與調優。在實驗室環境下都運作良好,但在生産環境中最終會表現出疲态。清單1由于沒有考慮到動态負載,到了生産環境即表現不良。清單2沒有考慮到應用程式在生産環境中的特性變化。這兩種情況應該歸咎于開發團隊,但是該歸咎于何處呢?

變通辦法可行嗎?

有些企業通過精确測量交易對象的大小定義極緻的對象回收空間并”精簡“其架構來适配該空間。這也許是辦法來削減碎片以應對一整天的交易(在不做堆壓縮的情況下)。還有一個辦法就是通過程式設計確定對象被引用的時間在一個比較短的時間内進而阻止其在SurvivorRatio時間之後不被遷往年老代而直接被回收,避免記憶體壓縮的場景。這兩種辦法都可以,但是對應用開發人員和設計人員有一定的挑戰。

誰保障應用程式的性能?

一個門戶應用可能會在其活動負載峰值點出現故障;一個交易應用可能會在每次市場下跌和上升時無法正常運作;電子商務網站可能會無法應對節假日購物高峰期。這些都是真實世界的案例基本都是JVM性能參數調優導緻的。當産生了經濟損失,開發團隊就會受到責備。也許某些場合下開發團隊應該要受到責備,但是JVM的提供商又應該負起什麼樣兒的責任呢?

首先JVM提供商應該要提供調優參數的優先順序,至少這在短期内還是很有意義的。有一些新的調優選項是針對特定的、 新興的企業應用程式場景。更多的調優選項是為了減輕JVM支援團隊的工作負荷而将性能優化轉嫁到應用開發者身上。但我個人認為這或将導緻更加漫長的支援負荷,一些針對最糟糕場景的調優選項也将被延期,當然不是無限延期。

毋庸置疑JVM的開發團隊也在努力地進行着他們的工作,同時也隻有應用實施者才會更加清楚他們應用的特定需求。但是應用的實施者或開發者是無法預測期動态的負載需求。在過去,JVM提供商也會去分析關于Java的性能與可擴充性問題,哪些是他們能夠解決的。不是提供調優參數,而是直接去優化或創新垃圾回收的算法。更有趣是我們可以想象一下如果OpenJDK的社群聚集在一起重新考慮Java垃圾回收器将會發生什麼!

JVM性能的基準測試

調優參數有時被JVM提供商作為其競争的工具,因為不同的調優可以改善他們的JVM在可預見的環境中的性能表現,本系列的最後一片文章中将調查這些基準測試來衡量JVM的性能。

JVM開發者的挑戰

真正的企業級可伸縮性需求是要求JVM能夠适應動态靈活的應用負載。這是在特定吞吐量和響應時間内保證持續穩定性能的關鍵。這是JVM開發者才能完成曆史使命,是以是時候号召我們Java開發者社群來迎接真正的Java可伸縮性的挑戰。

l  持續調優

對于給定的應用,在一開始需要告知其需要多大的記憶體,之後的工作都應該有JVM來負責 ,JVM需要适配動态的應用負載和運作場景。

l  JVM執行個體數 vs. 執行個體的可擴充性

現在的伺服器都支援很大的記憶體,那麼為什麼JVM執行個體不能有效地利用它呢?将應用拆分部署許多小的應用伺服器執行個體上,這從經濟和環保角度都是一種浪費。現代的JVM需要跟上硬體和應用的發展潮流。

l  真實世界的性能和可伸縮性

企業不需要為其應用的性能需求去做極緻的性能調優。JVM提供商和OpenJDK社群需要去解決Java可伸縮性的核心問題以及消除“stop the world“的操作。

結論

如果JVM做了這樣的工作,并且提供了并發壓縮的垃圾回收算法,JVM也不再成為Java可伸縮性的限制因素,Java應用開發者不需要花費痛苦的時間了解怎樣配置JVM去獲得最佳性能,進而将會有更多的有趣的Java應用層面的創新,而不是無休止的JVM調優。我要挑戰JVM開發人員以及提供商所需要做的事情來相應甲骨文所提倡的“Make the Java Future“的活動。

參考資料

  • “Understanding Java Garbage Collection and What You Can Do about It” (Gil Tene, InfoQ, December 2011): Azul cofounder Gil Tene explains the workings of a garbage collector, including terminology, metrics, key mechanisms, and the application memory wall challenge discussed in this article.
  •  Oracle HotSpot FAQ: State-of-the-art tuning advice for long pause times is to decrease heap size.
  • “Maximum Java heap size of a 32-bit JVM on a 64-bit OS” (Stackoverflow, September 2009): Developers on Stackoverflow discuss the challenge of tuning Java heap size for real-world systems.
  • “About OpenDJ and Hotspot JVM G1” (Ludovic Poitou, Ludo’s Sketches, May 2012): JVM performance has far-reaching implications for innovation on Java projects like the OpenDJ Directory Services Project.

關于作者

Eva Andearsson對JVM計數、SOA、雲計算和其他企業級中間件解決方案有着10多年的從業經驗。在2001年,她以JRockit JVM開發者的身份加盟了創業公司Appeal Virtual Solutions(即BEA公司的前身)。在垃圾回收領域的研究和算法方面,EVA獲得了兩項專利。此外她還是提出了确定性垃圾回收(Deterministic Garbage Collection),後來形成了JRockit實時系統(JRockit Real Time)。在技術上,Eva與Sun公司和Intel公司合作密切,涉及到很多JRockit産品線、WebLogic和Coherence整合的項目。2009年,Eva加盟了Azul System公司,擔任産品經理。負責新的Zing Java平台的開發工作。最近,她改換門庭,以進階産品經理的身份加盟Cloudera公司,負責管理Cloudera公司Hadoop分布式系統,緻力于高擴充性、分布式資料處理架構的開發。

【如需轉載,請在正文中标注并保留原文連結、譯文連結和譯者等資訊,謝謝合作!】