Java字節碼(Bytecode)與ASM簡單說明

"自由有諸多困難並且民主也並不完美,但我們絕不會築起一堵牆將我們的人民禁錮住."
-John Fitzgerald Kennedy 

這篇文章是為MCMod教程的Extra編第三篇配套寫成的,用於簡單說明Java字節碼和ASM的使用,由於作者是苦逼高三黨,平時只能在坐車回來路上用手機上網學Java字節碼╮(╯▽╰)╭難免可能有不妥甚至有誤的地方,求觸手神犇不要碾我,指出錯誤之處就好...

最後還要記住,這個說明的目的是速成,而不是深造...

另外在閱讀過程中請務必打開這個網頁:http://en.wikipedia.org/wiki/Javabytecodeinstruction_listings,以便隨時查閱信息.

什麼是Java字節碼?

自行百度/谷歌.

好吧我知道這麼說很欠抽,但我確實不擅長解釋它...簡單地說,一切Java操作都可以通過200種指令來表示出來,每一種指令通過一個8位整數(即byte)來標識,因此這些指令被稱為字節碼(Bytecode),一個編譯完畢的Java文件(.class)雖然並不是全部由字節碼組成,但至少字節碼佔了很大一部分.
200種指令聽起來比Java甚至是C#的保留關鍵字還要多,但事實上有不少是重複的.

Class文件的構造?

自行百度/谷歌.

隨便一個都講的比我好,不過請務必仔細看看常量池和方法這兩部分.

Java字節碼的棧幀

終於到我可以說的了...

如果你仔細讀了關於字節碼的文章後,你應該會知道Java中當一個方法被調用時會產生一個棧幀(Stack Frame),可以理解為那個方法被包含在了這個棧幀里,棧幀包括3個部分,局部變量區,操作數棧區和幀數據區.接下來我們主要要用到的是局部變量區和操作數棧區.

局部變量區有大小限制,最大大小已在編譯時被編譯器計算並指定, 它的結構(從0開始)是:
類的實例的引用(對於靜態方法則沒有這個)
參數
方法運行時創建的局部變量

舉例,對於:

void example1(int a,int b)
{
int c = a + b;
}

編譯成字節碼後,它的局部變量區最大大小是4,即0~3號位置是可用的,其中0號位是它的類的實例,1和2號位分別是a,b.3號位開始空缺,運行時被用於存儲c.

然而,局部變量區的基本單位是32位,這意味着如果你儲存long和double時它們需要佔2個位置.

舉例:

static long example2()
{
long l = 1000000L;
return l;
}

編譯成字節碼後,它的局部變量區最大大小只有2,首先它是靜態方法,意味着它不需要儲存它的類實例,其次它沒有參數.它只有一個long類型的局部變量,由於long要佔2個空位,因此它的最大大小只有2.

局部變量區的訪問沒有順序限制,可以隨意訪問.

然後是操作數棧區,操作數棧區是一個棧,主要用於存儲調用方法時的參數,比如當你要調用example1時,首先要先向操作數棧區壓入一個類的實例(或者說是實例的引用),然後壓入2個int變量,最後再調用example1方法,調用時棧幀會從操作數棧區里提出方法需要的數據.調用完畢後再將結果壓入操作數棧進行下一步的操作.

再上一個實例,以GameData類下的setName方法為例.
它的代碼是:

static void setName(Item item, String name, String modId)
{
int id = item.itemID;
ItemData itemData = idMap.get(id);
itemData.setName(name,modId);
}

編譯為字節碼後是:

aload0
getfield net/minecraft/item/Item/itemID I
istore3
getstatic cpw/mods/fml/common/registry/GameData/idMap Ljava/util/Map;
iload3
invokestatic java/lang/Integer/valueOf(I)Ljava/lang/Integer;
invokeinterface java/util/Map/get(Ljava/lang/Object;)Ljava/lang/Object; 2
checkcast cpw/mods/fml/common/registry/ItemData
astore 4
aload 4
aload1
aload2
invokevirtual cpw/mods/fml/common/registry/ItemData/setName(Ljava/lang/String;Ljava/lang/String;)V
return

每個字節碼的具體含義我們待會再了解,你現在只要知道xloadi是將局部變量區中的第i個變量/引用壓入操作數棧區,x是變量/引用的類型,xstorei是將操作數棧區棧頂的變量/引用提出棧並存入局部變量區的第i個位置.invokexxx是調用方法.getfield是獲取非靜態字段值.getstatic是獲取靜態字段值.
setName是個靜態方法,因此它的局部變量區無需儲存類的實例,所以它的0~2號局部變量分別對應3個參數.

第一行代碼int id = item.itemID創建了一個局部變量id,並用參數item的itemID字段為其賦值,故此我們的字節碼代碼是:
aload0 (將item的引用壓入操作數棧區)
getfield (從操作數棧棧頂提出一個引用(或者理解為類的實例),並從引用中獲取一個字段值然後壓入操作數棧區)
istore3 (將棧頂的值出棧然後存入局部變量區3號位)

