NO IMAGE

作者 Eva Andreasson  譯者:趙峰 校對:方騰飛  原文連結

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效能調優:閱讀該系列

  • 第一部分:概述
  • 第二部分:編譯工具
  • 第三部分:垃圾回收
  • 第四部分:併發壓縮GC
  • 第五部分:可擴充套件性

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的優點,更願意去犧牲機器優化程式碼,帶來完美的效能。

  • 易於編碼和功能開發(意義是更短的時間去響應市場)
  • 得到知識淵博的的程式設計師
  • 用Java APIs和標準庫更快速的開發
  • 可移植性——不用為新的平臺去重新寫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效能優化。