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