第二行代碼ItemData itemData = idMap.get(id)創建了一個局部變量itemData.
getstatic (獲取一個靜態字段並壓入操作數棧)
iload3 (讀取3號局部變量並壓入操作數棧)
invokestatic (從棧頂取出一個值,並調用Integer類的靜態方法,然後將返回值壓入操作數棧.)
invokeinterface (調用被實現的接口方法,從棧頂取出兩個值,第一個值作為目標方法所在類的實例,第二個值作為參數值.最後將結果壓入操作數棧)
checkcast (檢查操作數棧頂的值的類型)
astore 4 (將操作數棧棧頂的值出棧並寫入4號局部變量)

第三行代碼itemData.setName(name,modId)調用了局部變量itemData的setName方法.
aload 4 (將4號局部變量的值壓入操作數棧區)
aload1 (將1號局部變量的值壓入操作數棧區)
aload2 (將2號局部變量的值壓入操作數棧區)
invokevirtual (調用一個類的非靜態方法,從棧頂取出3個值,第一個值作為目標方法所在類的實例,第二三個值作為參數值.最後將結果壓入操作數棧)

這個方法因為沒有返回值,所以代碼中沒有寫return.編譯器在這裡自動補上了.
return (退出方法,不返回任何值)

最後還得說,操作數棧區也是有最大棧深度的限制,也就是說操作數棧能存進多少數據是有限制的,這個數是在編譯時由編譯器指定.

ASM總覽

ASM是啥我就不介紹了...具體看Extra編第三篇.

對於字節碼的修改,ASM提供了2種模型,對象模型和事件模型.

對象模型

對象模型的本質其實是一個經過封裝了的事件模型,它通過一個樹狀圖來描述一個類,一個類包含多個節點,比如方法節點,字段節點,每個節點又有子節點,比如方法節點有操作碼子節點...

樹狀圖模式適宜處理簡單的類修改,它的優點是便於使用和學習,而且節省了代碼量.缺點是若要處理大量信息則會使代碼變得複雜,並且難以做到代碼復用.

獲取一個類節點(ClassNode)

ASM使用ClassReader來解析字節碼,ClassReader在解析完字節碼後可以通過accept方法來將結果寫入一個ClassNode.
下面這個代碼可以將一個字節碼解析為一個ClassNode.

static public ClassNode getClassNode(byte[] bytes)
{
ClassNode classNode = new ClassNode();
ClassReader classReader = new ClassReader(bytes);
classReader.accept(classNode, 0);
return classNode;
}

類節點結構

類節點包括如下信息:

類型 名稱 說明
 int  version  class文件的major版本,即編譯它的java版本.
 int  access  訪問級
 String  name  類名,採用全地址,如java/lang/Object
 String  signature  簽名,通常是null
 String  superName  基類類名,採用全地址.
 List<String>  interfaces  實現的接口,採用全地址
 String  sourceFile  源代碼文件,可能為null
 String  sourceDebug  debug源,可能為null
 String  outerClass  <似乎和外部類有關,沒研究過...>
 String  outerMethod  <似乎和匿名類有關,沒研究過...>
 String  outerMethodDesc  <似乎和匿名類有關,沒研究過...>
 List<AnnotationNode>  visibleAnnotations  可見的註解
 List<AnnotationNode>  invisibleAnnotations  不可見的註解
 List<Attribute>  attrs  類的Attribute
 List<InnerClassNode>  innerClasses  類的內部類
 List<FieldNode>  fields  類的字段
 List<MethodNode>  methods  類的方法

它們的訪問級全部為public,因此你能獲得任何你想要的信息.

字段節點(FieldNode)

字段節點被儲存在類節點的fields字段中,由於它是一個ArrayList,因此我們只能以遍歷集合的形式來搜索我們需要的字段節點,如下列代碼:

for(FieldNode fieldNode : (List)classNode.fields)
{
if(fieldNode.name.equals("password")) //判斷字段名是否匹配目標字段
{
//進行操作
}
}

字段節點包括如下信息:

類型 名稱 說明
 int  access  訪問級
 String  name  字段名
 String  signature  簽名,通常是null
 String  desc  類型,如"Ljava/lang/Object;"或"F"
 Object  value  初始值,很可能是null
 List<AnnotationNode>  visibleAnnotations  可見的註解
 List<AnnotationNode>  invisibleAnnotations  不可見的註解
 List<Attribute>  attrs  字段的Attribute

開發者可以在此修改字段,如下列代碼

for(FieldNode fieldNode : (List)classNode.fields)
{
if(fieldNode.name.equals("password"))
{
fieldNode.access = Opcodes.ACC_PUBLIC;
}
}

這個代碼可以將password字段的訪問級設為public.

關於訪問級的具體說明請看附錄.

開發者還可以為類添加字段,如:

FieldNode newFieldNode = new FieldNode(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "tempInt", "I", null, null);
classNode.fields.add(newFieldNode);

