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效能最佳化。