Lambda初次使用很慢?從JIT到類加載再到實現原理

問題回顧

描述的話不多說,直接上圖:

看到輸出結果了嗎?爲什麼第一次和第二次的時間相差如此之多?咱們一起琢磨琢磨,也可以先去看看結論再回過頭看分析

注:並非僅第二次快,而是除了第一次,之後的每一次都很快

給與猜想

  1. 是否和操作系統預熱有關?
  2. 是否和JIT(即時編譯)有關?
  3. 是否和ClassLoader類加載有關?
  4. 是否和Lambda有關,並非foreach的問題

驗證猜想

操作系統預熱

操作系統預熱這個概念是我諮詢一位大佬得到的結論,在百度 / Google 中並未搜索到相應的詞彙,但是在模擬測試中,我用 普通遍歷 的方式進行測試:

基本上每次都是前幾次速度較慢,後面的速度更快,因此 可能 有這個因素影響,但差距並不會很大,因此該結論並不能作爲問題的答案。

JIT 即時編譯

首先介紹一下什麼是JIT即時編譯:

當 JVM 的初始化完成後,類在調用執行過程中,執行引擎會把字節碼轉爲機器碼,然後在操作系統中才能執行。在字節碼轉換爲機器碼的過程中,虛擬機中還存在着一道編譯,那就是即時編譯

最初,JVM 中的字節碼是由解釋器( Interpreter )完成編譯的,當虛擬機發現某個方法或代碼塊的運行特別頻繁的時候,就會把這些代碼認定爲熱點代碼

爲了提高熱點代碼的執行效率,在運行時,即時編譯器(JIT,Just In Time)會把這些代碼編譯成與本地平臺相關的機器碼,並進行各層次的優化,然後保存到內存中

再來一個概念,回邊計數器

回邊計數器用於統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向後跳轉的指令稱爲 "回邊"(Back Edge)

建立回邊計數器的主要目的是爲了觸發 OSR(On StackReplacement)編譯,即棧上編譯,在一些循環週期比較長的代碼段中,當循環達到回邊計數器閾值時,JVM 會認爲這段是熱點代碼,JIT 編譯器就會將這段代碼編譯成機器語言並緩存,在該循環時間段內,會直接將執行代碼替換,執行緩存的機器語言

從上述的概念中,我們應該可以得到一個結論:第一條所謂的操作系統預熱 大概率不正確,因爲普通遍歷方法執行N次,後續執行的時間佔用比較小,很可能是因爲JIT導致的。

那 JIT即時編譯 是否是最終的答案?我們想辦法把 JIT 關掉來測試一下,通過查詢資料發現瞭如下內容:

Procedure

  • Use the -D option on the JVM command line to set the java.compiler property to NONE or the empty string.

    Type the following command at a shell or command prompt:

java -Djava.compiler=NONE <class>

注:該段內容來自IBM官方資料,地址見 <收穫> ,咱們先不要停止思考

通過配置 IDEA JVM 參數:

執行問題中的代碼測試結果如下:

# 禁用前
foreach time one: 38
分割線...
foreach time two: 1

# 禁用後
foreach time one: 40
分割線...
foreach time two: 5

我測試了很多次,結果都很相近,因此得到可以得到另一個結論:JIT並非引發該問題的原因(但是它的確能提高執行效率)

難道和類加載有關?

在進行類加載驗證時,我依然無法放棄 JIT ,因此查閱了很多資料,知道了某個命令可以查看 JIT編譯的耗時情況,命令如下:

java -XX:+CITime com.code.jvm.preheat.Demo >> log.txt

解釋一下命令的意思

# 執行的目錄
C:\Users\Kerwin\Desktop\Codes\Kerwin-Code-Study\target\classes>

# 命令含義:Prints time spent in JIT Compiler. (Introduced in 1.4.0.)
# 打印JIT編譯所消耗的時間
-XX:+CITime

# 代表我指定的類
com.code.jvm.preheat.Demo

# 輸出至log.txt文件,方便觀看
>> log.txt

展示一下某一次結果的全部內容:

foreach time one: 35
分割線...
foreach time two: 1

