讀古今文學網 > Java程序員修煉之道 > 6.6 HotSpot的JIT編譯 >

6.6 HotSpot的JIT編譯

正如我們在第1章所講,Java是一種「動態編譯」語言。也就是說在程序運行時,其中的類還會再進行一次編譯,然後轉換成機器碼。

這個過程稱為即時編譯或JITing,並且通常是一次處理一個方法。要在龐大的代碼庫中找出其中的重要部分,理解這個過程是關鍵。

下面是一些與JIT編譯有關的基本事實。

  • 幾乎所有現代JVM中都有某種JIT編譯器。
  • 相比較而言,純粹解釋型的VM要慢得多。
  • 編譯過的方法在運行速度上要比解釋型的代碼快很多,非常多。
  • 先編譯用得最多的方法,這是有道理的。
  • 在做JIT編譯時,先處理唾手可得的編譯很重要。

按照最後一點,我們應該先研究編譯過的代碼,因為在正常情況下,所有仍然處於解釋狀態下的方法都沒有已經編譯過的方法運行頻繁。偶爾會有無法編譯的方法,但非常罕見。

方法一開始都是以字節碼形態存在的,有調用時JVM只會對字節碼進行解釋並執行,同時記錄方法被調用的次數及其他一些統計數據。當被調用次數達到某個閾值(默認10 000次)後,如果它是合格的方法,就會有個JVM線程在後台把它的字節碼編譯成機器碼。如果編譯成功,以後所有對該方法的調用都會用它的編譯結果,除非出現了某些導致檢驗失效的情況,或者出現了逆優化1。

1 JVM的動態優化技術可能會基於一些大膽(甚至不安全)的假設來編譯字節碼。比如假定要處理的數據都屬於某一類,而在編譯結果中只保留處理該類數據的程序分支。如果假設不成立,則JVM只能放棄編譯結果,回去解釋並執行原來的字節碼,這一過程被稱為逆優化。——譯者注

根據實際情況,方法編譯後產生的機器碼運行速度可能比解釋模式下的字節碼快100倍。改善性能通常都要先弄明白程序中哪些方法比較重要,以及哪些重要的方法被編譯了。

為什麼要動態編譯?

有時人們會問,Java平台為什麼要費心去做動態編譯——為什麼不提前編譯好(像C++一樣)。第一個答案通常都是:因為用平台無關的東西(.jar和.class文件)作為基本部署單位要比為每個目標平台做一份不同的編譯好的二進制文件更輕鬆。

另外一種答案是動態編譯會給編譯器提供更多信息。具體地說,提前(AOT)編譯的語言得不到運行時的任何信息——比如某個指令是否可用,其他的硬件細節以及代碼運行情況的統計數據。這些變數讓事情變得很有趣,使得Java這樣的動態編譯語言實際上可能會比提前編譯的語言運行得更快。

在接下來對JITing機制的討論中,我們所說的JVM特指HotSpot。後續討論中很多通用內容也適用於其他VM,但在具體細節上可能會有很大出入。

我們會先介紹一下HotSpot提供的幾個JIT編譯器,然後解釋HotSpot中最有力的兩項優化技術(內聯和獨佔派發)。在本節的結尾,我們會告訴你如何打開方法編譯日誌,以便你可以看到被編譯的確切方法。下面有請HotSpot。

6.6.1 介紹HotSpot

Oracle收購Sun時拿到了HotSpot VM(原來收購BEA時還拿到一個JRockit)。HotSpot是OpenJDK的基礎。它有兩種運行模式——客戶端模式和服務器端模式。可以在啟動JVM時指定-client-server選項來選擇不同的模式。(必須是命令行中的第一個選項。)每種模式都有各自適用的應用程序。

1.客戶端編譯器

客戶端編譯器主要用於GUI應用程序。在這個領域中,操作的一致性至關重要,所以客戶端編譯器(有時叫C1)在編譯時所做的決定往往更保守。也就是說它不能因為要取消一個經證實不正確或基於錯誤假設的優化決定而意外暫停。

2.服務器端編譯器

相反,服務器端編譯器(C2)在編譯時會大膽假設。為了確保代碼正確運行,C2會快速地做一次運行時檢查(通常被稱為警戒條件),以確保假設有效。如果假設無效,它會取消這次編譯,並嘗試別的編譯。這種大膽假設的方式比保守的客戶端編譯器產生的編譯結果性能好很多。

3,.實時Java

近年來出現了一種實時Java平台,有些開發人員好奇為什麼那些需要表現出高性能的代碼不直接用這個平台(它是獨立的JVM,不是HotSpot選件)。那是因為實時系統不一定是最快的。

實時編程的關注點實際上是承諾能否兌現。從統計角度講,實時系統是為了讓執行操作的時間盡量保持一致,並且為了達成這個目的,它可能會犧牲一些平均等待時間。為了讓運行狀況保持一致,整體性能是可以受到輕微影響的。

圖6-11中有兩組代表等待時間的點陣。系列2(上面那組點陣)的平均等待時間在增長(因為它的等待時間刻度更高),但方差在減小,因為這些點比系列1中的點更靠近自己的平均值,系列1的點陣相較而言分佈更加廣泛。

圖6-11 方差和均值的變化

但希望實現高性能表現的團隊想要的是更低的平均等待時間,即便以更高的方差為代價,所以他們通常會選擇服務器端編譯器的大膽優化策略(對應系列1)。

