JVM 必備指南

xiafei發表於2016-08-06

簡介

Java虛擬機器(JVM)是Java應用的執行環境,從一般意義上來講,JVM是通過規範來定義的一個虛擬的計算機,被設計用來解釋執行從Java原始碼編譯而來的位元組碼。更通俗地說,JVM是指對這個規範的具體實現。這種實現基於嚴格的指令集和全面的記憶體模型。另外,JVM也通常被形容為對軟體執行時環境的實現。通常JVM實現主要指的是HotSpot。

JVM規範保證任何的實現都能夠以同樣的方式解釋執行位元組碼。其實現可以多樣化,包括程式、獨立的Java作業系統或者直接執行位元組碼的處理器晶片。我們瞭解最多的JVM是作為軟體實現,執行在流行的作業系統平臺上(包括Windows、OS X、Linux和Solaris等)。

JVM的結構允許對一個Java應用進行更細微的控制。這些應用執行在沙箱(Sandbox)環境中。確保在沒有恰當的許可時,無法訪問到本地檔案系統、處理器和網路連線。遠端執行時,程式碼還需要進行證照認證。

除了解釋執行Java位元組碼,大多數的JVM實現還包含一個JIT(just-in-time 即時)編譯器,用於為常用的方法生成機器碼。機器碼使用的是CPU的本地語言,相比位元組碼有著更快的執行速度。

雖然理解JVM不是開發或執行Java程式的必要條件,但是如果多瞭解一些JVM知識,那麼就有機會避免很多效能上的問題。理解了JVM,實際上這些問題會變得簡單明瞭。

體系結構

JVM規範定義了一系列子系統以及它們的外部行為。JVM主要有以下子系統:

  • Class Loader 類載入器。 用於讀入Java原始碼並將類載入到資料區。
  • Execution Engine 執行引擎。 執行來自資料區的指令。

資料區使用的是底層作業系統分配給JVM的記憶體。

類載入器(Class Loader)

JVM在下面幾種不同的層面使用不同的類載入器:

  • Bootstrap class loader(引導類載入器):是其他類載入器的父類,它用於載入Java核心庫,並且是唯一一個用原生程式碼編寫的類載入器。
  • extension class loader(擴充套件類載入器):是bootstrap class loader載入器的子類,用於載入擴充套件庫。
  • system class loader(系統類載入器):是extension class loader載入器的子類,用於載入在classpath中的應用程式的類檔案。
  • user-defined class loader(使用者定義的類載入器):是系統類載入器或其他使用者定義的類載入器的子類。

當一個類載入器收到一個載入類的請求,首先它會檢查快取,確認該類是否已經被載入,然後把請求代理給它的父類。如果父類沒能成功的載入類,那麼子類就會自己去嘗試載入該類。子類可檢查父類載入器的快取,但父類不能看到子類所載入的類。之所類載入體系會這樣設計,是認為一個子類不應該重複載入已經被父類載入過的類。

執行引擎(Execution Engine)

執行引擎一個接一個地執行被載入到資料區的位元組碼。為了保證位元組碼指令對於機器來說是可讀的,執行引擎使用下面兩個方法:

  • 解釋執行:執行引擎把它遇到的每一條指令解釋為機器語言。
  • 即時編譯:如果一條指令經常被使用,執行引擎會把它編譯為原生程式碼並儲存在快取中。這樣,所有和這個方法相關的程式碼都會直接執行,從而避免重複解釋。

儘管即時編譯比解釋執行要佔用更多的時間,但是對於需要使用成千上萬次的方法,只需要處理一次。相比每次都解釋執行,以原生程式碼的方式執行會節約很多執行時間。

JVM規範中並不規定一定要使用即時編譯。即時編譯也不是用於提高JVM效能的唯一的手段。規範僅僅規定了每條位元組碼對應的原生程式碼,至於執行引擎如何實現這一對應過程的,完全由JVM的具體實現來決定。

記憶體模型(Memory Model)

Java記憶體模型建立在自動記憶體管理的概念之上。當一個物件不再被一個應用所引用,垃圾回收器就會回收它,從而釋放相應的記憶體。這一點和其他很多需要自行釋放記憶體的語言有很大不同。