Accumulated compiler times (for compiled methods only)
------------------------------------------------
  Total compilation time   :  0.044 s
    Standard compilation   :  0.041 s, Average : 0.000
    On stack replacement   :  0.003 s, Average : 0.002
    Detailed C1 Timings
       Setup time:         0.000 s ( 0.0%)
       Build IR:           0.010 s (38.8%)
         Optimize:            0.001 s ( 2.3%)
         RCE:                 0.000 s ( 0.7%)
       Emit LIR:           0.010 s (40.7%)
         LIR Gen:           0.002 s ( 9.3%)
         Linear Scan:       0.008 s (31.0%)
       LIR Schedule:       0.000 s ( 0.0%)
       Code Emission:      0.003 s (12.4%)
       Code Installation:  0.002 s ( 8.2%)
       Instruction Nodes:   9170 nodes

  Total compiled methods   :    162 methods
    Standard compilation   :    160 methods
    On stack replacement   :      2 methods
  Total compiled bytecodes :  13885 bytes
    Standard compilation   :  13539 bytes
    On stack replacement   :    346 bytes
  Average compilation speed: 312157 bytes/s

  nmethod code size        : 168352 bytes
  nmethod total size       : 276856 bytes

分別測試的結果如下:

類型 Total compilation time(JIT編譯總耗時)
兩次 foreach(lambda) 循環 0.044 s
兩次 foreach (普通)循環 0.016 s
兩次 增強for循環 0.015 s
一次 foreach(lambda) 一次增強for循環 0.046 s

通過上述測試結果,反正更加說明了一個問題:只要有 Lambda 參與的程序,編譯時間總會長一些

再次通過查詢資料,瞭解了新的命令

java -verbose:class -verbose:jni -verbose:gc -XX:+PrintCompilation com.code.jvm.preheat.Demo

解釋一下命令的意思

# 輸出jvm載入類的相關信息
-verbose:class

# 輸出native方法調用的相關情況
-verbose:jni

# 輸出每次GC的相關情況
-verbose:gc

# 當一個方法被編譯時打印相關信息
-XX:+PrintCompilation

對包含Lambda和不包含的分別執行命令,得到的結果如下:

從日誌文件大小來看,就相差了十幾kb

注:文件過大,僅展示部分內容

# 包含Lambda
[Loaded java.lang.invoke.LambdaMetafactory from D:\JDK\jre1.8\lib\rt.jar]

# 中間省略了很多內容,LambdaMetafactory 是最明顯的區別(僅從名字上發現)

[Loaded java.lang.invoke.InnerClassLambdaMetafactory$1 from D:\JDK\jre1.8\lib\rt.jar]
   5143  220       4       java.lang.String::equals (81 bytes)
[Loaded java.lang.invoke.LambdaForm$MH/471910020 from java.lang.invoke.LambdaForm]
   5143  219       3       jdk.internal.org.objectweb.asm.ByteVector::<init> (13 bytes)
[Loaded java.lang.invoke.LambdaForm$MH/531885035 from java.lang.invoke.LambdaForm]
   5143  222       3       jdk.internal.org.objectweb.asm.ByteVector::putInt (74 bytes)
   5143  224       3       com.code.jvm.preheat.Demo$$Lambda$1/834600351::accept (8 bytes)
   5143  225       3       com.code.jvm.preheat.Demo::lambda$getTime$0 (6 bytes)
   5144  226       4       com.code.jvm.preheat.Demo$$Lambda$1/834600351::accept (8 bytes)
   5144  223       1       java.lang.Integer::intValue (5 bytes)
   5144  221       3       jdk.internal.org.objectweb.asm.ByteVector::putByteArray (49 bytes)
   5144  224       3       com.code.jvm.preheat.Demo$$Lambda$1/834600351::accept (8 bytes)   made not entrant
   5145  227 %     4       java.util.ArrayList::forEach @ 27 (75 bytes)
   5146    3       3       java.lang.String::equals (81 bytes)   made not entrant
foreach time one: 50
分割線...
   5147  227 %     4       java.util.ArrayList::forEach @ -2 (75 bytes)   made not entrant
foreach time two: 1
[Loaded java.lang.Shutdown from D:\JDK\jre1.8\lib\rt.jar]
[Loaded java.lang.Shutdown$Lock from D:\JDK\jre1.8\lib\rt.jar]