接下來我們會討論所有運行時(服務器端、客戶端和實時)廣泛採用的技術,這項技術使它們表現得更好。

6.6.2 內聯方法

內聯是HotSpot的最大賣點之一。內聯的方法不再是被調用,而是將調用方法的代碼直接放到調用者內部。

平台有這方面的優勢,編譯器可以根據運行時的統計數據(方法的調用頻率)和其他因素(比如會不會因為調用者方法太多而對代碼緩存產生影響)來決定如何處理內聯。也就是說HotSpot編譯器所做的內聯決策比提前編譯的編譯器更智能。

方法的內聯是完全自動的,並且默認參數值幾乎適用於任何情況。但也有選項可以用來控制內聯方法大小,以及方法在成為內聯候選之前的調用頻率要達到多高。對於好奇的程序員來說,這些選項對於深入瞭解內聯如何工作很有幫助。通常它們對於生產環境下的代碼用處不大,並且應該作為性能調優的最後選擇,因為它們對運行時系統的性能可能存在不可預測的影響。

訪問器方法怎麼處理?

有些開發人員錯誤地認為訪問器方法(訪問私有變量的公共方法)不能由HotSpot內聯。他們認為變量是私有的,方法調用不能因為優化而去掉,不能在類外訪問這個變量。這種想法不對。HotSpot把方法編譯成機器碼時能夠並且會忽略訪問控制,不用訪問器方法直接訪問私有域。這並不違背Java的安全模型,因為所有訪問控制都在類加載和連接階段檢查過。

如果你還不信,可以做個練習,寫一個跟代碼清單6-2類似的測試類,對比一下預熱過的訪問器方法的速度和直接訪問公共域的速度。

6.6.3 動態編譯和獨佔調用

獨佔調用就是這種大膽優化的例子之一。它是基於大量觀察做出的優化,像下面這種對像上的方法調用:

MyActualClassNotInterface obj = getInstance;
obj.callMyMethod;
  

只會在一種類型的對象上調用。換句話說,就是調用點obj.callMyMethod幾乎不會同時碰到一個類和它的子類。這時可以把Java方法查找替換為callMyMethod編譯結果的直接調用。

提示 獨佔派發提供了一個剖析JVM運行時的例子,允許Java平台進行C++這種AOT語言實現不了的優化。

出於非技術的原因,getInstance方法有時不能返回MyActualClassNotInterface類型的對象,而其他情況下不能返回一些子類的對象,但實際上這種情況幾乎從沒發生過。但為了防止這種情況出現,會有一個運行時檢查來確保對象的類型是由編譯器按預期插入的。如果這個預期被違背,運行時會取消優化,程序甚至都不會注意也不會犯任何錯誤。

只有服務器端編譯器才會做這種大膽的優化。實時和客戶端編譯器都不會這樣做。

6.6.4 讀懂編譯日誌

我們來看一個例子,瞭解一下如何使用JIT編譯日誌。依巴谷星表中詳細列出了從地球上可以觀測到的星星。我們的程序會處理這個目錄,產生能在指定夜晚、指定地址看到的星圖。

我們來看這個程序輸出的一些日誌,看看在星圖應用運行時編譯了哪些方法。我們用的關鍵選項是-XX:+Print Compilation。我們前面簡單討論過這個擴展選項。把這個選項加到啟動JVM的命令裡是告訴JIT編譯線程輸出標準日誌。這些日誌表明方法超過編譯閾值並被轉成機器碼的時間。

1        java.lang.String::hashCode (64 bytes)
2        java.math.BigInteger::mulAdd (81 bytes)
3        java.math.BigInteger::multiplyToLen (219 bytes)
4        java.math.BigInteger::addOne (77 bytes)
5        java.math.BigInteger::squareToLen (172 bytes)
6        java.math.BigInteger::primitiveLeftShift (79 bytes)
7        java.math.BigInteger::montReduce (99 bytes)
8        sun.security.provider.SHA::implCompress (491 bytes)
9        java.lang.String::charAt (33 bytes)
1% !     sun.nio.cs.SingleByteDecoder::decodeArrayLoop @ 129 (308 bytes)
...
39       sun.misc.FloatingDecimal::doubleValue (1289 bytes)
40       org.camelot.hipparcos.DelimitedLine::getNextString (5 bytes)
41 !     org.camelot.hipparcos.Star::parseStar (301 bytes)
...
2% !     org.camelot.CamelotStarter::populateStarStore @ 25 (106 bytes)
65 s     java.lang.StringBuffer::append (8 bytes)
 

這是非常典型的PrintCompilation輸出。這些日誌表明了「熱」到可以編譯的方法。跟你想的一樣,第一個被編譯的方法很可能是平台方法(比如String#hashCode)。再過一段時間,應用方法(比如org.camelot.hipparcos.Star#parseStar方法,在例子中用於分析天文目錄裡的記錄)也會被編譯。

這些輸出中每行都有個數字,表明了這些方法在這次運行中的編譯順序。注意,由於平台的動態性質,這個順序在每次運行時可能會稍有變化。這裡還有一些其他域。

  • s——表明該方法是同步的。
  • !——表明方法有異常處理。
  • %——當前棧替換(OSR)。這個方法被編譯了,並且換掉了運行代碼中的解釋型版本。注意,OSR方法有它們自己的計數方案,從1開始。