JVM從底層作業系統中分配記憶體,並將它們分為以下幾個區域:

  • 堆空間(Heap Space):這是共享的記憶體區域,用於儲存可以被垃圾回收器回收的物件。
  • 方法區(Method Area):這塊區域以前被稱作“永生代”(permanent generation),用於儲存被載入的類。這塊區域最近被JVM取消了。現在,被載入的類作為後設資料載入到底層作業系統的本地記憶體區。
  • 本地區(Native Area):這個區域用於儲存基本型別的引用和變數。

一個有效的管理記憶體方法是把對空間劃分為不同代,這樣垃圾回收器就不用掃描整個堆區。大多數的物件的生命週期都很段短暫,那些生命週期較長的物件往往直到應用退出才需要被清除。

當一個Java應用建立了一個物件,這個物件是被儲存到“初生池”(eden pool)。一旦初生池儲存滿了,就會在新生代觸發一次minor gc(小範圍的垃圾回收)。首先,垃圾回收器會標記出那些“死物件”(不再被應用所引用的物件),同時延長所有保留物件的生命週期(這個生命週期長度是用數字來描述,代表了期所經歷過的垃圾回收的次數)。然後,垃圾回收器會回收這些死物件,並把剩餘的活著的物件移動到“倖存池”(survivor pool),從而清空初生池。

當一個物件存活達到一定的週期後,它就會被移動到堆中的老生代:“終身代”(tenured pool)。最後,當終身代被填滿時,就會觸發一次full gc或major gc(完全的垃圾回收),以清理終身代。

(譯者注:一般我們把初生池和倖存池所在的區域合併成為新生代,把終身代所在的區域成為老生代。對應的,在新生代上產生的gc稱為minor gc,在老生代上產生的gc稱為full gc。希望這樣大家在其他地方看到對應的術語時能更好理解)

當垃圾回收(gc)執行的時候,所有應用執行緒都要被停止,系統產生一次暫停。minor gc非常頻繁,所以被優化的能夠快速的回收死物件,是新生代的記憶體的主要的回收方式。major gc執行起來就相對慢得多,因為要掃描非常多的活著的物件。垃圾回收器本身也有多種實現,有些垃圾回收器在一定情況下能更快的執行major gc。

堆的大小是動態的,只有堆需要擴張的時候才會從記憶體中分配。當堆被填滿時,JVM會重新給堆分配更多的記憶體,直到達到堆大小的上限,這種重新分配同樣會導致應用的短暫停止。

執行緒

JVM是執行在一個獨立的程式中的,但它可以併發執行多個執行緒,每個執行緒都執行自己的方法,這是Java必備的一個部分。以即時訊息客戶端這樣一個應用為例,它至少執行兩個執行緒。一個執行緒用於等待使用者輸入,另一個檢查服務端是否有新的訊息傳輸。再以服務端應用為例,有時一個請求可能要涉及多個執行緒併發執行,所以需要多執行緒來處理請求。

在JVM的程式中,所有的執行緒共享記憶體和其他可用的資源。每一個JVM程式在進入點(main方法)處都要啟動一個主執行緒,其他執行緒都從主執行緒啟動,成為執行過程中的一個獨立部分。執行緒可以再不同的處理器上並行執行,同樣也可以共享一個處理器,執行緒排程器負責處理多個執行緒共享一個處理器的情況。

很多應用(特別是服務端應用)會處理很多工,需要並行執行。這些任務中有些是非常重要的,需要實時執行的。而另外一些是後臺任務,可以在CPU空閒時執行。任務是在不同的執行緒中執行的。舉例子來說,服務端可能有一些低優先順序的執行緒,它們會根據一些資料來計算統計資訊。同時也會啟動一些高優先順序的程式用於處理傳入的資料,響應對這些統計資訊的請求。這裡可能有很多的源資料,很多來自客戶端的資料請求,每個請求都會使服務端短暫的停止後臺計算的執行緒以響應這個請求。所以,你必須監控在執行的執行緒數目並且保證有足夠的CPU時間來執行必要的計算。

(譯者注:這一段在原文中是在效能優化的章節,譯者認為這可能是作者的不小心,似乎放線上程的章節更合適。)

效能優化

JVM的效能取決於其配置是否與應用的功能相匹配。儘管垃圾回收器和記憶體回收程式是自動管理記憶體的,但是你必須掌管它們的頻率。通常來說,你的應用可使用的記憶體越多,那麼這些會導致應用暫停的記憶體管理程式需要起作用的就越少。