# 不包含Lambda
   5095   45       1       java.util.ArrayList::access$100 (5 bytes)
   5095   46       1       java.lang.Integer::intValue (5 bytes)
   5096   47       3       java.util.ArrayList$Itr::hasNext (20 bytes)
   5096   49       3       java.util.ArrayList$Itr::checkForComodification (23 bytes)
   5096   48       3       java.util.ArrayList$Itr::next (66 bytes)
   5096   50       4       java.util.ArrayList$Itr::hasNext (20 bytes)
   5096   51       4       java.util.ArrayList$Itr::checkForComodification (23 bytes)
   5096   52       4       java.util.ArrayList$Itr::next (66 bytes)
   5097   47       3       java.util.ArrayList$Itr::hasNext (20 bytes)   made not entrant
   5097   49       3       java.util.ArrayList$Itr::checkForComodification (23 bytes)   made not entrant
   5097   48       3       java.util.ArrayList$Itr::next (66 bytes)   made not entrant
   5099   53 %     4       com.code.jvm.preheat.Demo::getTimeFor @ 11 (47 bytes)
   5101   50       4       java.util.ArrayList$Itr::hasNext (20 bytes)   made not entrant
foreach time one: 7
分割線...
   5102   54       3       java.util.ArrayList$Itr::hasNext (20 bytes)
   5102   55       4       java.util.ArrayList$Itr::hasNext (20 bytes)
   5103   53 %     4       com.code.jvm.preheat.Demo::getTimeFor @ -2 (47 bytes)   made not entrant
foreach time two: 1
   5103   54       3       java.util.ArrayList$Itr::hasNext (20 bytes)   made not entrant
[Loaded java.lang.Shutdown from D:\JDK\jre1.8\lib\rt.jar]
[Loaded java.lang.Shutdown$Lock from D:\JDK\jre1.8\lib\rt.jar]

我們可以結合JIT編譯時間,結合JVM載入類的日誌發現兩個結論:

  1. 凡是使用了Lambda,JVM會額外加載 LambdaMetafactory類,且耗時較長
  2. 第二次調用Lambda方法時,JVM就不再需要額外加載 LambdaMetafactory類,因此執行較快

完美印證了之前提出的問題:爲什麼第一次 foreach 慢,以後都很快,但這就是真相嗎?我們繼續往下看

排除 foreach 的干擾

先來看看 ArrayListforeach方法的實現:

@Override
public void forEach(Consumer<? super E> action) {
    Objects.requireNonNull(action);
    final int expectedModCount = modCount;
    @SuppressWarnings("unchecked")
    final E[] elementData = (E[]) this.elementData;
    final int size = this.size;
    for (int i=0; modCount == expectedModCount && i < size; i++) {
        action.accept(elementData[i]);
    }
    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}

乍一看,好像也沒什麼特別,我們來試試把 Consumer 預先定義好,代碼如下:

可以發現速度很快,檢查 JIT編譯時間,檢查類加載情況,發現耗時短,且無LambdaMetafactory加載

根據剛纔得到的結論,我們試試把 ConsumerLambda的方式定義一下

Consumer consumer = o -> {
    int curr = (int) o;
};

# 執行結果耗時
foreach time: 3

再來看看編譯時間和類加載,赫然發現:JIT編譯時間較長,且有LambdaMetafactory加載

重新探究Lambda的實現原理

Lambda表達式實現原理的細節,我之後會再出一篇新的文章,今天就先說一下結論:

  • 匿名內部類在編譯階段會多出一個類,而Lambda不會,它僅會多生成一個函數
  • 該函數會在運行階段,會通過LambdaMetafactory工廠來生成一個class,進行後續的調用

爲什麼Lamdba要如此實現?

匿名內部類有一定的缺陷:

  1. 編譯器爲每個匿名內部類生成一個新的類文件,生成許多類文件是不可取的,因爲每個類文件在使用之前都需要加載和驗證,這會影響應用程序的啓動性能,加載可能是一個昂貴的操作,包括磁盤I/O和解壓縮JAR文件本身。
  2. 如果lambdas被轉換爲匿名內部類,那麼每個lambda都有一個新的類文件。由於每個匿名內部類都將被加載,它將佔用JVM的元空間,如果JVM將每個此類匿名內部類中的代碼編譯爲機器碼,那麼它將存儲在代碼緩存中。
  3. 此外,這些匿名內部類將被實例化爲單獨的對象。因此,匿名內部類會增加應用程序的內存消耗。
  4. 最重要的是,從一開始就選擇使用匿名內部類來實現lambdas,這將限制未來lambda實現更改的範圍,以及它們根據未來JVM改進而演進的能力。

