Java應用程式是運行在JVM上的,但你對JVM技術了解嗎?這篇文章(這個系列的第一部分)講述了經典Java虛擬機是怎麼樣工作的,例如:Java一次編寫的利弊,跨平台引擎,垃圾回收基礎知識,經典的GC演算法和編譯優化。之後的文章會講JVM效能優化,包括最新的JVM設計――支援當今高並發Java應用的效能和擴充。
如果你是個開發人員,你一定遇過這樣的特殊感覺,你突然靈光一現,所有的思路連結起來了,你能以一個新的視角來回想起你以前的想法。我個人很喜歡學習新知識帶來的這種感覺。我已經有過很多次這樣的經驗了,在我使用JVM技術工作時,特別是使用垃圾回收和JVM效能優化時。在這個新的Java世界中,我希望和你分享我的這些啟發。希望你能像我寫這篇文章一樣興奮的去了解JVM的性能。
這個系列文章,是為所有有興趣去學習更多JVM底層知識,和JVM實際做了什麼的Java開發人員所寫的。在更高層次,我將討論垃圾回收和在不影響應用程式運行的情況下,對空閒記憶體安全性和速度上的無止境追求。你將學到JVM的關鍵部分:垃圾回收和GC演算法,編譯最佳化,和一些常用的最佳化。我同樣會討論為什麼Java標記這麼難,提供建議什麼時候應該考慮測試效能。最後,我將講一些JVM和GC的新的創新,包括Azul's Zing JVM, IBM JVM, 和Oracle's Garbage First (G1) 垃圾回收中的重點。
我希望你讀完這個系列時對Java可擴展性限制的特點有更深的了解,同樣的這樣限制是如何強制我們以最優的方式創建一個Java部署。希望你會有一種豁然開朗的感受,並且能激發了一些好的Java靈感:停止接受那些限制,並去改變它!如果你現在還不是一個開源工作者,這個系列或許會鼓勵你往這方面發展。
JVM效能和「一次編譯,到處執行」的挑戰
我有新的消息告訴那些固執的認為Java平臺本質上是緩慢的人。當Java剛剛做為企業級應用的時候,JVM被詬病的Java性能問題已經是十多年前的事了,但這個結論,現在已經過時了。這是真的,如果你現在在不同的開發平台上運行簡單靜態和確定的任務時,你將很可能發現使用機器優化過的代碼比使用任何虛擬環境執行的要好,在相同的JVM下。但是,Java的效能在過去10年有了非常大的提升。 Java產業的市場需求和成長,導致了少量的垃圾回收演算法、新的編譯創新、和大量的啟發式方法和最佳化,這些使JVM技術得到了進步。我將在以後的章節中介紹一些。
JVM的技術之美,同樣是它最大的挑戰:沒有什麼可以被認為是「一次編譯,到處運行」的應用。不是優化一個用例,一個應用,一個特定的用戶負載,JVM不斷的追蹤Java應用現在在做什麼,並進行相應的最佳化。這種動態的運作導致了一系列動態的問題。當設計創新時(至少不是在我們向生產環境要效能時),致力於JVM的開發者不會依賴靜態編譯和可預測的分配率。
JVM性能的事業
在我早期的工作中我意識到垃圾回收是非常難「解決」的,我一直著迷於JVMs和中間件技術。我對JVMs的熱情開始於我在JRockit團隊中時,編碼一種新的方法用於自學,自己調試垃圾回收演算法(參考Resources)。這個專案(轉變為JRockit一個實驗性的特點,並成為Deterministic Garbage Collection演算法的基礎)開啟了我JVM技術的旅程。我已經在BEA系統、Intel、Sun和Oracle(因為Oracle收購BEA系統,所以被Oracle短暫的工作過)工作過。之後我加入了在Azul Systems的團隊去管理Zing JVM,現在我為Cloudera工作。
機器優化的程式碼可能會實現較好的效能(但這是以犧牲靈活性來做代價的),但對於動態裝載和功能快速變化的企業應用這並不是一個權衡選擇它的理由。大多數的企業為了Java的優點,更願意去犧牲機器優化程式碼帶來的勉強完美的效能。
1.易於編碼和功能開發(意義是更短的時間去回應市場)
2.得到知識淵博的的程式設計師
3.用Java APIs和標準函式庫更快速的開發
4.可移植性――不用為新的平台去重寫Java應用
從Java代碼到字節碼
做為一個Java程式設計師,你可能對編碼、編譯和執行Java應用很熟悉。例子:我們假設你有一個程式(MyApp.java),現在你想讓它運行。去執行這個程式你需要先用javac(JDK內建的靜態Java語言到字節碼編譯器)編譯。基於Java程式碼,javac產生對應的可執行字節碼,並保存在相同名字的class檔案:MyApp.class中。在把Java程式碼編譯成字節碼後,你可以透過java命令(透過命令列或startup腳本,使用不使用startup選項都可以)來啟動可執行的class文件,從而運行你的應用。這樣你的class被載入到執行時期(意味著Java虛擬機器的運作),程式開始執行。
這就是表面上每一個應用程式執行的場景,但現在我們來探討下當你執行java指令時究竟發生了什麼事。 Java虛擬機器是什麼?大多數開發人員透過持續調試來與JVM互動――aka selecting 和value-assigning啟動選項能讓你的Java程式跑的更快,同時避免了臭名昭著的」out of memory」錯誤。但是,你是否曾經想過,為什麼我們起初需要一個JVM來執行Java應用程式呢?
什麼是Java虛擬機器?
簡單的說,一個JVM是一個軟體模組,用於執行Java應用字節碼並且把字節碼轉換到硬件,作業系統特殊指令。透過這樣做,JVM允許Java程式在第一次編寫後可以在不同的環境中執行,並不需要更改原始的程式碼。 Java的可移植性是通往企業應用語言的關鍵:開發者並不需要為不同平台重寫應用程式碼,因為JVM負責翻譯和平台最佳化。
一個JVM基本上是一個虛擬的執行環境,作為一個字節碼指令機器,而用於分配執行任務和執行記憶體操作通過與底層的交互。
一個JVM同樣為運行的Java應用照看動態資源管理。這就意味著它掌握分配和釋放內存,在每個平台上保持一致的線程模型,在應用執行的地方用一種適於CPU架構的方式組織可執行的指令。 JVM把開發人員從追蹤物件當中的引用,和它們需要在系統中存在多久中解放出來。同樣的它不用我們管理何時去釋放記憶體――一個像C語言那樣的非動態語言的痛點。
你可以把JVM當做是一個專門為Java運行的作業系統;它的工作是為Java應用管理運行環境。一個JVM基本上是一個虛擬的透過與底層的互動的執行環境,作為一個字節碼指令機器,而用來分配執行任務和執行記憶體操作。
JVM元件概述
有很多寫JVM內部和效能優化的文章。作為這個系列的基礎,我將會總結概述下JVM元件。這個簡短的閱覽會為剛接觸JVM的開發者有特別的幫助,會讓你更想了解之後更深入的討論。
從一種語言到另一種――關於Java編譯器
編譯器是把一種語言輸入,然後輸出另一個可執行的語句。 Java編譯器有兩個主要任務:
1. 讓Java語言更輕便,不用第一次寫的時候固定在特定的平台;
2. 確保對特定的平台產生有效的可執行的程式碼。
編譯器可以是靜態也可以是動態。一個靜態編譯的例子是javac。它把Java程式碼當作輸入,並轉換成字節碼(一種在Java虛擬機器執行的語言)。靜態編譯器一次解釋輸入的程式碼,輸出可執行的形式,這個是在程式執行時會被用到。因為輸入是靜態的,你將總是能看到結果相同。只有當你修改原始程式碼並重新編譯時,你才能看到不同的輸出。
動態編譯器,例如Just-In-Time (JIT)編譯器,把一種語言動態的轉換成另一種,這意味著它們做這些時把程式碼被執行。 JIT編譯器讓你收集或創建運行資料分析(透過插入效能計數的方式),用編譯器決定,用手邊的環境資料。動態的編譯器可以在編譯成語言的過程之中,實現更好的指令序列,把一系列的指令替換成更有效的,甚至消除多餘的操作。隨著時間的成長你將收集更多的程式碼配製數據,做更多更好的編譯決定;整個過程就是我們通常稱為的程式碼最佳化和重編譯。
動態編譯給了你可以根據行為去調整動態的變化的優勢,或隨著應用程式裝載次數的增加而催生的新的最佳化。這就是為什麼動態編譯器非常適合Java運行。值得注意的是,動態編譯器請求外部資料結構,執行緒資源,CPU週期分析和最佳化。越深層次的優化,你將需要越多的資源。然而在大多數環境中,頂層對執行效能的提升幫助非常小――比你純粹的解釋要快5到10倍的效能。
分配會導致垃圾回收
分配在每一個執行緒基於每個“Java進程分配記憶體位址空間”,或叫Java堆,或直接叫堆。在Java世界中單執行緒分配在客戶端應用程式中很常見。然而,單執行緒分配在企業應用和工作裝載服務端變的沒有任何益處,因為它並沒有使用現在多核心環境的平行優勢。
並行應用設計同樣迫使JVM保證在同一時間,多執行緒不會分配同一個位址空間。你可以透過在整個分配空間中放把鎖來控制。但這種技術(通常叫做堆鎖)很消耗效能,持有或排隊執行緒會影響資源利用和應用最佳化的效能。多核心系統好的一面是,它們創造了一個需求,為各種各樣的新的方法在資源分配的同時去阻止單線程的瓶頸,和序列化。
一個常用的方法是把堆分成幾部分,在對應用來說每個合式分區大小的地方――顯然它們需要調優,分配率和物件大小對不同應用來說有顯著的變化,同樣執行緒的數量也不同。線程本地分配緩存(Thread Local Allocation Buffer,簡寫:TLAB),或者有時,線程本地空間(Thread Local Area,簡寫:TLA),是一個專門的分區,在其中線程不用聲明一個全堆鎖就可以自由分配。當區域滿的時候,堆就滿了,表示堆上的空閒空間不夠用來放物件的,需要分配空間。當堆滿的時候,垃圾回收就會開始。
碎片
使用TLABs捕捉異常,是把堆碎片化來降低記憶體效率。如果一個應用在要分配物件時正巧不能增加或無法完全分配一個TLAB空間,這將會有空間太小而不能產生新物件的風險。這樣的空閒空間被當作「碎片」。如果應用程式一直保持物件的引用,然後再用剩下的空間分配,最後這些空間會在很長一段時間內空閒。
碎片就是當碎片被分散在堆中的時候――透過一小段不用的記憶體空間來浪費堆空間。為你的應用程式分配「錯誤的」TLAB空間(關於物件的大小、混合物件的大小和引用持有率)是導致堆內碎片增多的原因。在隨著應用的運行,碎片的數量會增加在堆中佔有的空間。碎片導致效能下降,系統不能給新應用程式分配足夠的執行緒和物件。垃圾回收器在隨後會很難阻止out-of-memory異常。
TLAB浪費在工作中產生。一種方法可以完全或暫時避免碎片,那就是在每次基礎操作時優化TLAB空間。這種方法典型的作法是應用只要有分配行為,就需要重新調校。透過複雜的JVM演算法可以實現,另一種方法是組織堆分區實現更有效的記憶體分配。例如,JVM可以實作free-lists,它是連接起一串特定大小的空閒記憶體區塊。一個連續的空閒記憶體區塊和另一個相同大小的連續記憶體區塊相連,這樣會建立少量的鍊錶,每個都有自己的邊界。在有些情況下free-lists導致更好的合適記憶體分配。執行緒可以物件分配在一個差不多大小的區塊中,這樣比你只依賴固定大小的TLAB,潛在的產生少的碎片。
GC瑣事
有一些早期的垃圾收集器擁有多個老年代,但是當超過兩個老年代的時候會導致開銷超過價值。另一種優化分配減少碎片的方法,就是創造所謂的新生代,這是一個專門用於分配新物件的專用堆空間。剩餘的堆會成為所謂的老年代。老年代是用來分配長時間存在的對象的,被假定會存在很長時間的對象包括不被垃圾收集的對像或者大對象。為了更好的理解這種分配的方法,我們需要講一些垃圾收集的知識。
垃圾回收和應用效能
垃圾回收是JVM的垃圾回收器去釋放沒有引用的被佔據的堆記憶體。當第一次觸發垃圾收集時,所有的物件引用還被保存著,被先前的引用佔據的空間被釋放或重新分配。當所有可回收的記憶體被收集後,空間等待被抓取和再次分配給新物件。
垃圾回收器永遠不能重宣告一個引用對象,這樣做會破壞JVM的標準規格。這個規則的異常是一個可以捕獲的soft或weak引用,如果垃圾收集器將要將近耗盡記憶體。我強烈建議你盡量避免weak引用,然而,因為Java規範的模糊導致了錯誤的解釋和使用的錯誤。更何況,Java是被設計成動態記憶體管理,因為你不需要考慮什麼時候和什麼地方釋放記憶體。
垃圾收集器的一個挑戰是在分配記憶體時,需要盡量不影響運行著的應用。如果你不盡量垃圾收集,你的應用程式將耗近記憶體;如果你收集的太頻繁,你將損失吞吐量和回應時間,這將對運行的應用產生壞的影響。
GC演算法
有許多不同的垃圾回收演算法。稍後,在這個系列中將深入討論幾點。在最高層,垃圾收集兩個最主要的方法是引用計數和追蹤收集器。
引用計數收集器會追蹤一個物件指向多少個引用。當一個物件的引用為0時,記憶體將立即回收,這是這種方法的優點之一。引用計數方法的困難在於環形資料結構和保持所有的引用即時更新。
追蹤收集器對仍在引用的對象標記,用已經標記的對象,重複的跟隨和標記所有的引用對象。當所有的仍然引用的物件被標記為「live」時,所有的不被標記的空間將被回收。這種方法管理環形資料結構,但是在許多情況下收集器應該等待直到所有標記完成,在重新回收不被引用的記憶體之前。
有不種的途徑來被上面的方法。最有名的演算法是marking 或copying 演算法, parallel 或concurrent演算法。我將在稍後的文章中討論這些。
通常來說垃圾回收的意義是致力於在堆中為新物件和舊物件分配地址空間。其中「老對象」是指在許多垃圾回收後倖存的對象。用新生代來給新對象分配,老年代給老對象,這樣能通過快速回收佔據內存的短時間對象來減少碎片,同樣通過把長時間存在的對象聚合在一起,並把它們放到老年代地址空間中。所有這些在長時間物件和保存堆記憶體不碎片化之間減少了碎片。新生代的一個正面作用是延遲了需要花費更大代價回收老年代物件的時間,你可以為短暫的物件重複利用相同的空間。 (老空間的收集會花費更多,是因為長時間存在的物件們,會包含更多的引用,需要更多的遍歷。)
最後值的一提的演算法是compaction,這是管理記憶體碎片的方法。 Compaction基本上就是把物件移動在一起,從來釋放更大的連續記憶體空間。如果你熟悉磁碟碎片和處理它的工具,你會發現compaction跟它很像,不同的是這個運行在Java堆記憶體中。我將在系列中詳細討論compaction。
總結:回顧與重點
JVM允許可移植(一次編程,到處運行)和動態的記憶體管理,所有Java平台的主要特性,都是它受歡迎和提高生產力的原因。
在第一篇JVM效能最佳化系統的文章中我解釋了一個編譯器怎麼把字節碼轉換為目標平台的指令語言的,並幫助動態的最佳化Java程式的執行。不同的應用需要不同的編譯器。
我同樣簡述了記憶體分配和垃圾收集,以及這些怎麼與Java應用效能相關的。基本上,你越快的填滿堆和頻繁的觸發垃圾收集,Java應用的佔有率越高。垃圾收集器的一個挑戰是在分配記憶體時,需要盡量不影響運行的應用,但在應用程式耗盡記憶體之前。在以後的文章中我們會更詳細的討論傳統的和新的垃圾回收和JVM性能優化。