如果垃圾回收發生的頻率比你想的要多很多,那麼可以在啟動JVM的時候為其配置更大的最大堆大小值。堆被填滿的時間越久,就越能降低垃圾回收發生的頻率。最大堆大小值可以在啟動JVM的時候,用-Xmx引數來設定。預設的最大堆大小是被設定為可用的作業系統記憶體的四分之一,或者最小1GB。

如果問題出在經常重新分配記憶體,那麼你可以把初始化堆大小設定為和最大堆大小一樣。這就意味著JVM永遠不需要為堆重新分配記憶體。但這樣做就會失去動態堆大小適配的優化,堆的大小從一開始就被固定下來。配置初始化對大小是在啟動JVM,用-Xms來設定。預設初始化堆大小會被設定為作業系統可用的實體記憶體的六十四分之一,或者設定一個最小值。這個值是根據不同的平臺來確定的。

如果你清楚是哪種垃圾回收(minor gc或major gc)導致了效能問題,可以在不改變整個堆大小的情況下設定新生代和老生代的大小比例。對於需要產生大量臨時物件的應用,需要增大新生代的比例(當然,後果是減小了老生代的大小)。對於長生命週期物件較多的應用,則需增大老生代的比例(自然需要減少新生代的大小)。以下幾種方法可以用來設定新生代和老生代的大小:

  • 在啟動JVM時,使用-XX:NewRatio引數來具體指定新生代和老生代的大小比例。比如,如果想讓老生代的大小是新生代的五倍,則設定引數為-XX:NewRatio=5,預設這個引數設定為2(即老生代佔用堆空間的三分之二,新生代佔用三分之一)。
  • 在啟動JVM時,直接使用-Xmn引數設定初始化和最大新生代大小,那麼堆中的剩餘大小即是老生代的大小。
  • 在啟動JVM時,直接使用-XX:NewSize-XX:MaxNewSize引數設定初始化和最大新生代大小,那麼堆中的剩餘大小即是老生代的大小。

每一個執行緒都有一個棧,用於儲存函式呼叫、返回地址等等,這些棧有著對應的記憶體分配。如果執行緒過多,就會導致OutOfMemory錯誤。即使你有足夠的空間的堆來存放物件,你的應用也可能會因為建立一個新的執行緒而崩潰。這種情況下,需要考慮限制執行緒中的棧大小的最大值。執行緒棧大小可以在JVM啟動的時候,通過-Xss引數來設定,預設這個值被設定為320KB至1024KB之間,這和平臺相關。

效能監控

當開發或執行一個Java應用的時候,對JVM的效能進行監控是很重要的。配置JVM不是一次配置就萬事大吉的,特別是你要應對的是Java伺服器應用的情況。你必須持續的檢查堆記憶體和非堆記憶體的分配和使用情況,執行緒數的建立情況和記憶體中載入的類的資料情況等。這些都是核心引數。

使用Anturis控制檯,你可以為任何的硬體元件上執行的JVM配置監控(例如,在一臺電腦上執行的一個Tomcat網頁伺服器)。

JVM監控可以使用以下衡量標準:

  • 總記憶體使用情況(MB):即JVM使用的總記憶體。如果JVM使用了所有可用記憶體,這項指標可以衡量底層作業系統的整體效能。
  • 堆記憶體使用(MB):即JVM為執行的Java應用所使用的物件分配的所有記憶體。不使用的物件通常會被垃圾回收器從堆中移除。所以,如果這個指數增大,表示你的應用沒有把不使用的物件移除或者你需要更好的配置垃圾回收器的引數。
  • 非堆記憶體的使用(MB):即為方法區和程式碼快取分配的所有記憶體。方法區是用於儲存被載入的類的引用,如果這些引用沒有被適當的清理,永生代池會在每次應用被重新部署的時候都會增大,導致非堆的記憶體洩露。這個指標也可能指示了執行緒建立的洩露。
  • 池內總記憶體(MB):即JVM所分配的所有變數記憶體池的記憶體和(即除了程式碼快取區外的所有記憶體和)。這個指標能夠讓你明確你的應用在JVM過載前所能使用的總記憶體。
  • 執行緒:即所有有效執行緒數。舉個例子,在Tomcat伺服器中每個請求都是一個獨立的執行緒來處理,所以這個衡量指標可以表示當前有多少個請求數,是否影響到了後臺低許可權的執行緒的執行。
  • 類:即所有被載入的類的總數。如果你的應用動態的建立很多類,這可能是伺服器記憶體洩露的一個原因。

相關文章