Java位元組碼忍者禁術

weixin_33806914發表於2015-04-24

Java語言本身是由Java語言規格說明(JLS)所定義的,而Java虛擬機器的可執行位元組碼則是由一個完全獨立的標準,即Java虛擬機器規格說明(通常也被稱為VMSpec)所定義的。\

JVM位元組碼是通過javac對Java原始碼檔案進行編譯後生成的,生成的位元組碼與原本的Java語言存在著很大的不同。比方說,在Java語言中為人熟知的一些高階特性,在編譯過程中會被移除,在位元組碼中完全不見蹤影。\

這方面最明顯的一個例子莫過於Java中的各種迴圈關鍵字了(for、while等等),這些關鍵字在編譯過程中會被消除,並替換為位元組碼中的分支指令。這就意味著在位元組碼中,每個方法內部的流程控制只包含if語句與jump指令(用於迴圈)。\

在閱讀本文前,我假設讀者對於位元組碼已經有了基本的瞭解。如果你需要了解一些基本的背景知識,請參考《Java程式設計師修煉之道》(Well-Grounded Java Developer)一書(作者為Evans與Verburg,由Manning於 2012年出版),或是來自於RebelLabs的這篇報告(下載PDF需要註冊)。\

讓我們來看一下這個示例,它對於還不熟悉的JVM位元組碼的新手來說很可能會感到困惑。該示例使用了javap工具,它本質上是一個Java位元組碼的反彙編工具,在下載的JDK或JRE中可以找到它。在這個示例中,我們將討論一個簡單的類,它實現了Callable介面:\

\public class ExampleCallable implements Callable {\    public Double call() {\        return 3.1415;\    }\}
\

我們可以通過對javap工具進行最簡單形式的使用,對這個類進行反彙編後得到以下結果:\

\$ javap kathik/java/bytecode_examples/ExampleCallable.class\Compiled from \"ExampleCallable.java\"\public class kathik.java.bytecode_examples.ExampleCallable \       implements java.util.concurrent.Callable {\  public kathik.java.bytecode_examples.ExampleCallable();\  public java.lang.Double call();\  public java.lang.Object call() throws java.lang.Exception;\}
\

這個反彙編後的結果看上去似乎是錯誤的,畢竟我們只寫一個call方法,而不是兩個。而且即使我們嘗試手工建立這兩個方法,javac也會提示,程式碼中有兩個具有相同名稱和引數的方法,它們僅有返回型別的不同,因此這段程式碼是無法編譯的。然而,這個類確確實實是由上面那個真實的、有效的Java原始檔所生成的。\

這個示例能夠清晰地表明在使用Java中廣為人知的一種限制:不可對返回型別進行過載,其實這只是Java語言的一種限制,而不是JVM字元碼本身的強制要求。javac確實會在程式碼中插入一些不存在於原始的類檔案中的內容,如果你為此感到擔憂,那大可放心,因為這種事每時每刻都在發生!每一位Java程式設計師最先學到的一個知識點就是:“如果你不提供一個建構函式,那麼編譯器會為你自動新增一個簡單的建構函式”。在javap的輸出中,你也能看到其中有一個建構函式存在,而它並不存在於我們的程式碼中。\

這些額外的方法從某種程度上表明,語言規格說明的需求比VM規格說明中的細節更為嚴格。如果我們能夠直接編寫位元組碼,就可以實現許多“不可能”實現的功能,而這種位元組碼雖然是合法的,卻沒有任何一個Java編譯器能夠生成它們。\

舉例來說,我們可以建立出完全不含建構函式的類。Java語言規格說明中要求每個類至少要包含一個建構函式,而如果我們在程式碼中沒有加入建構函式,javac會自動加入一個簡單的void建構函式。但是,如果我們能夠直接編寫位元組碼,我們完全可以忽略建構函式。這種類是無法例項化的,即使通過反射也不行。\

我們的最後一個例子已經接近成功了,但還是差一口氣。在位元組碼中,我們可以編寫一個方法,它將試圖呼叫一個其它類中定義的私有方法。這段位元組碼是有效的,但如果任何程式打算載入它,它將無法正確地進行連結。這是因為在型別載入器中(classloader)的校驗器會檢測出這個方法呼叫的訪問控制限制,並且拒絕這個非法訪問。\