這個代碼是添加一個類型為int的靜態公共(static public)字段.注意這裡的類型使用的是Java類型描述符,如int的類型描述符是I,boolean的類型描述符是Z.具體的說明看附錄.

方法節點(MethodNode)

字段節點被儲存在類節點的methods字段中,由於它也是一個ArrayList,所以我們最好也還是用遍歷集合的形式來獲取方法節點.

for(MethodNode methodNode : (List)classNode.methods)
{
if(methodNode.name.equals("getName")) //判斷方法名是否匹配目標方法
{
//進行操作
}
}

注:構造函數的名字一定是,靜態構造函數的名字一定是

方法節點包含如下信息:

類型 名稱 說明
 int  access  訪問級
 String  name  方法名
 String  desc  方法的返回值和參數
 String  signature  簽名,通常是null
 List<String>  exceptions  可能返回的異常
 List<AnnotationNode>  visibleAnnotations  可見的註解
 List<AnnotationNode>  invisibleAnnotations  不可見的註解
 List<Attribute>  attrs  方法的Attribute
 Object  annotationDefault  默認的註解
 List[]<AnnotationNode>  visibleParameterAnnotations  可見的參數註解
 List[]<AnnotationNode>  invisibleParameterAnnotations  不可見的參數註解
 InsnList  instructions  操作碼列表
 List<TryCatchBlockNode>  tryCatchBlocks  try-catch塊列表
 int  maxStack  最大棧深度
 int  maxLocals  最大局部變量數量
 List<LocalVariableNode>  localVariables  局部變量節點

開發者對方法節點最長進行的操作是插入/修改/刪去指令.這裡就涉及到了Java字節碼和棧幀.

首先,ASM的目的是簡化開發者操作字節碼的難度,因此不可能讓用戶直接對着byte變量擼,所以ASM在使用時用操作碼來代替字節碼,用最簡單的話說,操作碼是ASM對字節碼的一個包裝.如果你不想對着一個byte擼的話,就來對着操作碼擼吧.
操作碼列表是方法節點中用於存儲操作碼的地方,這個集合中每一個元素都代表一行操作碼.
ASM將一行字節碼封裝為一個xxxInsnNode(某某操作碼節點),如ALOAD被封裝入VarInsnNode(變量操作碼節點),INVOKEVIRTUAL被封裝入MethodInsnNode(方法操作碼節點).
ASM之所以將字節碼封裝的如此詳細,是為了簡化開發者的操作,比如ALOAD指令除了它本身以外,後面還要附加一個參數值,用來指示局部變量位置.VarInsnNode便原生支持快速創建或修改一個帶有此參數值的字節碼.

所有xxxInsnNode都有一個共性,就是它們都是AbstractInsnNode(抽象操作碼節點)的派生類.AbstractInsnNode只關心字節碼的類型,而不關心參數值.

除AbstractInsnNode以外,所有的xxxInsnNode為:

 名稱  說明  額外參數
 FieldInsnNode  用於GETFIELD和PUTFIELD之類的字段操作的字節碼. String owner 字段所在的類
String name 字段的名稱
String desc 字段的類型
 FrameNode  棧映射幀的字節碼 因為不了解,所以不敢亂說...
 IincInsnNode  用於IINC變量自加操作的字節碼. int var 目標局部變量的位置
int incr 要增加的數
 InsnNode  一切無參數值操作的字節碼.如ALOAD_0,DUP
這裡有個疑問,POP也是無參數值的字節碼,但Javadoc
里沒提到InsnNode支持這個操作, 筆誤還是Bug?
 IntInsnNode  用於BIPUSH,SIPUSH和NEWARRAY這三個直接操作
整數的操作.
int operand 操作的整數值
 InvokeDynamicInsnNode  用於Java7新增的INVOKEDYNAMIC操作的字節碼 String name 方法的名稱
String desc 方法的描述
Handle bsm 句柄
Object[] bsmArgs 參數常量
 JumpInsnNode  用於IFEQ或GOTO等轉跳操作字節碼 LabelNode lable 目標lable
 LabelNode  一個用於表示轉跳點的Label節點
 LdcInsnNode  用於LDC等插入引用值的字節碼 Object cst 引用值
 LineNumberNode  一個表示行號的節點 int line 行號
LabelNode start 對應的
第一個Label
 LookupSwitchInsnNode  用於實現LOOKUPSWITCH操作的字節碼 LabelNode dflt default塊對
應的Lable
List<Integer> keys 值
List<LabelNode> labels
對應的Label
 MethodInsnNode  用於INVOKEVIRTUAL等傳統方法調用操作的字節碼.
不適用於Java7新增的INVOKEDYNAMIC
String owner 方法所在的類
String name 方法的名稱
String desc 方法的描述
 MultiANewArrayInsnNode  用於MULTIANEWARRAY操作的字節碼 String desc 類型
int dims 維數
 TableSwitchInsnNode  用於實現TABLESWITCH操作的字節碼 int min 鍵的最小值
int max 鍵的最大值
LabelNode dflt default塊對
應的Lable
List<LabelNode> labels
對應的Label
 TypeInsnNode  用於實現NEW,ANEWARRAY和CHECKCAST等
