深刻剖析Java即時編譯器(下)

本文會介紹分層編譯的機制,而後介紹即時編譯器對應用啓動性能的影響。java

本文內容基於HotSpot虛擬機,設計Java版本的地方會在文中說明。web

0 分層編譯概述

在引入分層編譯以前,咱們須要手動的選擇編譯器。對於啓動性能有要求的短時運行程序,咱們會選擇C1編譯器,對應參數-client,對於長時間運行的對峯值性能有要求的程序,咱們會選擇C2編譯器,對應參數-server。微信

Java7引入了分層編譯,使用-XX:+TieredCompilation參數開啓,它綜合了C1的啓動性能優點和C2的峯值性能優點。app

在Java8中默認開啓了分層編譯,在Java8中,不管是開啓仍是關閉了分層編譯,-cilent和-server參數都是無效的了。當關閉分層編譯的狀況下,JVM會直接使用C2。dom

分層編譯將JVM中代碼的執行狀態分爲了5個層次,五個層次分別是:oop

  • 0 - 解釋執行
  • 1 - 執行不帶profiling的C1代碼
  • 2 - 執行僅帶方法調用次數和循環回邊次數profiling的C1代碼
  • 3 - 執行帶全部profiling的C1代碼
  • 4 - 執行C2代碼

(profiling是指在程序執行過程當中收集的程序執行狀態數據,例如在上篇中提到的方法調用次數和循環回邊次數)性能

這幾個層次的代碼執行效率由高到低排序以下:4 > 1 > 2 > 3 > 0測試

其中,1 > 2 > 3的緣由在於profiling越多,其性能開銷也越大。優化

下圖顯示了幾種可能的編譯執行路徑。ui

編譯執行路徑

第一條執行路徑,指的是在一般狀況下,熱點方法會被3層的C1編譯,而後被4層的C2編譯。

第二條執行路徑,指的是字節碼方法較少的狀況下,如getter和setter,此時沒有什麼可收集的profiling,就會在3層編譯後,直接交給1層來編譯。

第三條執行路徑,指的是C1繁忙時,JVM會在解釋執行時收集profiling,而後直接有4層的C2編譯。

第四條執行路徑,指的是C2繁忙時,先由2層的C1編譯再由3層的C1編譯,這樣能夠減小方法在3層的執行時間,最終再交給C2執行。

1 分層編譯實戰

1.1 分層編譯的觸發

本小結說明了在開啓分層編譯的狀況下,上述的五個層次的編譯分別在什麼時機觸發。

在上篇的第二小節,介紹了在不開啓分層編譯的狀況下,觸發即時編譯的時機與-XX:CompileThreshold參數有關(具體可參考上篇)。

在開啓分層編譯的狀況下,這個參數設定的閾值將失效,取而代之的是另外一種計算閾值的方案,這個閾值是動態調整的(會乘一個係數s),當方法調用次數和循環回邊次數知足下述兩個公式的任意一個時,將會觸發第X層的即時編譯({X}表示第X層)。

method_invoke_number > Tier{X}InvocationThreshold * s
or
method_invoke_number > Tier{X}MinInvocationThreshold * s 且 method_invoke_number + loop_number > Tier{X}CompileThreshold * s

說明:
* method_invoke_number:方法調用次數
* loop_number:循環回邊次數
* Tier{X}InvocationThreshold:由JMV參數指定,X可取34,第3層的默認值爲200,第4層的默認值爲15000
* s:動態調整的係數(接下來會說明它的計算方式)
* Tier{X}MinInvocationThreshold:JVM設定的參數,X可取34,第3層的默認值爲100,第4層的默認值爲600
* Tier{X}CompileThreshold:JVM設定的參數,X可取234,第2層的默認值爲0,第3層的默認值爲2000,第4層的默認值爲15000
複製代碼

PS:在【附加】中提供了查看JVM參數默認值的方式

係數s的計算方式:

s = compiler_method_number_{X} / (Tier{X}LoadFeedback * compiler_thread_number_{X}) + 1
* compiler_method_number_{X}:第X層待編譯方法的數目
* Tier{X}LoadFeedback:JVM參數,X可取34,第3層的默認值爲5,第4層的默認值爲3
* compiler_thread_number_{X}:第X層編譯線程數目
複製代碼

compiler_thread_number_{X}的計算方式爲:

在64位的JVM中,默認狀況下編譯線程的總數目thread_total是根據CPU的數量來調整的,thread_total的計算方式以下所示,JVM會把這些線程按照1:2的比例分配給C1和C2。

thread_total = log2(N) * log2(log2(N)) * 3 / 2
* N爲CPU核心數
例如一個4核的機器,總的編譯線程數目thread_total = 3,那麼會給C1分配1個線程,C2分配2個線程
複製代碼

由此能夠計算出,JVM默認配置狀況下,4核CPU,第三層觸發C1即時編譯的閾值爲:

假設第3層有10000個待編譯的方法,係數s = 10000 / (5 * 1) + 1 = 2001

那麼

method_invoke_number > 200 * s = 200 * 2001 = 400200

也就是方法調用次數超過400200次的時候觸發第3層的C1即時編譯。

或者

method_invoke_number > 100 * s = 100 * 2001 = 200100 且 method_invoke_number + loop_number > 2000 * s = 2000 * 2001 = 4002000

即:方法調用次數>200100 而且 方法調用次數+循環回邊次數>4002000次時,觸發3層的C1即時編譯。

同理能夠計算出第4層C2的即時編譯閾值:

method_invoke_number > 30015000時

或者

method_invoke_number > 1200600 且 method_invoke_number + loop_number > 30015000時

會觸發第4層的C2即時編譯。

1.2 分層編譯日誌

以上篇的一段代碼爲例,說明分層編譯的日誌。

/** * 添加JVM參數: -XX:+PrintCompilation ,打印編譯日誌 */
public class JITDemo2 {

    private static Random random = new Random();

    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        int count = 0;
        int i = 0;
        while (i++ < 15000) {
            count += plus();
        }
    }

    // 調用時,編譯器計數器+1
    private static int plus() {
        int count = 0;
        // 每次循環,編譯器計數器+1
        for (int i = 0; i < 10; i++) {
            count += random.nextInt(10);
        }
        return random.nextInt(10);
    }
}
複製代碼

執行結果以下:

176    1       3       java.util.Arrays::copyOf (19 bytes)
    176    6       3       java.io.ExpiringCache::entryFor (57 bytes)
    177    7       3       java.util.LinkedHashMap::get (33 bytes)
    177    8       2       java.lang.String::hashCode (55 bytes)
    177    9       3       java.lang.String::equals (81 bytes)
    178   10       2       java.lang.CharacterData::of (120 bytes)
    178   11       2       java.lang.CharacterDataLatin1::getProperties (11 bytes)
    179   12       3       java.lang.String::<init> (82 bytes)
    179   17     n 0       java.lang.System::arraycopy (native)   (static)
    179   13       2       java.lang.String::indexOf (70 bytes)
    179    3       4       java.lang.Object::<init> (1 bytes)
    179    2       4       java.lang.AbstractStringBuilder::ensureCapacityInternal (27 bytes)
    179    5       4       java.lang.String::length (6 bytes)
    179   15       3       java.lang.Math::min (11 bytes)
    180   14       3       java.util.Arrays::copyOfRange (63 bytes)
    180    4       4       java.lang.String::charAt (29 bytes)
    180   16       3       java.lang.String::indexOf (7 bytes)
    180   18       3       java.util.HashMap::hash (20 bytes)
    180   19       3       java.lang.String::substring (79 bytes)
    181   20       4       java.util.TreeMap::parentOf (13 bytes)
    181   21       3       java.lang.Character::toUpperCase (6 bytes)
    181   22       3       java.lang.Character::toUpperCase (9 bytes)
    181   23       3       java.lang.CharacterDataLatin1::toUpperCase (53 bytes)
    181   24       3       java.lang.String::getChars (62 bytes)
    182   25       3       java.io.File::isInvalid (47 bytes)
    184   26       3       java.lang.String::startsWith (7 bytes)
    184   28       3       sun.nio.cs.UTF_8$Encoder::encode (359 bytes)
    184   31  s    4       java.lang.StringBuffer::append (13 bytes)
    184   32       4       java.lang.AbstractStringBuilder::append (29 bytes)
    185   33       4       java.io.WinNTFileSystem::isSlash (18 bytes)
    185   29       3       java.lang.String::indexOf (166 bytes)
    185   27       3       java.lang.String::startsWith (72 bytes)
    186   30       3       java.lang.String::toCharArray (25 bytes)
    186   34       3       java.lang.StringBuffer::<init> (6 bytes)
    186   35       3       java.lang.AbstractStringBuilder::<init> (12 bytes)
    186   37     n 0       sun.misc.Unsafe::getObjectVolatile (native)   
    186   36       3       java.util.concurrent.ConcurrentHashMap::tabAt (21 bytes)
    187   38     n 0       sun.misc.Unsafe::compareAndSwapLong (native)   
    187   41       3       java.util.Random::nextInt (74 bytes)
    187   39       3       java.util.concurrent.atomic.AtomicLong::get (5 bytes)
    187   40       3       java.util.concurrent.atomic.AtomicLong::compareAndSet (13 bytes)
    187   42       3       java.util.Random::next (47 bytes)
    188   43       1       java.util.concurrent.atomic.AtomicLong::get (5 bytes)
    188   39       3       java.util.concurrent.atomic.AtomicLong::get (5 bytes)   made not entrant
    188   44       1       java.util.concurrent.atomic.AtomicLong::compareAndSet (13 bytes)
    188   46       4       java.util.Random::nextInt (74 bytes)
    188   40       3       java.util.concurrent.atomic.AtomicLong::compareAndSet (13 bytes)   made not entrant
*   188   45       3       com.example.demo.gcdemo.JITDemo2::plus (36 bytes)
    188   47       4       java.util.Random::next (47 bytes)
    189   42       3       java.util.Random::next (47 bytes)   made not entrant
*   189   48       4       com.example.demo.gcdemo.JITDemo2::plus (36 bytes)
    189   41       3       java.util.Random::nextInt (74 bytes)   made not entrant
*   191   45       3       com.example.demo.gcdemo.JITDemo2::plus (36 bytes)   made not entrant
複製代碼

說明一下日誌格式(最前面的*號忽略,這是爲了標記出plus方法):

  • 第一列:時間(毫秒)
  • 第二列:JVM維護的編譯ID
  • 第三列:一些標識,好比上面出現的n和s,n表示是不是native方法,顯示在日誌中爲true,沒顯示爲false。s表示是不是synchronized方法。此外還有:%表示是不是OSR編譯,!表示是否包含異常處理器,b表示是否阻塞應用線程。
  • 第四列:編譯的層次,0-4層
  • 第五列:編譯的方法名
  • made not entrant:以前被編譯過的方法發生了「去優化」,這個在上篇中已經提到過

從日誌能夠觀察出,plus方法首先觸發了3層的C1即時編譯,而後觸發了4層的C2的即時編譯,最後被標記爲made not entrant,即plus方法發生了去優化。

這裏爲何會發生去優化呢,筆者猜測,made not entrant也就是不會再被進入,由於即時編譯器會將編譯完的代碼存入CodeCache,而CodeCache是在堆外內存的,JVM進程的結束不會釋放這塊堆外內存,這樣會形成內存泄漏。那麼爲了釋放CodeCache,就須要在JVM結束前對其全部內存進行回收,而CodeCache中的內容被回收的依據是全部線程都退出被標記爲made not entrant方法時,該方法的CodeCache就能夠被回收。

