本文已收錄GitHub,更有互聯網大廠面試真題,面試攻略,高效學習資料等前端
Java 程序員幾乎都瞭解 Spring。它的 IoC(依賴反轉)和 AOP(面向切面編程)功能很是強大、易用。而它背後的字節碼生成技術(在運行時,根據須要修改和生成 Java 字節碼的技術)就是就是一項重要的支撐技術。java
Java 字節碼可以在 JVM(Java 虛擬機)上解釋執行,或即時編譯執行。其實,除了Java,JVM 上的 Groovy、Kotlin、Closure、Scala 等不少語言,也都須要生成字節碼。另外,playscript 也能夠生成字節碼,從而在 JVM 上高效地運行!git
並且,字節碼生成技術頗有用。你能夠用它將高級語言編譯成字節碼,還能夠向原來的代碼中注入新代碼,來實現對性能的監測等功能。程序員
目前,我就有一個實際項目的需求。咱們的一個產品,須要一個規則引擎,解析自定義的DSL,進行規則的計算。這個規則引擎處理的數據量比較大,因此它的性能越高越好。所以,若是把 DSL 編譯成字節碼就最理想了。github
既然字節碼生成技術有很強的實用價值,那麼本文就帶你掌握它。面試
我會先帶你瞭解 Java 的虛擬機和字節碼的指令,而後藉助 asm 這個工具,生成字節碼,最後,再實現從 AST 編譯成字節碼。經過這樣一個過程,你會加深對 Java 虛擬機的瞭解,掌握字節碼生成技術,從而更加了解 Spring 的運行機制,甚至有能力編寫這樣的工具!算法
字節碼是一種二進制格式的中間代碼,它不是物理機器的目標代碼,而是運行在 Java 虛擬機上,能夠被解釋執行和即時編譯執行。編程
在講後端技術時,我強調的都是,如何生成直接在計算機上運行的二進制代碼,這比較符合C、C++、Go 等靜態編譯型語言。但若是想要解釋執行,除了直接解釋執行 AST 之外,我沒有講其餘解釋執行技術。後端
而目前更常見的解釋執行的語言,是採用虛擬機,其中最典型的就是 JVM,它可以解釋執行 Java 字節碼。數組
而虛擬機的設計又有兩種技術:一是基於棧的虛擬機;二是基於寄存器的虛擬機。
標準的 JVM 是基於棧的虛擬機(後面簡稱「棧機」)。
每個線程都有一個 JVM 棧,每次調用一個方法都會生成一個棧楨,來支持這個方法的運行。棧楨裏面又包含了本地變量數組(包括方法的參數和本地變量)、操做數棧和這個方法所用到的常數。
棧機是基於操做數棧作計算的。以「2+3」的計算爲例,只要把它轉化成逆波蘭表達式,「2 3 +」,而後按照順序執行就能夠了。也就是:先把 2 入棧,再把 3 入棧,再執行加法指令,這時,要從棧裏彈出 2 個操做數作加法計算,再把結果壓入棧。
你能夠看出,棧機的加法指令,是不須要帶操做數的,就是簡單的「iadd」就行,這跟你以前學過的 IR 都不同。爲何呢?由於操做數都在棧裏,加法操做須要 2 個操做數,從棧裏彈出 2 個元素就好了。
也就是說,指令的操做數是由棧肯定的,咱們不須要爲每一個操做數顯式地指定存儲位置,因此指令能夠比較短,這是棧機的一個優勢。
接下來,咱們聊聊字節碼的特色。
字節碼是什麼樣子的呢?我編寫了一個簡單的類,其中的 foo() 方法實現了一個簡單的加法計算,你能夠看看它對應的字節碼是怎樣的:
publicclassMyClass{ publicintfoo(inta){ returna+3; } }
在命令行終端敲入下面兩行命令,生成文本格式的字節碼文件:
javacMyClass.java javap-vMyClass>MyClass.bc
打開 MyClass.bc 文件,你會看到下面的內容片斷:
publicintfoo(int); Code: 0:iload_1 //把下標爲1的本地變量入棧 1:iconst_3 //把常數3入棧 2:iadd //執行加法操做 3:ireturn //返回
其中,foo() 方法一共有四條指令,前三條指令是計算一個加法表達式 a+3。這徹底是按照逆波蘭表達式的順序來執行的:先把一個本地變量入棧,再把常數 3 入棧,再執行加法運算。
若是你細心的話,應該會發現:把參數 a 入棧的第一條指令,用的下標是 1,而不是 0。這是由於,每一個方法的第一個參數(下標爲 0)是當前對象實例的引用(this)。
我提供了字節碼中,一些經常使用的指令,增長你對字節碼特色的直觀認識,完整的指令集能夠參見JVM 的規格書:
其中,每一個指令都是 8 位的,佔一個字節,並且 iload_0,iconst_0 這種指令,甚至把操做數(變量的下標、常數的值)壓縮進了操做碼裏,能夠看出,字節碼的設計很注重節省空間。
根據這些指令所對應的操做碼的數值,MyClass.bc 文件中,你所看到的那四行代碼,變成二進制格式,就是下面的樣子:
你能夠用"hexdump MyClass.class"顯示字節碼文件的內容,從中能夠發現這個片斷(就是橙色框裏的內容):
如今,你已經初步瞭解了基於棧的虛擬機,與此對應的是基於寄存器的虛擬機。這類虛擬機的運行機制跟機器碼的運行機制是差很少的,它的指令要顯式地指出操做數的位置(寄存器或內存地址)。它的優點是:能夠更充分地利用寄存器來保存中間值,從而能夠進行更多的優化。
例如,當存在公共子表達式時,這個表達式的計算結果能夠保存在某個寄存器中,另外一個用到該公共子表達式的指令,就能夠直接訪問這個寄存器,不用再計算了。在棧機裏是作不到這樣的優化的,因此基於寄存器的虛擬機,性能能夠更高。而它的典型表明,是 Google公司爲 Android 開發的 Dalvik 虛擬機和 Lua 語言的虛擬機。
這裏你須要注意,棧機並非不用寄存器,實際上,操做數棧是能夠基於寄存器實現的,寄存器放不下的再溢出到內存裏。只不過棧機的每條指令,只能操做棧頂部的幾個操做數,因此也就沒有辦法訪問其它寄存器,實現更多的優化。
如今,你應該對虛擬機以及字節碼有了必定的瞭解了。那麼,如何藉助工具生成字節碼呢?你可能會問了:爲何不純手工生成字節碼呢?固然能夠,只不過藉助工具會更快一些。
就像你生成 LLVM 的 IR 時,也曾得到了 LLVM 的 API 的幫助。因此,接下來我會帶你認識 asm 這個工具,並藉助它爲咱們生成字節碼。
其實,有不少工具會幫咱們生成字節碼,好比 Apache BCEL、Javassist 等,選擇 asm 是由於它的性能比較高,而且它還被 Spring 等著名軟件所採用。
asm是一個開源的字節碼生成工具。Grovvy 語言就是用它來生成字節碼的,它還能解析Java 編譯後生成的字節碼,從而進行修改。
asm 解析字節碼的過程,有點像 xml 的解析器解析 xml 的過程:先解析類,再解析類的成員,好比類的成員變量(Field)、類的方法(Mothod)。在方法裏,又能夠解析出一行行的指令。
你須要掌握兩個核心的類的用法:
這兩個類若是配合起來用,就能夠一邊讀入,作必定修改後再寫出,從而實現對原來代碼的修改。
咱們先試驗一下,用 ClassWriter 生成字節碼,看看能不能生成一個跟前面示例代碼中的MyClass 同樣的類(咱們能夠稱呼這個類爲 MyClass2),裏面也有一個如出一轍的 foo函數。相關代碼參考genMyClass2()方法,這裏只拿出其中一段看一下:
//////建立foo方法 MethodVisitormv=cw.visitMethod(Opcodes.ACC_PUBLIC,"foo", "(I)I",//括號中的是參數類型,括號後面的是返回值類型 null,null); //添加參數a mv.visitParameter("a",Opcodes.ACC_PUBLIC); mv.visitVarInsn(Opcodes.ILOAD,1); //iload_1 mv.visitInsn(Opcodes.ICONST_3); //iconst_3 mv.visitInsn(Opcodes.IADD); //iadd mv.visitInsn(Opcodes.IRETURN); //ireturn //設置操做數棧最大的幀數,以及最大的本地變量數 mv.visitMaxs(2,2); //結束方法 mv.visitEnd(); //////建立foo方法 MethodVisitormv=cw.visitMethod(Opcodes.ACC_PUBLIC,"foo", "(I)I",//括號中的是參數類型,括號後面的是返回值類型 null,null); //添加參數a mv.visitParameter("a",Opcodes.ACC_PUBLIC); mv.visitVarInsn(Opcodes.ILOAD,1); //iload_1 mv.visitInsn(Opcodes.ICONST_3); //iconst_3 mv.visitInsn(Opcodes.IADD); //iadd mv.visitInsn(Opcodes.IRETURN); //ireturn //設置操做數棧最大的幀數,以及最大的本地變量數 mv.visitMaxs(2,2); //結束方法 mv.visitEnd();
從這個示例代碼中,你會看到兩個特色:
執行這個程序,就會生成 MyClass2.class 文件。
把 MyClass2.class 變成可讀的文本格式以後,你能夠看到它跟 MyClass 的字節碼內容幾乎是同樣的,只有類名稱不一樣。固然了,你還能夠寫一個程序調用 MyClass2,驗證一下它是否可以正常工做。
發現了嗎?只要熟悉 Java 的字節碼指令,在 asm 的幫助下,你能夠很方便地生成字節碼!
既然你已經能生成字節碼了,那麼不如趁熱打鐵,把編譯器前端生成的 AST 編譯成字節碼,在 JVM 上運行?由於這樣,你就能從前端到後端,完整地實現一門基於 JVM 的語言了!
基於 AST 生成 JVM 的字節碼的邏輯仍是比較簡單的,比生成針對物理機器的目標代碼要簡單得多,爲何這麼說呢?主要有如下幾個緣由:
按照這個思路,你能夠在 playscript-java 中增長一個ByteCodeGen的類,針對少許的語言特性作一下字節碼的生成。最後,咱們再增長一點代碼,可以加載並執行所生成的字節碼。運行下面的命令,能夠把bytecode.play示例代碼編譯並運行。
javaplay.PlayScript-bcbytecode.play
固然了,咱們只實現了 playscript 的少許特性,不過,若是在這個基礎上繼續完善,你就能夠逐步實現一門完整的,基於 JVM 的語言了。
我在開篇提到,Java 程序員大部分都會使用 Spring。Spring 的 IoC(依賴反轉)和AOP(面向切面編程)特性幾乎是 Java 程序員在面試時必被問到的問題,瞭解 Spring 和字節碼生成技術的關係,能讓你在面試時更輕鬆。
Spring 的 AOP 是基於代理(proxy)的機制實現的。在調用某個對象的方法以前,要先通過代理,在代理這兒,能夠進行安全檢查、記日誌、支持事務等額外的功能。
Spring 採用的代理技術有兩個:一個是 Java 的動態代理(dynamic proxy)技術;一個是採用 cglib 自動生成代理,cglib 採用了 asm 來生成字節碼。
Java 的動態代理技術,只支持某個類所實現的接口中的方法。若是一個類不是某個接口的實現,那麼 Spring 就必須用到 cglib,從而用到字節碼生成技術來生成代理對象的字節碼。
本文主要帶你瞭解了字節碼生成技術。字節碼生成技術是 Java 程序員很是熟悉的Spring 框架背後所依賴的核心技術之一。若是想要掌握這個技術,你須要對 Java 虛擬機的運行原理、字節碼的格式,以及常見指令有所瞭解。我想強調的重點以下:
運行程序的虛擬機有兩種設計:一個是基於棧的;一個是基於寄存器的。
基於棧的虛擬機不用顯式地管理操做數的地址,所以指令會比較短,指令生成也比較容易。而基於寄存器的虛擬機,則能更好地利用寄存器資源,也能對代碼進行更多的優化。
你要可以在大腦中圖形化地想象出棧機運行的過程,從而對它的原理理解得更清晰。
asm 是一個字節碼操縱框架,它能幫你修改和生成字節碼,若是你有這方面的需求,能夠採用這樣的工具。
在這裏,我也建議 Java 程序員,多多瞭解 JVM 的運行機制,和 Java 字節碼,這樣會更好地把握 Java 語言的底層機制,從而更利於本身職業生涯的發展。