介紹ASM

\

如果我們打算在建立的程式碼中實現這些超越Java語言的行為,那就需要完全手動建立這樣的一個類檔案。由於這個類檔案的格式是兩進位制的,因此可以選擇使用某種類庫,它能夠讓我們對某個抽象的資料結構進行操作,隨後將其轉換為位元組碼,並通過流方式將其寫入磁碟。\

具備這種功能的類庫有多個選擇,但在本文中我們將關注於ASM。這是一個非常常見的類庫,在Java 8分發包中有一個以內部API的形式提供的版本(其內容稍有不同)。對於使用者程式碼來說,我們選擇使用通用的開源類庫,而不是JDK中提供的版本,畢竟我們不應當依賴於內部API來實現所需的功能。\

ASM的核心功能在於,它提供了一種API,雖然它看上去有些神祕莫測(有時也會顯得有些粗糙),但能夠以一種直接的方式反映出位元組碼的資料結構。\

我們看到的Java執行時是由多年之前的各種設計決策所產生的結果,而在後續各個版本的類檔案格式中,我們能夠清晰地看到各種新增的內容。\

ASM致力於儘量使構建的類檔案接近於真實形態,因此它的基礎API會分解為一系列相對簡單的方法片段(而這些片段正是用於建模的二進位制所關注的)。\

如果程式設計師打算完全手動編寫類檔案,就必需理解類檔案的整體結構,而這種結構是會隨時改變的。幸運的是,ASM能夠處理多個不同Java版本中的類檔案格式之間的細微差別,而Java平臺本身對於可相容性的高要求也側面幫助了我們。\

一個類檔案依次包含以下內容:\

  • 某個特殊的數字(在傳統的Unix平臺上,Java中的特殊數字是這個歷史悠久的、人見人愛的0xCAFEBABE)\
  • 正在使用中的類檔案格式版本號\
  • 常量\
  • 訪問控制標記(例如類的訪問範圍是public、protected還是package等等)\
  • 該類的型別名稱\
  • 該類的超類\
  • 該類所實現的介面\
  • 該類擁有的欄位(處於超類中的欄位上方)\
  • 該類擁有的方法(處於超類中的方法上方)\
  • 屬性(類級別的註解)

可以用下面這個方法幫助你記憶JVM類檔案中的主要部分:\

f3bad89eed0532dc848e4a69b82d1ee7.png

\

ASM中提供了兩個API,其中最簡單的那個依賴於訪問者模式。在常見的形式中,ASM只包含最簡單的欄位以及ClassWrite類(當已經熟悉了ASM的使用和直接操作位元組碼的方式之後,許多開發者會發現CheckClassAdapter是一個很實用的起點,作為一個ClassVisitor,它對程式碼進行檢查的方式,與Java的類載入子系統中的校驗器的工作方式非常想像。)\

讓我們看幾個簡單的類生成的例子,它們都是按照常規的模式建立的:\

  • 啟動一個ClassVisitor(在我們的示例中就是一個ClassWriter)\
  • 寫入頭資訊\
  • 生成必要的方法和建構函式\
  • 將ClassVisitor轉換為位元組陣列,並寫入輸出

示例

\public class Simple implements ClassGenerator {\ // Helpful constants\ private static final String GEN_CLASS_NAME = \"GetterSetter\";\ private static final String GEN_CLASS_STR = PKG_STR + GEN_CLASS_NAME;\\ @Override\ public byte[] generateClass() {\   ClassWriter cw = new ClassWriter(0);\   CheckClassAdapter cv = new CheckClassAdapter(cw);\   // Visit the class header\   cv.visit(V1_7, ACC_PUBLIC, GEN_CLASS_STR, null, J_L_O, new String[0]);\   generateGetterSetter(cv);\   generateCtor(cv);\   cv.visitEnd();\   return cw.toByteArray();\ }\\ private void generateGetterSetter(ClassVisitor cv) {\   // Create the private field myInt of type int. Effectively:\   // private int myInt;\   cv.visitField(ACC_PRIVATE, \"myInt\

相關文章