PS:經過下面代碼能夠在程序中獲取CodeCache的使用狀況

// 查看Code Cache使用量
List<MemoryPoolMXBean> beans = ManagementFactory.getMemoryPoolMXBeans();
for (MemoryPoolMXBean bean : beans) {
    if ("Code Cache".equalsIgnoreCase(bean.getName())) {
        System.out.println("max: " + bean.getUsage().getMax() + " bytes, used: " + bean.getUsage().getUsed() + " bytes");
    }
}
複製代碼

2 即時編譯器對應用程序啓動的影響

先來講一下發現的問題:應用啓動後,CPU使用率和負載飆升,致使部分請求失敗,頻繁報警,大概會持續1分鐘左右。

而後考慮是不是即時編譯器的影響。當時咱們在生產環境使用的是jdk1.7.0_67,且沒有開啓分層編譯,而後想到java8對編譯器作了一些優化,而且是默認開啓分層編譯的,而後將其中的一臺機器升級到java8,再從新啓動,發現CPU使用率和負載都下降了。

因爲當時的截圖沒有了,這裏我本身作了一個web程序的小demo。

下面會分別比較java7環境和java8環境的啓動後CPU使用率和負載變化。

java7默認JVM參數狀況下的CPU使用率和複雜變化(不開啓分層編譯):

CPU使用率:

CPU使用率

CPU負載:

CPU負載

Java8默認JVM參數狀況下的CPU使用率和複雜變化(開啓分層編譯):

CPU使用率:

CPU使用率

CPU負載:

CPU負載

由此能夠看出,同爲即時編譯器默認參數狀況下,java8在啓動性能上提高了不少。

那如何肯定分層編譯是否會影響啓動性能呢?由於在java7中已經支持了分層編譯,因此在java7環境下將分層編譯打開,就能夠進行比對。

須要說明的是,這個比對並不嚴格,java7在CodeCache的回收上作的很差,這方面在java8中獲得了改進,除此以外還有一些其餘方面的改進,因此這是一個不嚴格的測試,但大致能說明問題。

將java7的啓動參數加上-XX:+TieredCompilation,下面是CPU使用率和CPU負載的變化狀況。

CPU使用率的變化:

CPU使用率

CPU負載的變化:

CPU負載

因而可知分層編譯的開啓有利於提高應用的啓動性能。

3 思考:分層編譯對代碼執行性能的影響

3.1 從分層編譯的模式考慮

  • 在不開啓分層編譯的狀況下,代碼以混合模式執行,當方法調用次數和循環回邊次數達到設定的閾值時,會觸發對應編譯器的即時編譯,這個設定的閾值是固定的。
  • 在開啓分層編譯的狀況下,每一層即時編譯觸發的閾值是動態計算的,並且會根據JVM當前執行狀態的不一樣,選用不一樣的編譯器編譯,例如C1繁忙時,會直接提交給C2執行,C2繁忙時,會先有C1編譯,在逐步的提交給C2執行。

3.2 CodeCache方面

  • 不開啓分層編譯的狀況下,64位JVM的CodeCache默認大小爲48M
  • 開啓分層編譯的狀況下,64位JVM的CodeCache的默認大小爲256M

因爲CodeCache若是越小,GC的次數越頻繁,越影響編譯器的性能,CodeCache過大也很差,會提升單詞GC須要的時間,因此CodeCache儘量要調整成最合適的大小。

PS:CodeCache的GC筆者沒有研究過,因此這裏GC對其的影響也是一個猜想。

4 附加

查看JVM默認值的方式:

-XX:+PrintFlagsFinal

例如:java -XX:+PrintFlagsFinal -version > options.txt

結果以下:

JVM默認值

5 參考文檔

[1] 極客時間《深刻拆解Java虛擬機》 鄭雨迪


歡迎關注個人微信公衆號

公衆號
相關文章
相關標籤/搜索