小師妹學JVM之:深入理解JIT和編譯優化-你看不懂系列

目錄

  • 簡介
  • JIT編譯器
  • Tiered Compilation分層編譯
  • OSR(On-Stack Replacement)
  • Deoptimization
  • 常見的編譯優化舉例
    • Inlining內聯
    • Branch Prediction分支預測
    • Loop unswitching
    • Loop unrolling展開
    • Escape analysis逃逸分析
  • 總結

簡介

小師妹已經學完JVM的簡單部分了,接下來要進入的是JVM中比較晦澀難懂的概念,這些概念是那麼的枯燥乏味,甚至還有點惹人討厭,但是要想深入理解JVM,這些概念是必須的,我將會盡量嘗試用簡單的例子來解釋它們,但一定會有人看不懂,沒關係,這個系列本不是給所有人看的。

更多精彩內容且看:

  • 區塊鏈從入門到放棄系列教程-涵蓋密碼學,超級賬本,以太坊,Libra,比特幣等持續更新
  • Spring Boot 2.X系列教程:七天從無到有掌握Spring Boot-持續更新
  • Spring 5.X系列教程:滿足你對Spring5的一切想象-持續更新
  • java程序員從小工到專家成神之路(2020版)-持續更新中,附詳細文章教程

JIT編譯器

小師妹:F師兄,我的基礎已經打牢了嗎?可以進入這麼複雜的內容環節了嗎?

小師妹不試試怎麼知道不行呢?了解點深入內容可以幫助你更好的理解之前的知識。現在我們開始吧。

上次我們在講java程序的處理流程的時候,還記得那通用的幾步吧。

小師妹:當然記得了,編寫源代碼,javac編譯成字節碼,加載到JVM中執行。

對,其實在JVM的執行引擎中,有三個部分:解釋器,JIT編譯器和垃圾回收器。

解釋器會將前面編譯生成的字節碼翻譯成機器語言,因為每次都要翻譯,相當於比直接編譯成機器碼要多了一步,所以java執行起來會比較慢。

為了解決這個問題,JVM引入了JIT(Just-in-Time)編譯器,將熱點代碼編譯成為機器碼。

Tiered Compilation分層編譯

小師妹你知道嗎?在JDK8之前,HotSpot VM又分為三種。分別是 client VM, server VM, 和 minimal VM,分別用在客戶端,服務器,和嵌入式系統。

但是隨着硬件技術的發展,這些硬件上面的限制都不是什麼大事了。所以從JDK8之後,已經不再區分這些VM了,現在統一使用VM的實現來替代他們。

小師妹,你覺得Client VM和Server VM的本質區別在哪一部分呢?

小師妹,編譯成字節碼應該都是使用javac,都是同樣的命令,字節碼上面肯定是一樣的。難點是在執行引擎上面的不同?

說的對,因為Client VM和Server VM的出現,所以在JIT中出現了兩種不同的編譯器,C1 for Client VM, C2 for Server VM。

因為javac的編譯只能做少量的優化,其實大量的動態優化是在JIT中做的。C2相對於C1,其優化的程度更深,更加激進。

為了更好的提升編譯效率,JVM在JDK7中引入了分層編譯Tiered compilation的概念。

對於JIT本身來說,動態編譯是需要佔用用戶內存空間的,有可能會造成較高的延遲。

對於Server服務器來說,因為代碼要服務很多個client,所以磨刀不誤砍柴工,短暫的延遲帶來永久的收益,聽起來是可以接受的。

Server端的JIT編譯也不是立馬進行的,它可能需要收集到足夠多的信息之後,才進行編譯。

而對於Client來說,延遲帶來的性能影響就需要進行考慮了。和Server相比,它只進行了簡單的機器碼的編譯。

為了滿足不同層次的編譯需求,於是引入了分層編譯的概念。

大概來說分層編譯可以分為三層:

  1. 第一層就是禁用C1和C2編譯器,這個時候沒有JIT進行。
  2. 第二層就是只開啟C1編譯器,因為C1編譯器只會進行一些簡單的JIT優化,所以這個可以應對常規情況。
  3. 第三層就是同時開啟C1和C2編譯器。

在JDK7中,你可以使用下面的命令來開啟分層編譯:

-XX:+TieredCompilation

而在JDK8之後,恭喜你,分層編譯已經是默認的選項了,不用再手動開啟。

OSR(On-Stack Replacement)

小師妹:F師兄,你剛剛講到Server的JIT不是立馬就進行編譯的,它會等待一定的時間來搜集所需的信息,那麼代碼不是要從字節碼轉換成機器碼?

對的,這個過程就叫做OSR(On-Stack Replacement)。為什麼叫OSR呢?我們知道JVM的底層實現是一個棧的虛擬機,所以這個替換實際上是一系列的Stack操作。

上圖所示,m1方法從最初的解釋frame變成了後面的compiled frame。

Deoptimization

這個世界是平衡的,有陰就有陽,有優化就有反優化。

小師妹:F師兄,為什麼優化了之後還要反優化呢?這樣對性能不是下降了嗎?

通常來說是這樣的,但是有些特殊的情況下面,確實是需要進行反優化的。

