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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章