類型相關的操作的字節碼
String desc 類型
 VarInsnNode  用於實現ALOAD,ASTORE等局部變量操作的字節碼 int var 局部變量的編號

定位操作碼的位置

如果你明確知道你想要的操作碼在InsnList中的位置的話,你可以使用get方法來直接獲取一個操作碼,如:

MethodNode methodNode = ...;
AbstractInsnNode ainNode = methodNode.instructions.get(4);

這個代碼是獲得第5個操作碼.

但在大多數情況下我們都無法確定操作碼的具體位置,因此我們主要還是通過遍歷操作碼列表然後判斷其關鍵特徵來定位操作碼,例如這個代碼:

MethodNode methodNode = ...;
for(AbstractInsnNode ainNode : methodNode.instructions.toArray())
{
if(ainNode.getOpcode() == Opcodes.BIPUSH && ((IntInsnNode)ainNode).operand == 64)
{
....//進行操作
}
}

它能定位一個"BIPUSH 64"字節碼.有時一個程序會有多個相同的指令,這時開發者只能靠判斷前後文識別其特徵來定位,或者記下其命中次數然後設定在某一次進行操作.

(遍歷操作碼列表其實有2種方法,具體的討論參見附錄E)

替換一個操作碼

替換操作簡單到無腦,調用InsnList的set方法就能替換一個特定的操作碼實例,如:

MethodNode methodNode = ...;
for(AbstractInsnNode ainNode : methodNode.instructions.toArray())
{
if(ainNode.getOpcode() == Opcodes.BIPUSH && ((IntInsnNode)ainNode).operand == 64)
{
methodNode.instructions.set(ainNode, new VarInsnNode(Opcodes.ALOAD, 1));
}
}

我們在定位了"BIPUSH 64"字節碼的位置後,將封裝它的操作碼替換為一個新的VarInsnNode操作碼,這個新操作碼封裝了"ALOAD 1"字節碼.實現了將原程序中"將值設為64"替換為"將值設為局部變量1".

刪去一個操作碼

無腦到讓人腎爆,只要你知道一個操作碼實例,就能調用InsnList的remove方法將它隙間掉.

methodNode.instructions.remove(xxx);

插入操作碼

插入操作碼還稍微像回事,InsnList提供了以下幾類方法用於插入字節碼.
add(AbstractInsnNode insn) 將一個操作碼添加到這個InsnList的末尾
insert(AbstractInsnNode insn) 將一個操作碼插入到這個InsnList的開頭
insert(AbstractInsnNode location,AbstractInsnNode insn) 將一個操作碼插入到另一個已知的操作碼的下面
insertBefore(AbstractInsnNode location,AbstractInsnNode insn) 將一個操作碼插入到另一個已知的操作碼的上面

例如下面的代碼:

MethodNode methodNode = ...;
for(AbstractInsnNode ainNode : methodNode.instructions.toArray())
{
if(ainNode.getOpcode() == Opcodes.BIPUSH && ((IntInsnNode)ainNode).operand == 64)
{
methodNode.instructions.insert(ainNode, new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/awt/image/BufferedImage", "getWidth", "(Ljava/awt/image/ImageObserver;)I"));
methodNode.instructions.insert(ainNode, new InsnNode(Opcodes.ACONSTNULL));
methodNode.instructions.set(ainNode, new VarInsnNode(Opcodes.ALOAD, 1));
}
}

這個代碼的作用是將:
BIPUSH 64
替換為:
ALOAD 1
ACONSTNULL
INVOKEVIRTUAL java/awt/image/BufferedImage.getWidth(Ljava/awt/image/ImageObserver;)I

輸出字節碼

當我們修改完一個類節點後,就該將它輸出為字節碼了.ASM通過ClassWriter類來輸出字節碼.
下面這個代碼可以將一個ClassNode解析為字節碼.

static public byte[] getBytecode(ClassNode classNode)
{
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTEFRAMES);//讓ClassWriter自行計算最大棧深度和棧映射幀等信息
classNode.accept(classWriter);
return classWriter.toByteArray();
}

至此,對象模型的使用介紹差不多就到這了...接下來是事件模型.

(PS:有時修改字節碼會遇到棧映射幀(StackMap Frame)的驗證問題,因為這個問題並非總是會遇到,所以我就寫在了附錄里.)

事件模型

