1. 程式人生 > >小師妹學JVM之:深入理解JIT和編譯優化-你看不懂系列

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

[toc] # 簡介 小師妹已經學完JVM的簡單部分了,接下來要進入的是JVM中比較晦澀難懂的概念,這些概念是那麼的枯燥乏味,甚至還有點惹人討厭,但是要想深入理解JVM,這些概念是必須的,我將會盡量嘗試用簡單的例子來解釋它們,但一定會有人看不懂,沒關係,這個系列本不是給所有人看的。 更多精彩內容且看: * [區塊鏈從入門到放棄系列教程-涵蓋密碼學,超級賬本,以太坊,Libra,比特幣等持續更新](http://www.flydean.com/blockchain/) * [Spring Boot 2.X系列教程:七天從無到有掌握Spring Boot-持續更新](http://www.flydean.com/learn-spring-boot/) * [Spring 5.X系列教程:滿足你對Spring5的一切想象-持續更新](http://www.flydean.com/spring5/) * [java程式設計師從小工到專家成神之路(2020版)-持續更新中,附詳細文章教程](http://www.flydean.com/java-roadmap-2020/) # JIT編譯器 小師妹:F師兄,我的基礎已經打牢了嗎?可以進入這麼複雜的內容環節了嗎? 小師妹不試試怎麼知道不行呢?瞭解點深入內容可以幫助你更好的理解之前的知識。現在我們開始吧。 上次我們在講java程式的處理流程的時候,還記得那通用的幾步吧。 小師妹:當然記得了,編寫原始碼,javac編譯成位元組碼,載入到JVM中執行。 ![](https://img-blog.csdnimg.cn/20200524212920415.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_0,text_aHR0cDovL3d3dy5mbHlkZWFuLmNvbQ==,size_35,color_8F8F8F,t_70) 對,其實在JVM的執行引擎中,有三個部分:直譯器,JIT編譯器和垃圾回收器。 ![](https://img-blog.csdnimg.cn/20200524221637660.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_0,text_aHR0cDovL3d3dy5mbHlkZWFuLmNvbQ==,size_35,color_8F8F8F,t_70) 直譯器會將前面編譯生成的位元組碼翻譯成機器語言,因為每次都要翻譯,相當於比直接編譯成機器碼要多了一步,所以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中,你可以使用下面的命令來開啟分層編譯: ~~~java -XX:+TieredCompilation ~~~ 而在JDK8之後,恭喜你,分層編譯已經是預設的選項了,不用再手動開啟。 # OSR(On-Stack Replacement) 小師妹:F師兄,你剛剛講到Server的JIT不是立馬就進行編譯的,它會等待一定的時間來蒐集所需的資訊,那麼程式碼不是要從位元組碼轉換成機器碼? 對的,這個過程就叫做OSR(On-Stack Replacement)。為什麼叫OSR呢?我們知道JVM的底層實現是一個棧的虛擬機器,所以這個替換實際上是一系列的Stack操作。 ![](https://img-blog.csdnimg.cn/20200528094011924.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_0,text_aHR0cDovL3d3dy5mbHlkZWFuLmNvbQ==,size_35,color_8F8F8F,t_70) 上圖所示,m1方法從最初的解釋frame變成了後面的compiled frame。 # Deoptimization 這個世界是平衡的,有陰就有陽,有優化就有反優化。 小師妹:F師兄,為什麼優化了之後還要反優化呢?這樣對效能不是下降了嗎? 通常來說是這樣的,但是有些特殊的情況下面,確實是需要進行反優化的。 下面是比較常見的情況: 1. 需要除錯的情況 如果程式碼正在進行單個步驟的除錯,那麼之前被編譯成為機器碼的程式碼需要反優化回來,從而能夠除錯。 2. 程式碼廢棄的情況 當一個被編譯過的方法,因為種種原因不可用了,這個時候就需要將其反優化。 3. 優化之前編譯的程式碼 有可能出現之前優化過的程式碼可能不夠完美,需要重新優化的情況,這種情況下同樣也需要進行反優化。 # 常見的編譯優化舉例 除了JIT編譯成機器碼之外,JIT還有一下常見的程式碼優化方式,我們來一一介紹。 ## Inlining內聯 舉個例子: ~~~java 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可以提前準備相應的執行程式碼,如果分支檢查成功就直接執行,省去了程式碼準備的步驟。 比如下面的程式碼: ~~~java // 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語句從迴圈中提取出來: ~~~java int i, w, x[1000], y[1000]; for (i = 0; i < 1000; i++) { x[i] += y[i]; if (w) y[i] = 0; } ~~~ 可以改為下面的方式: ~~~java 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展開 在迴圈語句中,因為要不斷的進行跳轉,所以限制了執行的速度,我們可以對迴圈語句中的邏輯進行適當的展開: ~~~java int x; for (x = 0; x < 100; x++) { delete(x); } ~~~ 轉變為: ~~~java 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中分配這個物件,棧上分配肯定比堆上分配要快,因為不用考慮同步的問題。 我們舉個例子: ~~~java 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引入了鎖的概念,雖然鎖可以保證多執行緒的有序執行,但是如果實在單執行緒環境中呢?是不是還需要一直使用鎖呢? 比如下面的例子: ~~~java public String getNames() { Vector