一個程序的編譯過程可以是步驟迭代式的,即每一輪步驟結束后得到的結果都可獨立運行,比如,先構造AST 再輸出字節碼,中間狀態AST 也是可以解釋執行的。由于編譯的本質就是代碼轉換,因此對一個語言可以有多個獨立的編譯器,每個負責一輪步驟
AOT Compiler 和JIT Compiler 就是針對編譯形式做的分類:
* AOT:Ahead Of Time,指在運行前編譯,比如普通的靜態編譯
* JIT:Just In Time,指在運行時編譯,邊運行邊編譯,比如java 虛擬機在運行時就用到JIT 技術
JIT 可能知道的人多些,AOT 這個名詞就相對少見一些了,其實除了JIT,剩下的都是AOT。wiki 上JIT 的解釋也比AOT 詳盡很多,如果按wiki 上的理解,一般來說,是從形式上來區分這兩個概念,即看編譯是不是在“運行時”進行
然而,這兩個概念又有模糊性,問題在于這個“運行時”怎么來區分,比方說,從這個概念來看,python 是用到JIT 技術的,因為:
```
... import a ...
```
當執行到import a 的時候,當然是運行時,這時候如果只找到了a.py,則會進行編譯工作,并生成a.pyc,這就是python 的JIT 特性,但是一般來說,認為python 的JIT 是psyco、pypy 之類,并不認為python 本身的動態性屬于JIT 范疇,或者說,它的這種“形式上”的JIT 特性不納入討論范圍。其他腳本語言,動態語言也有類似的情況。具體原因我覺得有幾點
* 首先被主流理論認定的JIT 編譯器對于被其編譯的語言來說屬于附加品,也就是說,就算去掉JIT,并不影響語言本身的運行,例如java,如果關閉JIT,依然可以解釋執行,而上述python 的運行時import 的特性雖然形式上符合JIT,但這個機制是語言本身規定的,如果去掉,語言(的主流實現)就不完整了。反過來說,如果python 采用源碼直接解析執行,則編譯為字節碼的行為就可以看做是JIT,因為做不做都不影響解析執行過程
* 其次,python 的這種編譯并非每次執行都會進行,因為一般來說會生成字節碼結果pyc 文件存在磁盤,它更像是對java 源代碼轉class 文件這一過程的惰性化,在需要的時候進行
* 最后,JIT 會消耗運行時資源,可能導致進程卡頓,而java 等語言之所以引入JIT,是因為JIT 對字節碼編譯后能以更快的速度運行,卡頓的時間能補救回來,因此從工程角度講,JIT 幾乎就等于是運行時優化(雖然從概念和形式上并非如此),而python 的import 就只有卡頓,對速度沒啥好處
于是,雖然從概念來說,上面的例子的確符合JIT,但一般來說也不這么認為,出發角度問題,說python 自帶JIT 特性或沒有JIT 都算說得通的
之所以先舉這個例子,因為我覺得能體現AOT 和JIT 概念的對立和統一,對立是形式上的,以“運行”為分界線,而統一則是說,其實所有需要執行的指令序列,都是需要先編譯再執行的,比如import a,這個相對于整個進程當然是JIT,但相對于a.py 這個模塊(python 進程首次import 某個模塊時會執行它)不妨看做AOT,如果有人覺得這么做不妥,那換個更明顯的例子,如果一個python 程序的所有import 都在進程開啟時立即運行,然后才進入執行,那按照概念來說,這是JIT,因為進程已經開始運行了,但是,為什么不能看做是先編譯再執行的AOT 模式,只是整個過程被批處理化了呢?
帶著這個問題再考慮很多資料(包括wiki)對JIT 的另一個描述,JIT 是在運行時將解釋執行的語言(比如字節碼)編譯成機器指令,以提高運行速度。這個看法在前面的某篇也提過,的確很多JIT 編譯器,比如java 的就是這么干的(我們下面就拿java 舉例),但是,既然字節碼編譯成機器指令可以提高速度,為何一定要放在運行時進行,做成AOT 模式不是可以運行得更流暢嗎,而且還能一次編譯,N 次執行,為啥非要做成運行時做,JIT 本來是要提高運行速度,但這豈不是降低了效率?
這種看法是有道理的,事實上,java 的確有一些AOT 編譯器,可以將字節碼甚至java 源碼直接編譯成機器指令的可執行文件,微軟當初的VJ++似乎就這么搞的,和sun 打了很久的架,sun 還喊出了pure java(純粹的java,即按照sun的設計理念和標準來實現java)的口號,有興趣可以去搜一下這段歷史,挺搞笑的
另一方面,sun 的jvm 雖然采用了JIT 編譯,但同時也提供了client 和server 模式,在server 模式下,虛擬機在一開始執行的時候會先盡可能多地對字節碼進行編譯,且優化程度也盡量高,這樣可以使得服務器在運行過程中能盡量少卡頓,根據上面的討論,這實際上相當于AOT 批處理了。client 模式下則不會這樣做,主要是為了盡量縮短啟動延遲,提高用戶體驗
順便說一句,對于JIT 將字節碼編譯成機器指令,wiki 的描述比較曖昧,有時候用machine code,有時候用native code,比方說我們用java 實現一個A 語言的虛擬機,解釋A 的字節碼執行,并將字節碼編譯成java 自己的字節碼,這也是JIT,因為A 跑在jvm 上,則java 字節碼就看做是native code,而machine code這個machine 也不見得是真實機器,jvm 也是一種機器
由于JIT 編譯耗費運行時間,則對于某些優化點就無法做到百分百支持,必須在代碼優化和執行卡頓之間做一個權衡,AOT 就沒有這個問題,另外,AOT 可以做到編譯后持久化到存儲,而JIT 一般是每運行一次就會搞一遍重復的編譯
如果我們不考慮AOT 本身耗費的時間(比如編譯一次,N 次運行),也不考慮使用上的方便性(AOT 可能會有多次編譯過程),那是不是可以認為,AOT 編譯可以完全替換JIT 編譯,JIT 就完全沒必要了,實際情況當然不是這樣,JIT 還是有它的優勢和必要性的,否則研究它的那群人豈不都是傻子
從動靜態來看這個問題,AOT 是靜態編譯,而JIT 是運行時動態編譯,則JIT 的優勢在于,它不但能看到靜態信息(代碼),還能看到運行時的情況,這就是JIT的優勢。接下來討論的JIT 是一種狹義的JIT,即在AOT 搞不定的地方使用的JIT,而非上述形式上的
關于JIT 的優勢,wiki 上給出了四點理由,但有意思的是,其中有兩條連它自己都承認并非只有JIT 能做,也就是說至少理論上,用AOT 實現(或部分實現)是可行的,這四條是:
* 1、JIT 可以根據當前的硬件情況實時編譯成最優機器指令,比如cpu 中如果含FPU,MMX,SSE2,或者Intel cpu 的并行計算特性,則可以做到同一份字節碼,在不同機器運行時最大限度利用硬件資源。而如果是AOT 編譯一個程序放出去給不同用戶使用,就只能去兼容特性最少的cpu,或者內部實現多個版本
* 2、JIT 可以根據當前進程實際運行狀態,將字節碼編譯成適合最優化的機器指令序列。wiki 認為靜態編譯也可以通過分析profile 來實現這方面的優化(可能有點麻煩)
* 3、當程序需要支持動態鏈接時,即在靜態編譯階段,可能不知道運行時會引入什么樣的代碼來和程序協作執行,這時候就只能依靠JIT
* 4、考慮到垃圾收集,JIT 可以根據進程中的內存實際情況來調整代碼,使得cache能更充分地使用,wiki 認為靜態編譯也可以做到,但JIT 做起來更容易實現
對于第一條,JIT 的確可以實現這種優化,但是AOT 一樣可以實現,雖然AOT編譯一個程序給不同用戶執行無法做到,但是可以編譯字節碼發布,用戶使用時再根據當前機器再做一次AOT
對于第二條,首先我認為大多數程序的運行狀態不會經常變動,比如同一個程序有時候是整數計算居多,有時候是浮點計算居多,一般來說程序應用場景是固定的;其次對于特定場景也可以AOT
對于第三條,的確動態鏈接的全文靜態優化AOT 無法做到,但是如上篇所說,必要時候我們可以直接砍掉語言的動態性,再者靜態編譯時候也不是什么都感知不到,比如C 語言做靜態鏈接時,至少是知道頭文件的,動態性沒那么強
對于第四條,AOT 也是有可能實現的,雖然麻煩很多。另一方面,靜態編譯時也有指令亂序來提高cache 使用效果,再者這塊也和垃圾收集算法、程序本身的局部性有很大關系,如果程序本身寫的爛,這個調整效果可能也比較有限
所以我覺得,這四條雖然都有道理,但沒精確說到點子上。再來審視這個問題,
我們可以看出,從理論上講,AOT 可以完全代替JIT,因為一個進程的狀態是有限的,AOT 可以預測所有可能情況并進行優化,實際運行時的狀態不會超出AOT的預測,采用最優代碼執行即可,而JIT 在這里的優勢就是,它能精準地得知運行時狀態,而不是像AOT 那樣預測,成本更低,如果一個AOT 優化的成本過高,則應該選擇JIT。AOT 不是不能做,而是不可行
JIT 相關的資料,相比wiki 我更推薦這篇論文:《Representation-basedJust-in-time Specialization and the Psyco prototype for Python》by Armin Rigo,這個論文是以python 和其JIT 插件庫psyco 為例來分析,論文題目中的單詞Specialization 可謂畫龍點睛,它指出至少在動態類型語言中,JIT 的關鍵作用之一是特化,用上篇的話說,就是動態行為靜態化,而這些場景中AOT 不可行的原因是它很難找到特化的方向,而枚舉所有特化是不可行的
一個典型的特化案例,也是論文中提到的,假設有一個函數f(x,y),則對于x 的輸入x1,x2,x3...,我們可以特化這個函數為f1(y),f2(y),f3(y)...,其中fk(y)在功能上對應f(xk,y),這樣一來,每個fk 可以單獨地做優化,與其他函數無關,而特化后的函數列表至少不會比原來的f(x,y)慢。唯一的問題是,x 的取值可能很多,比如x 是一個int,則如果采用AOT 方式來特化,則需要編譯42 億多個函數,這顯然是不現實的,但是JIT 就有可能對這個場景做優化,原因在于,x 的取值雖然很多,但在一個具體運行過程中范圍相對小,甚至是很小,這符合二八定律
于是,在運行時我們可以對函數f 做監控,統計每次輸入的x 的值,如果發現這些值的分布不平均,比如x 為123 的情況占大多數,則動態特化一個f123(y),對其進行高度優化,然后修改f 函數為:
```
func f(x, y):
if x == 123:
return f123(y)
... //f 的正常流程
```
于是只需要一個特化函數,就能帶來運行時效率的提升,這就是JIT 特化的優勢對很多程序來說,對這種數值做監控和特化可能性價比不高,因為不是每個函數的輸入值范圍都呈現不平衡狀態,或者說不是那么明顯,但上面這個例子中,x和y 不一定是變量,也可以是類型,這樣一來對動態類型語言就有很大的意義
前面講過,在C++中可以用模板來實現鴨子類型,實質是通過代碼替換來實現類型靜態化,C++這個方式雖然效率高,但渠道是通過靜態編譯中的全文分析,是AOT 編譯,如果改成稍微動態性強一些的語言,就用不上了。在動態類型中,一個函數如果有k 個參數,有n 個可能類型,則AOT 需要將一個函數擴展為n^k個特化實例,n 和k 稍大一點就不可操作了,何況本身就是動態類型,n 的范圍都不一定在編譯期能知道
對這種場景,JIT 就可以通過統計的方式來選擇性地特化,這個的可行性和現實意義更大,原因在于,程序員在用動態類型寫程序的時候,比如寫一個函數:
```
func f(x, y):
return x + y
```
理論上,這個函數可以接受任意類型的x 和y,只要x 能和y 相加即可,但具體到一個確定的程序,這個函數的業務意義一般是固定的,或者是做字符串拼接,或者是數值相加,很少說寫一個函數,接收八竿子打不著的不同的類型還能運算,而且還是程序員刻意這么設計,就像前面講過的C++模板的二義性一樣,基本見不到這種需求,所以在函數的輸入參數類型上,符合二八定律。于是對于上述代碼,假設x 和y 絕大多數情況下都是整數,則進行特化(假設這個偽代碼中不考慮整數溢出):
```
func f(x, y):
if not (x instanceof int and y instanceof int):
//有一個不是整數,走原有流程
return x + y
//整數加法的特化流程
internal_code:
int ix = get_internal_int(x)
int iy = get_internal_int(y)
int iresult
asm:
push ... //當前狀態壓棧
mov eax, ix
mov ebx, iy
add eax, ebx
mov iresult, eax
pop ... //狀態出棧
return build_int_object(iresult)
```
當然這只是個例子,如果只是為了一個加法,這多少有點小題大做,但如果f 的邏輯較為復雜,優化就很明顯了
還可以逆向思維一下,AOT 難以實現特化的原因是無法考慮所有情況,但我們也沒有必要考慮所有情況,實際上類型使用的二八定律本身也在另一個二八定律里,具體到int 類型,一個絕大多數使用到的類型都是int 的程序在所有程序中占絕大多數,至少在一個有限的領域是這樣,因此干脆對于每個函數都只做int相關的特化,這樣2k 種情況還算能接受(實際情況數比2k 低很多,因為很多參數如果被假定為int,會語法錯誤,就不用假設了),如果再做的好一點,還可以做成編譯器選項,由用戶來指定AOT 的時候對哪個類型特化,這樣就比較完美了
除類型的動態性外,其他動態性也可以類似討論,僅拿上篇的例子,不贅述了:
```
for i in range(n):
print(i)
轉換為: if not (range is builtins.range and print is builtins.print):
for i in range(n):
print(i) else:
internal_code:
long tmp = get_internal_long(n)
long i
//這里應該用匯編,僅表個意思
for (i = 0; i < tmp; ++ i):
print_long(i)
```
需要在程序啟動時在builtins 里面保存默認函數,用于檢測當前運行環境是否被用戶修改過,這樣就兼顧了效率和動態性,跟上面一樣,這里JIT 或AOT 實現都可以。
- 第一章 熱修復設計
- 第一節、AOT/JIT & dexopt 與dex2oat
- 一、AOT/JIT
- 二、dexopt 與dex2oat
- 第二節、熱修復設計之CLASS_ISPREVERIFIED 問題
- 一、前言
- 二、建立測試Demo
- 三、制作補丁
- 四、加載補丁
- 五、CLASS_ISPREVERIFIED
- 第三節、熱修復設計之熱修復原理
- 一、Android 熱修復
- 二、Android 虛擬機和編譯加載順序
- 三、混合模式的理解
- 四、源碼類到機器執行的文件過程
- 五、補丁包
- 六、類補丁生效原理
- 七、Davlik 虛擬機的限制
- 八、Davlik Class resolved by unexpected DEX: 限制和處理方式
- 九、類加載器的雙親委派加載機制
- 第四節、Tinker 的集成與使用(自動補丁包生成)
- 一、簡述
- 二、Tinker 組件依賴
- 三、Tinker 的配置及任務
- 四、Tinker 封裝與拓展
- 五、編寫Application 的代理類
- 六、常用API
- 七、測試
- 八、細節
- 第二章 插件化設計
- 第一節、Class 文件與Dex 文件的結構解讀
- 一、Class 文件
- 二、Dex 文件
- 三、Class 文件和Dex 文件對比
- 第二節、Android 資源加載機制詳解
- 第三節、四大組件調用原理
- 第四節、so 文件加載機制
- 第五節、Android 系統服務實現原理
- 第三章 組件化框架設計
- 第一節、阿里巴巴開源路由框——ARouter 原理分析
- 第二節、APT 編譯時期自動生成代碼&動態類加載
- 第三節、Java SPI 機制
- 第四節、AOP&IOC
- 第五節、手寫組件化架構
- 第四章 圖片加載框架
- 第一節 圖片加載框架選型
- 第二節 Glide 原理分析
- 第三節 手寫圖片加載框架實戰
- 第五章 網絡訪問框架設計
- 第一節 網絡通信必備基礎
- 第二節 OkHttp 源碼解讀
- 第三節 Retrofit2 源碼解析
- 第六章 RXJava響應式編程框架設計
- 第一節 RXJava之鏈式調用
- 第二節 RXJava之擴展的觀察者模式
- 第三節 RXJava之事件變換設計
- 第四節 Scheduler 線程控制
- 第七章 IOC架構設計
- 第一節 依賴注入與控制反轉
- 第二節 ButterKnife 原理上篇、中篇、下篇
- 第三節 IOC架構設計之Dagger2架構設計
- 第八章 Android架構組件 JetPack
- 第一節 LiveData的工作原理
- 第二節 Navigation 如何解決tabLayout 問題
- 第三節 ViewModel 如何感知View 生命周期及內核原理
- 第四節 Room 架構方式方法
- 第五節 dataBinding 為什么能夠支持MVVM
- 第六節 WorkManager 內核揭秘
- 第七節 Lifecycles 生命周期