修改機器碼的重要性—修改機器碼軟件好使嗎
JIT(Just In Time)技術是Java虛擬機中的一項重要技術,其在運行時將字節碼編譯為機器碼,以大幅提升程序的執行速度。
正是因為JVM中使用了JIT技術,才為Java代碼在運行時的性能可能超過C++提供了基礎。
一般情況下,我們所產出的代碼,很大層面上需要保障代碼的可讀性,而這里的可讀性是針對于編碼人員的,而非針對于機器。具備高可讀性的代碼,通常并不意味著其可以高效地被機器直接執行,而通常情況下剛好相反。
此處,我們針對JIT中一些常用的優化手段,來理解為何Java代碼的執行效率可以如此之高。
經過JIT優化的代碼的執行效率提升,很大層面上是因為JIT對指令進行了重新的排列。指令重排在保證代碼邏輯不變的情況下,對代碼的執行順序進行了調整,從而提升了代碼的執行效率。
為了理解指令重排,我們需要首先了解JVM所支持的指令是什么樣子的。
對于已經編譯完成的一個方法,存在三個重要的組成部分:
- 本地變量表:用以保存方法的入參及聲明的局部變量。
- 操作數棧:用以存儲運行的中間結果。
- 指令集:即編譯完成后的代碼,也可能稱為字節碼。
Java指令在JVM規范中有詳細的描述,對應版本的JVM都會擁有一份JVM規范的文檔,這些文檔被收錄在Oracle的官網中:
在對應文檔的“The Java Machine Instruction Set”章節中,有對各種執行的詳細介紹,此處我們不過多贅述,而是簡單討論一下指令的行為。
JVM所支持的指令,從行為中可分為四類:
- 從本地變量表或常量池中取出一個值,并將其壓入到操作數棧中。如aload_0,將本地變量表中索引為0的值壓入到操作數棧中。
- 從操作數棧中取出操作數進行計算,并將操作結果重新壓入到操作數棧中。如iadd,從操作數棧中取出兩個32位整數,并將其相加得到的和重新壓入到操作數棧中。
- 從操作數棧中取出值并寫入到本地變量表中。如astore_0,從操作數棧中取出一個值,并將其寫入到本地變量表中索引為0的位置。
- 用于控制程序跳轉。如if_icmpeq、lookupswitch、tableswitch等。
此處我們著重了解前三類指令,首先看示例代碼:
我們將這段代碼編譯成為class文件,并通過javap命令查看編譯后的結果。
輸出的結果如下:
這里我們只關注最后public int compute(int, int)方法中的指令:
我們可以看到,在源代碼中的兩行代碼,編譯完成后得到了8條指令,這8條指令是完全按照源代碼的意圖進行直譯的。
而在實際執行中,JVM會對指令進行簡化,簡化后的指令:
我們可以看到,指令從8條被精簡到了6條,其中針對操作數棧頂的值的讀取和寫入(即istore_3和iload_3)被合并,從而減少了不必要的操作。
那么此時,我們就可以理解指令重排的意義。
在編碼過程中,從提高代碼可讀性的角度考慮,我們會將含義、目的將近的變量放到一起聲明和初始化,并在后續操作中,按更容易理解的業務語義來對其進行批量操作,但是這個時候,可能會導致很多無效的讀取和寫入操作。為了合并掉這些操作,JVM在邏輯不變的前提下,對指令進行重排,從而使得更多的指令被合并,減少同一代碼執行時所需的指令數量。
而指令重排所帶來的好處是顯而易見的,如果指令的數量被降低10%,那么性能將是實打實地提升10%。
逃逸分析是在Java6中引入的新特性,其與標量替換共同完成運行時的優化。
逃逸分析用來判斷在一個方法中所實例化的對象,是否在方法外被使用。如果對象在方法外被使用,則表示這個對象發生了“**逃逸**”,否則視作未發生“**逃逸**”。而對于未發生逃逸的對象,則可通過棧上分配技術,直接在方法棧中為對象分配內存。進而通過**標量替換**技術,將變量中的字段打散到方法的本地變量表中,后續對于對象中字段的操作,就直接操作這些本地變量,此時這個對象就不見了,取而代之的是表示其所包含字段的本地變量。
標量是不可再被細分的值,如32位整數、布爾值、字符串等。標量不僅局限于基本數據類型。
此優化所帶來的好處有:
- 因為不再需要實例化對象,因此減少了堆內存的使用,降低了垃圾回收的壓力,更多的內存可隨著方法棧的銷毀而直接被釋放。
- 鎖消除,因為對象不會發生逃逸,因此對象的作用域僅在方法執行過程中,因此其是不會發生線程同步的。此時無效的對于同步鎖的操作將被消除掉,提升執行效率。
- 替換為標量的值,在方法邏輯執行過程中,可以參與到指令重排中,從而進一步優化性能。
因此,對于以下代碼:
在進行標量替換后,其實際的邏輯將近似地被優化為:
以上僅是一個示意,當然,此間還涉及到一些其他的優化手段,比如內聯等。
內聯的概念比較容易理解,即是將一個方法的邏輯直接打平打調用方的代碼中。例如:
在內聯后即成為:
內聯的好處有很多,例如降低代碼的實際調用層次等。但是相比于其直接產生的收益,其間接收益則更大。內聯是將各種優化手段有效銜接起來的重要手段。例如,逃逸分析的重要依據是對象是否在方法外被使用,如果我們將一個對象傳入到一個方法中,例如對其字段進行校驗等,那么這個對象就發生了逃逸,不能應用棧上分配、標量替換等優化手段,更進一步也就無法更好地進行指令重排。
而內聯則有效地解決了這個問題,在實際代碼運行過程中,內聯無處不在,通常幾層、十幾層的調用棧,都會被內聯到一個方法中。
那么,什么樣的方法可以被內聯呢?
簡單來說,穩定的方法可以被內聯。即當一個方法調用另一個方法時,如果另一個方法的邏輯不會發生變化,那么這個方法就可以被內聯到調用方的方法中。例如final方法、private方法等。
但是因為內聯的優化手段實在過于重要,因此JVM后期對內聯再次進行了增強,也就是所說的激進優化。這里的激進優化主要在于可能發生變化的方法,如通過接口調用一個實現時。
一般情況下,當我們通過接口調用一個方法時,我們并不能確定最終調用的是接口的哪個實現。當對這個接口方法的調用成為熱點,且目標方法不曾發生改變時,將嘗試對這個被調用的方法進行內聯,如果后續調用的目標方法發生變化,則會進行優化回退。優化回退的成本相對是很高的,因為一般情況下,代碼將被回退到所有優化發生之前的狀態。
這里所說的激進優化,與JVM參數中的AggressiveOpts是不同的,AggressiveOpts參數的開啟表示將啟用當前JVM版本中還不成熟的優化手段。
那么,激進優化的意義何在和?
一般情況下,在編碼過程中,需要考慮到諸如并行開發、接口分離原則等諸多方面,會使我們的代碼在設計層面被拆分成為不同的組件,而大多情況下,這些用作分離的接口通常只有一個實現(默認實現),這就為激進優化帶來的底層的邏輯支撐。
JVM中還存在諸多的優化手段,如分支消除、反射優化等。但是總體而言,**JIT的優化主要依據在于熱點代碼判斷,最重要的手段在于方法內聯**。因此當我們進行代碼設計時,首要應考慮代碼的內聯屬性。如果組件的代碼是更容易被內聯的,通常情況下,其所帶來的效率將會更高。基于此,可以總結一些有效的代碼設計方法:
- 多抽取工具方法。工具方法的抽取除了代碼更好的可靠性外,也更便于內聯的發生,并不會帶來額外的調用棧開銷。
- 明確擴展點。在進行類設計時,對于哪些方法是需要多態的應該有明確的規劃,對于不需要多態的方法應明確使用final進行封閉。
- 單純的沒有多態的接口分離是不會帶來額外的性能損耗的,因為這些方法最終會被內聯掉。