內容參考:https://www.infoq.com/articles/Java-8-Lambdas-A-Peek-Under-the-Hood/

真相

在理解了匿名內部類以及Lambda表達式的實現原理後,對Lambda耗時長的原因反而更懵逼,畢竟匿名內部類的生成一個新類和Lambda生成一個新方法所耗時間差別不會太多,然後運行期間同樣有Class產生,耗時也不應該有太大的區別,到底哪裏出現了問題呢?

再次通過查詢資料,最終找到了答案:

You are obviously encountering the first-time initialization overhead of lambda expressions. As already mentioned in the comments, the classes for lambda expressions are generated at runtime rather than being loaded from your class path.

However, being generated isn’t the cause for the slowdown. After all, generating a class having a simple structure can be even faster than loading the same bytes from an external source. And the inner class has to be loaded too. But when the application hasn’t used lambda expressions before, even the framework for generating the lambda classes has to be loaded (Oracle’s current implementation uses ASM under the hood). This is the actual cause of the slowdown, loading and initialization of a dozen internally used classes, not the lambda expression itself.

大概翻譯過來如下:

顯然,您遇到了lambda表達式的首次初始化開銷。正如註釋中已經提到的,lambda表達式的類是在運行時生成的,而不是從類路徑加載的。

然而,生成類並不是速度變慢的原因。畢竟,生成一個結構簡單的類比從外部源加載相同的字節還要快。內部類也必須加載。但是,當應用程序以前沒有使用lambda表達式時,甚至必須加載用於生成lambda類的框架(Oracle當前的實現在幕後使用ASM)。這是導致十幾個內部使用的類(而不是lambda表達式本身)減速、加載和初始化的真正原因。

真相:應用程序初次使用Lambda時,必須加載用於生成Lambda類的框架,因此需要更多的編譯,加載的時間

回過頭去看看類加載的日誌,赫然發現了ASM框架的引入:

[Loaded jdk.internal.org.objectweb.asm.ClassVisitor from F:\Java_JDK\JDK1.8\jre\lib\rt.jar]
[Loaded jdk.internal.org.objectweb.asm.ClassWriter from F:\Java_JDK\JDK1.8\jre\lib\rt.jar]
[Loaded jdk.internal.org.objectweb.asm.ByteVector from F:\Java_JDK\JDK1.8\jre\lib\rt.jar]
[Loaded jdk.internal.org.objectweb.asm.Item from F:\Java_JDK\JDK1.8\jre\lib\rt.jar]
[Loaded jdk.internal.org.objectweb.asm.MethodVisitor from F:\Java_JDK\JDK1.8\jre\lib\rt.jar]
[Loaded jdk.internal.org.objectweb.asm.MethodWriter from F:\Java_JDK\JDK1.8\jre\lib\rt.jar]
[Loaded jdk.internal.org.objectweb.asm.Type from F:\Java_JDK\JDK1.8\jre\lib\rt.jar]
[Loaded jdk.internal.org.objectweb.asm.Label from F:\Java_JDK\JDK1.8\jre\lib\rt.jar]
[Loaded jdk.internal.org.objectweb.asm.Frame from F:\Java_JDK\JDK1.8\jre\lib\rt.jar]
[Loaded jdk.internal.org.objectweb.asm.AnnotationVisitor from F:\Java_JDK\JDK1.8\jre\lib\rt.jar]
[Loaded jdk.internal.org.objectweb.asm.AnnotationWriter from F:\Java_JDK\JDK1.8\jre\lib\rt.jar]

結論

  • 導致 foreach 測試時數據不正常的罪魁禍首是:Lambda表達式
  • Lambda表達式 在應用程序中首次使用時,需要額外加載ASM框架,因此需要更多的編譯,加載的時間
  • Lambda表達式的底層實現並非匿名內部類的語法糖,而是其優化版
  • foreach 的底層實現其實和增強 for循環沒有本質區別,一個是外部迭代器,一個是內部迭代器而已
  • 通過 foreach + Lambda 的寫法,效率並不低,只不過需要提前進行預熱(加載框架)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章