下面是比較常見的情況:

  1. 需要調試的情況

如果代碼正在進行單個步驟的調試,那麼之前被編譯成為機器碼的代碼需要反優化回來,從而能夠調試。

  1. 代碼廢棄的情況

當一個被編譯過的方法,因為種種原因不可用了,這個時候就需要將其反優化。

  1. 優化之前編譯的代碼

有可能出現之前優化過的代碼可能不夠完美,需要重新優化的情況,這種情況下同樣也需要進行反優化。

常見的編譯優化舉例

除了JIT編譯成機器碼之外,JIT還有一下常見的代碼優化方式,我們來一一介紹。

Inlining內聯

舉個例子:

int a = 1;
int b = 2;
int result = add(a, b);
...
public int add(int x, int y) { return x + y; }
int result = a + b; //內聯替換

上面的add方法可以簡單的被替換成為內聯表達式。

Branch Prediction分支預測

通常來說對於條件分支,因為需要有一個if的判斷條件,JVM需要在執行完畢判斷條件,得到返回結果之後,才能夠繼續準備後面的執行代碼,如果有了分支預測,那麼JVM可以提前準備相應的執行代碼,如果分支檢查成功就直接執行,省去了代碼準備的步驟。

比如下面的代碼:

// make an array of random doubles 0..1
double[] bigArray = makeBigArray();
for (int i = 0; i < bigArray.length; i++)
{
 double cur = bigArray[i];
 if (cur > 0.5) { doThis();} else { doThat();}
}

Loop unswitching

如果我們在循環語句裏面添加了if語句,為了提升併發的執行效率,可以將if語句從循環中提取出來:

  int i, w, x[1000], y[1000];
  for (i = 0; i < 1000; i++) {
    x[i] += y[i];
    if (w)
      y[i] = 0;
  }

可以改為下面的方式:

  int i, w, x[1000], y[1000];
  if (w) {
    for (i = 0; i < 1000; i++) {
      x[i] += y[i];
      y[i] = 0;
    }
  } else {
    for (i = 0; i < 1000; i++) {
      x[i] += y[i];
    }
  }

Loop unrolling展開

在循環語句中,因為要不斷的進行跳轉,所以限制了執行的速度,我們可以對循環語句中的邏輯進行適當的展開:

 int x;
 for (x = 0; x < 100; x++)
 {
     delete(x);
 }

轉變為:

 int x; 
 for (x = 0; x < 100; x += 5 )
 {
     delete(x);
     delete(x + 1);
     delete(x + 2);
     delete(x + 3);
     delete(x + 4);
 }

雖然循環體變長了,但是跳轉次數變少了,其實是可以提升執行速度的。

Escape analysis逃逸分析

什麼叫逃逸分析呢?簡單點講就是分析這個線程中的對象,有沒有可能會被其他對象或者線程所訪問,如果有的話,那麼這個對象應該在Heap中分配,這樣才能讓對其他的對象可見。

如果沒有其他的對象訪問,那麼完全可以在stack中分配這個對象,棧上分配肯定比堆上分配要快,因為不用考慮同步的問題。

我們舉個例子:

  public static void main(String[] args) {
    example();
  }
  public static void example() {
    Foo foo = new Foo(); //alloc
    Bar bar = new Bar(); //alloc
    bar.setFoo(foo);
  }
}

class Foo {}

class Bar {
  private Foo foo;
  public void setFoo(Foo foo) {
    this.foo = foo;
  }
}

上面的例子中,setFoo引用了foo對象,如果bar對象是在heap中分配的話,那麼引用的foo對象就逃逸了,也需要被分配在heap空間中。

但是因為bar和foo對象都只是在example方法中調用的,所以,JVM可以分析出來沒有其他的對象需要引用他們,那麼直接在example的方法棧中分配這兩個對象即可。

逃逸分析還有一個作用就是lock coarsening。

為了在多線程環境中保證資源的有序訪問,JVM引入了鎖的概念,雖然鎖可以保證多線程的有序執行,但是如果實在單線程環境中呢?是不是還需要一直使用鎖呢?

比如下面的例子:

public String getNames() {
     Vector<String> v = new Vector<>();
     v.add("Me");
     v.add("You");
     v.add("Her");
     return v.toString();
}

Vector是一個同步對象,如果是在單線程環境中,這個同步鎖是沒有意義的,因此在JDK6之後,鎖只在被需要的時候才會使用。

這樣就能提升程序的執行效率。

總結

本文介紹了JIT的原理和一些基本的優化方式。後面我們會繼續探索JIT和JVM的秘密,敬請期待。

本文作者:flydean程序那些事

本文鏈接:http://www.flydean.com/jvm-jit-in-detail/

本文來源:flydean的博客

歡迎關注我的公眾號:程序那些事,更多精彩等着您!

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

USB CONNECTOR掌控什麼技術要點? 帶您認識其相關發展及效能

台北網頁設計公司這麼多該如何選擇?

※智慧手機時代的來臨,RWD網頁設計為架站首選

※評比南投搬家公司費用收費行情懶人包大公開

※回頭車貨運收費標準