我們之前提到,對象模型是一個經過封裝的事件模型,換句話說事件模型才是本體,那我為什麼還要把事件模型放在第二位?因為事件模型使用起來難度較大,不易理解.而且有些事件模型的操作還要依賴對象模型的思路(比如對操作碼節點的操作).所以我放到了第二位.
事件模型採用了設計模式中的訪問者模式,訪問者模式被譽為"最難學的設計模式"...
故此,我先簡單解釋一下訪問者模式,GoF在<設計模式>一書中對訪問者模式的定義是"表示一個作用於某對象結構中的各元素的操作.它是你可以在不改各元素的類的前提下定義作用於這些元素的新操作".其實後半句對一流程序員來說才算萌點,我等蒟蒻看前半句就行了...
訪問者模式設定了這樣的一個故事背景:"有M個元素和N種算法,每個算法都能作用於任意一個元素,並且在不同的元素上有不同的運行方式."在過去你可能會想到在每一個元素的類中添加N個方法,一個方法實現一個算法.但這樣做會導致維護困難,代碼混亂和耦合度過高等問題.因此有了訪問者模式,現在有N個訪問者,每個訪問者擁有一個算法以及它的M種運行方式,當需要調用一個算法時,就讓對應的訪問者去訪問元素,然後讓訪問者根據被訪問對象自行選擇算法.

依然無法理解的話就再舉一個例子.以Minecraft為例,Block類的onBlockDestroyedByPlayer方法就是上文提到的算法,這個算法在不同的Block派生類中都有不同的運行方式(通過重寫方法來實現),如果將它改造成訪問者模式的話,那Block類的onBlockDestroyedByPlayer就會被去掉,取而代之的是增加一個accept方法,用來接收一個訪問者的來訪.我們會添加一個BlockDestroyedByPlayerVisitor類,並為它添加一個visit方法,用來回應一個Block類的同意訪問請求.算法的實現全在visit方法中實現.
你可能注意到我們沒有直接讓訪問者去操作元素,而是先讓元素接受(accept)訪問者,然後訪問者開始訪問(visit)元素,這是為了避免暴露不必要的內部細節.因為算法位於元素類的外部,這意味着它無法訪問元素的私有(private)字段,所以我們讓元素通過調用visit方法的方式將必要的私有參數傳遞過去.以下是個代碼例子:

Block block = ...;
BlockDestroyedByPlayerVisitor visitor = BlockDestroyedByPlayerVisitor.getInstance(); //我採用了單例模式,因為沒必要大量創建算法的實例.
block.accept(visitor);
accept的內容為:
public void accept(BlockVisitor visitor) //通常來說,訪問者都會實現同一個接口,或者統一派生自一個類
{
if(visitor instanceof BlockDestroyedByPlayerVisitor)
{
(BlockDestroyedByPlayerVisitor)visitor.visit(world,x,y,z,id);
}
}

這僅僅只是個例子而已,事實上它有不少問題所以不能投入實用,在這裡僅僅只是用於展示訪問者模式的實例而已...

訪問者模式的另一個好處就是可以用它來模擬事件系統,這也是這個操作模型被稱為事件模型的原因.一個被訪問者可以向訪問者通過調用各種visit的方式引發各種事件,以此推進程序流程.

ASM的事件模型採用的訪問者模式結合了這些優點,雖然學起來有些麻煩,但使用起來並不難.

類訪問者(ClassVisitor)

ClassVisitor被定義為一個能接收並解析ClassReader發來的信息的類.當你讓ClassVisitor訪問ClassReader時,ClassReader會開始字節碼的解析工作,並將結果源源不斷通過調用各種方法輸入ClassVisitor.其中只有visit方法只會並且一定會被調用一次,其它都不定.比如visitMethod方法,每當ClassReader解析出一個方法的字節碼時,都會調用一次visitMethod方法.由它生成一個MethodVisitor(方法訪問者).這個MethodVisitor不會消停下來,而是會被反饋回ClassReader,由ClassReader向MethodVisitor輸入更多的信息(如操作碼內容).這也是訪問者模式的另一個優勢,將工作分隔開,ClassVisitor只處理和類相關的事,方法的事情被外包給MethodVisitor處理.
我們之前說過對象模型是事件模型的一個封裝.其實,ClassNode就是ClassVisitor的一個派生類.它將ClassReader發來的信息分類儲存.MethodNode也是MethodVisitor的一個派生類,它將ClassReader發來的操作碼信息串成一個列表來儲存.
ClassWriter也是ClassVisitor的一個派生類,不過它不會儲存信息,它會將收到的信息立即轉譯成字節碼,並隨時輸出它們.
ClassReader則與一切都無關係,如果你再深入了解訪問者模式的話,你會知道訪問者模式中還存在一個叫ObjectStructure的東西,可以看做所有元素的集合以及事件源.在這裡ClassReader一定程度上充當了它的作用.

ClassReader解析過程

了解ClassReader解析過程有助於我們深入學習ASM的事件模型的使用方法.

(抱歉,這張圖右上角有個錯誤,"實例化"因為手滑出現了兩次...不過並不影響閱讀)
graph2big

