出處:https://segmentfault.com/a/1190000039157114
什麼是虛擬機器?“虛擬機器”是個非常大的概念,從字面意思理解,“虛擬機器”就是“虛擬的計算機”,我們在學習服務端程式設計時,相信大部分同學都接觸過虛擬機器。有這樣一種場景,由於我們日常使用的計算機大部分是Windows作業系統,但絕大多數的服務端軟體卻都執行在Linux系統上,假設我們在Windows上進行程式設計,就無法直接在Windows上進行測試,非常不方便。基於這樣的場景於是就有了虛擬機器,它的作用是可以在windows系統的基礎上執行Linux系統,然後我們就可以很方便的在windows系統上測試Linux系統的程式。這個Linux作業系統是透過某種技術手段虛擬出來的,中間的過程非常複雜,無法用三言兩語來描述。
今天想聊的虛擬機器和上面說的虛擬機器略有不同,但是它們要解決的問題是一樣的。上面說的虛擬機器,它虛擬出了一個完整的作業系統,我把它稱之為“作業系統級虛擬機器”。而我們今天要聊的虛擬機器它是針對程式語言的,它能達到的效果是,同一份程式碼執行在不同的作業系統上輸出相同的結果,可以實現一次編寫到處執行,我把它稱為“語言級虛擬機器”。我們非常熟悉的Java、PHP、Python等程式語言,實際上都是基於虛擬機器的語言,它們都具備跨平臺性,我們只需要編寫一次程式碼,就可以執行在不同的作業系統上,並且輸出幾乎完全相同的結果。
瞭解過系統程式設計的同學應該都知道,不同作業系統對於同一個功能所提供的“系統API”可能是不一樣的。例如 Windows和Linux系統都提供了網路監聽的API,但是它們對應的SOCKET API卻不同,假設我們使用平臺相關的程式語言(例如:C、C++),我們在程式設計時就必須要注意這樣的區別,並且針對不同的作業系統做相應的相容處理,否則程式在Linux系統上能正常執行,但是Windows就會報錯。這種類似的區別非常多,具體細節要看對應的系統程式設計手冊才知道。有的系統API完全不同,而有的僅僅是個別引數不同,方法名完全相同,程式設計師在編寫程式碼時需要時刻注意這些,才能編寫出健壯的跨平臺程式碼,這對新手來講是非常困難的,並且這樣一來,程式設計師就需要把很大一部分精力花費在相容性問題上,而不能專注於實際功能的開發。
有了虛擬機器之後,上面的問題就不復存在了。虛擬機器的作用簡單來說就是中介代理,好比我們初來乍到大城市要租房子,北上廣等大城市的房東那麼多,如果沒有房產中介(虛擬機器),我們就需要和N個房東對接,然後才能租到合適的房子;有了房產中介(虛擬機器),我們只需要告訴房產中介(虛擬機器)我們要租什麼樣的房子,由房產中介(虛擬機器)去協調各個房東,我們就能租到合適的房子,過程不同,最後的結果是相同的。同理,以Socket API呼叫為例,我們把編寫好的程式碼交給虛擬機器,再由虛擬機器來負責呼叫系統API,相當於中間加了一層中介代理,虛擬機器將根據作業系統選擇正確的Soekct API,來幫我們完成最終的功能。這樣的好處是程式設計師不再需要關注底層API的細節,可以專注於真正功能的編寫,虛擬機器幫我們遮蔽底層系統API的細節,並且程式設計的門檻也大大降低,程式碼健壯性也大大提高。
PHP的執行過程PHP解釋執行過程瞭解PHP的同學都知道,PHP是一種解釋型語言,也稱作指令碼語言,它的特點就是輕量、簡單易用。傳統的程式語言在執行前都需要進行編譯、連結,然後才能執行並輸出結果。而指令碼語言(PHP)則省略了這個過程,直接透過shell命令就能執行執行並輸出對應的結果,非常輕量、直觀、易上手。不瞞大家說,我在入坑程式設計時也學過Java,為什麼最後入了PHP的坑呢,可能就是這些特點吸引的我。
剛才我們只說了PHP的優點,但是大多數時候都是有得必有失,我想程式語言也一樣,PHP非常輕量、易上手那麼它必然是犧牲了某種優點為代價的,否則為什麼其它程式語言不這麼做呢。接下來我們就聊一聊PHP的執行過程,我想了解了PHP的執行過程,就能理解PHP語言設計上的取捨了。
以下是PHP在開啟了 Opcache 快取後程序執行的主要過程。
圖-1
從 圖-1 中可以看到,載入PHP程式碼檔案後,首先透過 詞法分析器(re2c/lex) ,從程式碼中提取出 單詞符號(token) ,然後再經過 語法分析器(yacc/bison) ,從token中發現語法結構後,生成 抽象語法樹(AST) ,再經由 靜態編譯器 生成 Opcode ,最後由 直譯器 模擬機器指令來執行每一條 Opcode 。
另外,當PHP開起了Opcache後,ZendVM會對Opcode進行快取處理,快取在共享記憶體中。不僅如此,ZendVM還會對編譯後的Opcode進行最佳化,編譯的最佳化技術包括 方法內聯 、 常量傳播 、 重複程式碼刪除 等。有了Opcache後,不僅可以省略掉 詞法分析、語法分析、靜態編譯等步驟,同時Opcode也被額外優化了,程式的執行效率比首次執行時的速度更快。
以上就是PHP解釋執行的過程,雖然解釋執行對程式設計師非常友好,省略了靜態編譯的步驟,但實際上這個過程並沒有省略,只是由虛擬機器幫我們完成了,以犧牲一部分效能為代價,換來了輕量、易用性、靈活性。其中 詞法分析、語法分析、靜態編譯、解釋執行 這些流程都是在執行時完成的。
編譯型語言執行過程瞭解過解釋型語言的執行過程後,作為對比我們再來看下 編譯型語言 的執行過程,來看看它相比比解釋型語言有什麼不同。
圖-2
從 圖-2 中我們可以看到,虛線框中的執行過程包括:詞法分析、語法分析、編譯,這3步在PHP解釋執行時也同樣有,唯一的區別是,C/C++這3步是提前由編譯器在編譯過程中完成的,這樣可以在執行時節省大量的時間和開銷。生成彙編程式碼後,第4步是 連結 彙編檔案,並生成可執行檔案,這裡的可執行檔案指的是二進位制的機器碼,CPU可以直接執行不需要再額外翻譯,這4個步驟合起來稱為 靜態編譯 。可以很明顯的看到, 編譯型語言 相對 解釋型語言 在前期需要做更多的工作,但換來的是更高的效能和執行效率。因此,一般在大型的專案中,由於對效能要求比較高,程式碼量也很大,如果採用解釋型語言會大大降低執行效率,使用靜態編譯型能夠獲得更好的執行效率,降低伺服器採購成本。
什麼是JIT?JIT可以說是虛擬機器中最有技術含量的技術,剛才我們分別講了解釋型語言和編譯型語言執行的過程,也分析了它們各自的優勢和劣勢,我們可以思考一下,有沒有一種技術,既有解釋型語言輕量、易上手的優點,同時也擁有編譯型語言的高效能,結論就是JIT。下面我們要介紹的就是程式語言中的JIT技術,它的全稱是“即時編譯”,具體指的是什麼呢?我們先來看下維基百科對即時編譯的定義。
在計算機技術中,即時編譯(英語:just-in-time compilation,縮寫為JIT;又譯及時編譯、實時編譯),也稱為動態翻譯或執行時編譯,是一種執行計算機程式碼的方法,這種方法涉及在程式執行過程中(在執行期)而不是在執行之前進行編譯。通常,這包括原始碼或更常見的位元組碼到機器碼的轉換,然後直接執行。實現JIT編譯器的系統通常會不斷地分析正在執行的程式碼,並確定程式碼的某些部分,在這些部分中,編譯或重新編譯。
剛才我們說了,JIT既擁有解釋型語言的輕量易用性,同時擁有高效能,那麼它是如何實現的呢?以PHP8中加入了JIT的特性為例,下圖描述了PHP開啟了JIT特性後的執行流程,PHP8-JIT是在Opcache最佳化的基礎上更進一步,將Opcache中儲存的Opcode最佳化後再進行編譯,將Opcode編譯成CPU可識別的可執行檔案,也就是二進位制檔案,相當於C++編譯後的可執行檔案,只不過這個過程不需要在執行前完成,而是在執行時,虛擬機器開啟後臺執行緒,將Opcode轉換成二進位制檔案,有了二進位制檔案快取後,當下次執行該邏輯時,CPU就可以直接執行,不需要再經過解釋,理論效能和C++一樣。這樣的好處就是既保留了PHP語言的易用性、靈活性,同時也獲得了高效能。
圖-3
JIT的觸發條件JIT實際上就是把執行時的一部分程式碼,轉換成可執行檔案並快取起來,加速下次程式碼的執行。那麼JIT是程式啟動後就會觸發嗎?
JIT在程式初次啟動時並不會起作用,可以理解為PHP/Java程式碼在首次執行時,其實仍然是以解釋的形式執行的,JIT需要在程式執行一段時間後才能真正觸發。說到這裡,大家有沒有跟我有一樣的疑問,為什麼JIT不在程式啟動時,就把所有的程式碼都轉換成可執行檔案快取起來,就像C++一樣,這樣豈不是效率更高。在Java語言中確實有少部分這樣的應用,但並不是主流。主要有以下幾方面的原因
全部編譯成二進位制檔案需要耗費很多時間,程式啟動會非常慢,這對於大型專案來說是不可接受的並不是所有的程式碼都有必要進行效能最佳化,大部分程式碼在實際場景中用的並不多編譯成二進位制會佔用很大的容量提前編譯好相當於是靜態的編譯,JIT編譯相對於靜態編譯有很多不可替代的優勢JIT的觸發條件,主要是基於“計數器的熱點探測”,虛擬機器會為每個方法(或者程式碼塊)建立計數器,如果執行次數超過一定的閾值就認為它是“熱點方法”,在達到閾值後,虛擬機器會開啟後臺執行緒將該程式碼塊編譯成可執行檔案,快取在記憶體中,加速下次執行的速度。以上只是簡單描述了熱點程式碼的觸發規則,實際的虛擬機器採用的規則,會比這個更復雜。
JIT&提前編譯的優劣勢JIT編譯器是在執行時進行的,我們很容易發現,它和提前編譯相比有幾個很明顯的劣勢。首先,JIT編譯需要消耗執行時的計算資源,原本這些資源可以用來執行程式,不管JIT編譯器如何最佳化(例如:分層編譯),這是始終沒辦法迴避的問題,其中最消耗資源的一步是“過程間分析”,比如分析這個方法是否永遠不可能被呼叫,抽象方法是否永遠只會呼叫單一版本的結論,這些資訊對生成高質量的程式碼有非常高的價值,但是要精確的得到這些資訊,必須要經過大量的耗時計算,消耗大量執行時的計算資源。反過來,如果這些耗時的工作的提前編譯時就完成了,執行時就只需享受高質量程式碼帶來的高效能,最多就是提前編譯時稍微慢一點,但這都是可以接受的。
說了這麼多,那JIT編譯和提前編譯相比,在效能最佳化上就真的沒什麼優勢了嗎?結論是不是的,JIT編譯有很多提前編譯不可替代的優勢。正是因為JIT編譯器是在執行時進行的,所以JIT編譯器能獲取到程式真實的資料,透過不斷收集程式執行時的監控資訊,並對這些資料進行分析,JIT編譯器可以對程式做一些激進的最佳化,這是提前的靜態編譯器做不到的。
首先是,效能分析制導最佳化。比如說JIT編譯器在執行時,透過程式執行的監控資料,如果發現某些程式碼塊被執行的特別頻繁,那可以集中最佳化這一塊程式碼,例如:給這段程式碼分配更好的暫存器、快取等。
然後是激進預測最佳化。比如說有一個介面,它的實現類有3個,但在真實執行過程中,95%以上的時間都在執行A這個實現類,透過資料的分析,那就可以激進的對它進行預測,每次都執行A,如果發現有幾次預測錯誤了,可以退回到解釋狀態再次執行,但只是小機率事件,並且不影響程式執行的結果。
最後是連結時最佳化,傳統的編譯器的步驟是編譯最佳化和連結是分開的,什麼意思呢?加入某個程式需要用到A、B、C 3個庫,編譯器先各自編譯這3個類庫,並且進行各種手段的最佳化,轉換成彙編程式碼儲存到檔案中,最後一步是將這3個彙編檔案連結起來,最終轉換成可執行檔案。這裡存在一個問題,A、B、C 3個庫在編譯時是分別進行最佳化的,假設A和B中有些方法是重複執行的,或者可以方法內聯來最佳化,那是無法做到的。但是JIT編譯器是的不同之處在於,它是執行時動態連結的,可以針對整個程式的呼叫棧進行最佳化,這樣的最佳化更加徹底。
總結寫這篇部落格的主要目的,是對自己這段時間學習虛擬機器相關技術的一個總結,在我谷歌搜尋PHP虛擬機器相關文章時,發現可參考的文章寥寥無幾。由於Java和PHP的執行原理很相近,我想可以透過學習Java虛擬機器來了解ZendVM的工作原理,Java虛擬機器非常成熟,可以說是虛擬機器的鼻祖,JVM世面上的優秀書籍非常多,JVM打開了我的新世界,讓我對虛擬機器有了全新的認識,JIT技術更是驚豔到我。
最後,PHP是世界上最好的語言!
參考《深入理解Java虛擬機器(第3版)》深入理解PHP opcode最佳化PHP 8新特性之JIT簡介PHP JIT in DepthJava 9 AOT 初探How PHP's Just In Time compiler works作者:MeetMax
出處:https://segmentfault.com/a/1190000039157114