(大圖下載地址:http://sdrv.ms/14O1dM9)

基本上,這張圖解答了關於ClassVisitor,FieldVistor,MethodVisitor的大部分問題.比如各個方法的作用,何時被調用等.

如果使用事件模型來修改字節碼,我們可以先創建一個ClassVisitor的派生類,建議繼承ClassNode而不是直接繼承ClassVisitor,這樣可以復用一些ClassNode的功能.之後再創建新的MethodVisitor派生類(同樣建議繼承MethodNode).
然後重寫新的ClassVisitor的visitMethod方法,判斷傳來的方法的名字,如果是我們要修改的方法,就實例化一個新的MethodVisitor返回過去(別忘了把你的MethodVisitor添加入methods列表裡).如果不是就調用基類方法.
最後根據你要修改的代碼,在新的MethodVisitor中重寫相關的方法,繼續用上文的BIPUSH 64舉例,BIPUSH指令屬於IntInsn,因此我們要重寫visitIntInsn方法.判斷傳來的字節碼類型(畢竟BIPUSH,SIPUSH和NEWARRAY都是IntInsn)以及參數(是否是64),之後如果滿足條件就改為新的代碼,不符合條件就交給基類方法處理.
比如下列代碼:

/<em>*</em>
* 有時代碼中存在多個BIPUSH 64,而我們卻只要修改特定的一個.
* 由於事件模型無法方便地分析代碼的前後文(只能分析前文),
* 所以這裡採用的是計算命中次數.
* 假設代碼中存在2個以上的BIPUSH 64,而我們只修改第二個.
*/
private int hit = 0;

@Override
public void visitIntInsn(final int opcode, final int operand)
{
if(opcode == Opcodes.BIPUSH && operand == 64)
{
if(hit++ != 2)
{
super.visitIntInsn(opcode, operand);
return;
}
instructions.add(new VarInsnNode(Opcodes.ALOAD, 1));
instructions.add(new InsnNode(Opcodes.ACONST_NULL));
instructions.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/awt/image/BufferedImage", "getWidth", "(Ljava/awt/image/ImageObserver;)I"));
return;
}
super.visitIntInsn(opcode, operand);
}

這個是MethodVisitor中的代碼,ClassVisitor中的代碼就沒有什麼可寫的了...僅僅是個判斷名字而已.

對於事件模型我能說的也只有這麼多...因為我更喜歡對象模型,雖然說大觸手們都愛用事件模型吧-w-...對於事件模型的使用,Aswang翻譯的ASM官方手冊中有更詳細的解釋.

另外那些代碼沒有經過實測= =如果你發現了問題請反饋給我...

結尾

"То робостью, то ревностью томим.
Я вас любил так искренно, так нежно,
Как дай вам бог любимой быть другим."
-Александр Сергеевич Пушкин

這麼快就到結尾了嗎...這只是一個簡單說明而已,僅僅只是一個Java字節碼和ASM的簡單說明,如果這兩個東西用寥寥幾千字就能說清楚的話,就不需要那些長篇的指導和手冊了.
無論如何,任何事情的學習都源於需求,實現於實踐,別說是熟練,僅僅只是入門便需要數十小時的實踐操作,學編程最好的途徑是讀別人的代碼和寫自己的代碼,繼續努力吧...

附錄A - 訪問級 

訪問級準確說是類/字段/方法的修飾名的集合.比如一個公共類的修飾符是public,一個公共接口的修飾符是public interfact.

ASM使用一個18位二進制數字來表示一個完整的訪問級,即000000000000000000,其中第1位(即最右邊的0)表示public修飾符,第4位表示static修飾符,因此一個static public類的訪問級是000000000000001001.
(也有可能是000000000000101001...高三黨時間苦逼啊,無力詳細測試了)

當然,ASM不會讓我們用這麼蠢的方式來設置訪問級,ASM的Opcodes類已經定義了各個修飾符的常量.

名稱(已略去ACC_前綴) 說明 16進制大小 適用
PUBLIC 公共 0x0001  類,方法,字段
PRIVATE 私有 0x0002  類,方法,字段
PROTECTED 內部保護 0x0004  類,方法,字段
STATIC 靜態 0x0008  方法,字段
FINAL Final... 0x0010  類,方法,字段
SUPER 將一個類標示為"超類",
定義看這裡:
http://t.cn/zYm7PMv
0x0020  類
SYNCHRONIZED 需要資源鎖方法 0x0020  方法
VOLATILE 需要同步的字段 0x0040  字段
BRIDGE 不了解...和泛型有關吧 0x0040  方法
VARARGS 參數不定的方法 0x0080  方法
TRANSIENT 不會被串行化的臨時字段 0x0080  字段
NATIVE JNI引用的外部方法 0x0100  方法
INTERFACE 表明此為接口 0x0200  類
ABSTRACT 抽象 0x0400  類,方法
STRICT 讓浮點數採用精確浮點 0x0800  方法
SYNTHETIC 和內部類以及匿名類有關 0x1000  類,方法,字段
ANNOTATION 表明此為註解 0x2000  類
ENUM 表明此為枚舉 0x4000  似乎是字段?
DEPRECATED ASM專用的一個東西? 0x20000  類,方法,字段

當我們需要計算一個訪問級時,簡單地用這些修飾符常量相加就可以獲得一個訪問級了.

比如當我需要一個公共+超類+抽象的訪問級時,用Opcodes.ACC_PUBLIC + Opcodes.ACC_SUPER + Opcodes.ACC_ABSTRACT就可以獲得一個訪問級了.

分析訪問級就需要用到位操作,這個就自行百度吧...

附錄B - Java類型描述符

變量類型在字節碼中有特殊的寫法,比如int會被寫為I,float會被寫為F,long會被寫為J等等...

以下為Java代碼中的變量類型和其在Java字節碼中的類型描述符.

變量類型 類型描述符 包裝類 包裝類類型描述符
int I(大寫i) Integer Ljava/lang/Integer;
short S Short Ljava/lang/Short;
long J Long Ljava/lang/Long;
boolean Z Boolean Ljava/lang/Boolean;
char C Character Ljava/lang/Character;
byte B Byte Ljava/lang/Byte;
float F Float Ljava/lang/Float;
double D Double Ljava/lang/Double;
void V Void Ljava/lang/Void;
Object L+類名(使用'/'作為
分隔符)+;
如: Ljava/lang/Object;
Lorg/objectweb/asm/MethodVisitor;
/ /
String Ljava/lang/String; / /
數組寫法:
X的N維數組 N個[+X的類型描述符 / /
int[] [I / /
byte[][] [[B / /
String[] [Ljava/lang/String; / /

附錄C - Java字節碼分類

坑中...

附錄D - 棧映射幀與字節碼錯誤

棧映射幀(StackMap Frame)是Java6以後引入的一種驗證機制,用於檢驗Java字節碼的正確性.它的方式是記錄每一個關鍵步驟時操作數棧的理論狀態,並在實際運行時將實際狀態和理論狀態對比,如果狀態不一樣則代表出現了錯誤.具體的介紹可以看這裡.

ASM可以自動計算棧映射幀,通常來說是極為有效的(儘管有人指出ASM自動計算棧映射幀會增加50%的額外運算,但誰在乎呢...),但偶爾你會遇到棧映射幀驗證失敗,比如:VerifyError: Inconsistent stackmap frames at branch target錯誤.

通常來說是有這幾個原因導致:
錯誤1.字節碼編寫錯誤,導致ASM計算失敗
ASM測不出你的錯誤,但JVM可以,比如代碼:

this.adouble *= (double)afloat;

其中,adouble是一個double類型字段,afloat是一個flaot類型的局部變量,位置為1.
編譯為字節碼後是:

ALOAD 0
DUP
GETFIELD apackage/Aclass.adouble : D
FLOAD 1
F2D
DMUL
PUTFIELD apackage/Aclass.adouble : D

我們要把它修改成this.adouble = 2.0D,也許你樂觀地以為只要把FLOAD 1換成LDC 2.0,然後去掉F2D和DMUL就可以了,但實際上如果你這樣做的話,會在運行時導致VerifyError錯誤.
這是因為對字節碼操作指令不熟悉而導致的,ALOAD是讀入一個實例引用(佔1個字節的空間),DUP是複製棧頂的數據,GETFIELD是以棧頂的數據為實例,讀取那個實例的字段(佔1~2個字節,視類型而定),FLOAD是讀入float(佔1個字節),F2D是將棧頂的數據(假設為float類型)轉換為double類型(佔2個字節).DMUL是將棧頂的4個字節視為2個double數據並相乘,並將結果壓回棧頂.PUTFIELD是將棧頂的2~3個字節視為2個數據(第一個視為實例,第二個視為值),並將值賦給實例中的字段.
也許你現在看出問題了,原先的操作數棧的狀態變化過程是:
graph3
然而,我們修改後的字節碼的運行狀態是:
graph4
感謝JVM的驗證機制將我們攔了下來,PUTFIELD會把adouble的數值的後半部分視為一個實例引用,然後試圖將2.0D寫進裡面,只有神知道會發生什麼,ASM也不知道會發生什麼,因此它算不出此時的棧映射圖,我們運行時也自然會遇到錯誤.

這是一種情況,此外還有一種容易犯錯的地方,就是忽視了數據的體積(也就是占的字節數).
假如我們打算換一種方案,在DMUL後面加入POP(粗俗理解為彈出棧頂數據)和LDC 2.0,來達到寫入的目的,但這樣做的後果同樣是驗證錯誤.
這也是因為對字節碼的認識不充分,將POP理解為彈出棧頂數據實在是俗不可耐,它的正確定義是彈出棧頂的1個字節.double變量佔2個字節,你只彈出了半個數據,還殘留下半個double佔在棧中,PUTFIELD又把那半個double當做實例,結果自然又是慘劇.
graph5
正確的辦法是使用POP2,它是從棧頂彈出2個字節,正好是1個double或long變量.

錯誤2:JDK版本錯誤
VerifyError錯誤在JDK7較為常見,據說可以在啟動時加入虛擬機參數-XX:-UseSplitVerifier來取消驗證;也可以將編譯環境換為JDK6.
但我認為這種情況少之又少,ASM4.0的changelog中明確說明支持Java7.

錯誤3:ASM腦抽
這個問題無解了,自行修正吧...
我認為這個情況幾乎不可能存在,十有九九九是你的字節碼寫錯了...

附錄E - Iterator VS ToArray&Foreach

遍歷MethodNode的instructions有兩種方法,一種是使用它自帶的Iterator(遍歷器),另一種是將它輸出為一個數組,然後用foreach遍歷一遍數組.

大多數大觸都會下意識地選擇Iterator,然而看下列代碼:

for(Iterator iterator = methodNode.instructions.iterator();iterator.hasNext();)
{
AbstractInsnNode ainNode = iterator.next();
if(ainNode.getOpcode() == Opcodes.GETFIELD && ((FieldInsnNode)ainNode).name.equal("afloat"))
{
//中心代碼
methodNode.instructions.set(ainNode.getNext(), new LdcInsnNode(Float.valueOf(1.00F)));
}
}

如果程序中有3個afloat = xxx,或者xxx = afloat.那麼你可能認為中心代碼會被運行3次,實際上它只會運行1次.

這是由於InsnList的set方法和內部的InsnListIterator的特性造成的(其實我認為與其說是特性,不如說是Bug),每一個AbstractInsnNode(簡稱節點)都有2個字段,上一個節點(prev)和下一個節點(next),set的原理是將記住目標節點的上一個節點(簡稱P節點)和下一個節點(簡稱N節點),然後將P節點的next設為新節點,新節點的prev設為P節點,N節點的prev設為新節點,新節點的next設為N節點,最後把舊節點的prev和next都設為null,這樣新節點就連入了鏈表,舊節點就成了一個無法到達的變量,可以坐等被GC回收了,這聽力來很美好,但也僅僅是聽起來.
事實上當set和Iterator用在一起時,set可以處理幾乎所有的節點,但惟獨不包括下一個節點(當前被遍歷的節點的next).因為Iterator內部維持着2個字段,它們也叫next和prev,分別是當前被遍歷節點的下一個節點和上一個節點.這時問題就來了,假設我們的Iterator正位於A節點上,A節點的下一個節點是B節點,我們通過methodNode.instructions.set(A.getNext(),ab)來將B節點修改為ab節點,修改結果很成功,ab節點指向了C節點(假設B的下一個節點是C節點),A節點指向了ab節點,B節點被孤立了出去.但我們忽略了一個問題,Iterator的next依然指向B節點,set方法忘了通知它.
結果就是當Iterator繼續開始遍歷時,開入了一個死胡同,這個死胡同就是B節點,它不指向任何節點,也不被任何節點指向(除了悶在鼓裡的Iterator),結果就是Iterator誤以為已經遍歷到盡頭,從而停止遍歷.

所以我們可以知道InsnList的Iterator實現的很失敗,至少是set方法很失敗.如果我們一定要用遍歷器,那隻能通過一個變量記住要修改的節點,然後在遍歷結束後統一修改.

這個事情在數組中完全沒有問題,因為我們是在遍歷一個InsnList的深層副本,對本體的操作完全不會影響到深層副本,比如:

for(AbstractInsnNode ainNode : methodNode.instructions.toArray())
{
if(ainNode.getOpcode() == Opcodes.GETFIELD && ((FieldInsnNode)ainNode).name.equal("afloat"))
{
//中心代碼
methodNode.instructions.set(ainNode.getNext(), new LdcInsnNode(Float.valueOf(1.00F)));
}
}

這個代碼完全沒有問題,它可以正常地被執行3次.

總之就是這個樣子,我比較喜歡toArray轉為數組後再遍歷,但這也不代表我們就可以完全否定Iterator,它總會有用的...

附錄F - Keep you stupid

ASM的目的是簡化程序員操作字節碼的難度,因此它絕對不可能再為你多添麻煩(ASM自身的bug除外...),眾所周知Java字節碼為了提高效率,將一種指令拆分成多種形式,比如LDC擁有三種形式,分別擁有不同的尋址能力,xLOAD有xLOAD_0等形式,其速度比xLOAD 0要快,但在ASM中你無需考慮這個,所有形式的LDC都被簡單視為LDC,所有xLOAD_0都被視為xLOAD 0,ASM會在輸出字節碼時自動優化.

更令人遺憾的是,手工優化不但是無用功,而且反而會導致ASM錯誤.比如如果你將
VarInsnNode(ALOAD,0)
寫為
InsnNode(42) //42=ALOAD_0
看上去兩者確實是等效的,在實際運行中也確實如此,但ASM的ClassWriter可不這麼認為,如果你讓ClassWriter自動計算棧映射幀(即加入COMPUTE_FRAMES參數)那麼ClassWriter會因為無法識別那個InsnNode(42)而出現謎のNPE,最終導致無法輸出正確的字節碼.

附錄G - ClassCircularityError

這個Error的解釋是"類循環引用",即A的父類是B,B的父類是A...這個看似不可能的事情卻可能在ClassWriter的getCommonSuperClass方法中出現,其發生的原因和機理我現在也沒發現...一個取巧的解決方式是自己創建個ClassWriter的派生類並重寫getCommonSuperClass方法,自行判斷其公共基類並返回值.