一、HelloWorld 字節碼生成
眾所周知,Java 程序是在 JVM 上運行的,不過 JVM 運行的其實不是 Java 語言本身,而是 Java 程序編譯成的字節碼文件。可能一開始 JVM 是為 Java 語言服務的,不過隨着編譯技術和 JVM 自身的不斷發展和成熟,JVM 已經不僅僅只運行 Java 程序。任何能編譯成為符合 JVM 字節碼規范的語言都可以在 JVM 上運行,比較常見的 Scala、Groove、JRuby等。今天,我就從大家最熟悉的程序“HelloWorld”程序入手,分析整個 Class 文件的結構。雖然這個程序比較簡單,但是基本上包含了字節碼規范中的所有內容,因此即使以后要分析更復雜的程序,那也只是“量”上的變化,本質上沒有區別。
我們先直觀的看下源碼與字節碼之間的對應關系:
HelloWorld的源碼:
package com.paddx.test.asm; public class HelloWorld { public static void main(String[] args) { System.out.println("Hello,World!"); } }
編譯器采用JDK 1.7:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>1.7</source> <target>1.7</target> </configuration> </plugin>
編譯以后的字節碼文件(使用UltraEdit的16進制模式打開):
紅色框內的部分就是HelloWorld.class的內容,其他部分是UltraEdit自動生成的:紅色框頂部的0~f代表列號,左邊部分代表行號,右側部分是二進制碼對應的字符(utf-8編碼)。
二、字節碼解析
要弄明白 HelloWorld.java 和 HelloWorld.class 文件是如何對應的,我們必須對 JVM 的字節碼規范有所了解。字節碼文件的結構非常緊湊,沒有任何冗余的信息,連分隔符都沒有,它采用的是固定的文件結構和數據類型來實現對內容的分割的。字節碼中包括兩種數據類型:無符號數和表。無符號數又包括 u1,u2,u4,u8四種,分別代表1個字節、2個字節、4個字節和8個字節。而表結構則是由無符號數據組成的。
字節碼文件的格式固定如下:
type | descriptor |
u4 | magic |
u2 | minor_version |
u2 | major_version |
u2 | constant_pool_count |
cp_info | constant_pool[cosntant_pool_count – 1] |
u2 | access_flags |
u2 | this_class |
u2 | super_class |
u2 | interfaces_count |
u2 | interfaces[interfaces_count] |
u2 | fields_count |
field_info | fields[fields_count] |
u2 | methods_count |
method_info | methods[methods_count] |
u2 | attributes_count |
attribute_info | attributes[attributes_count] |
現在,我們就按這個格式對上述HelloWorld.class文件進行分析:
magic(u4):CA FE BA BE ,代表該文件是一個字節碼文件,我們平時區分文件類型都是通過后綴名來區分的,不過后綴名是可以隨便修改的,所以僅靠后綴名不能真正區分一個文件的類型。區分文件類型的另個辦法就是magic數字,JVM 就是通過 CA FE BA BE 來判斷該文件是不是class文件。
minor_version(u2):00 00,小版本號,因為我這里采用的1.7,所以小版本號為0.
major_version(u2):00 33,大版本號,x033轉換為十進制為51,下表是jdk 1.6 以后對應支持的 Class 文件版本號:
編譯器版本 | -target參數 | 十六進制版本 | 十進制版本 |
JDK 1.6.0_01 | 不帶(默認 -target 1.6) | 00 00 00 32 | 50.0 |
JDK 1.6.0_01 | -target 1.5 | 00 00 00 31 | 49.0 |
JDK 1.6.0_01 | -target 1.4 -source 1.4 | 00 00 00 30 | 48.0 |
JDK 1.7.0 | 不帶(默認 -target 1.7) | 00 00 00 33 | 51.0 |
JDK 1.7.0 | -target 1.6 | 00 00 00 32 | 50.0 |
JDK 1.7.0 | -target 1.4 -source 1.4 | 00 00 00 30 | 48.0 |
JDK 1.8.0 | 不帶(默認 -target 1.8) | 00 00 00 34 | 52.0 |
constant_pool_count(u2):00 22,常量池數量,轉換為十進制后為34,這里需要注意的是,字節碼的常量池是從1開始計數的,所以34表示為(34-1)=33項。
TAG(u1):0A,常量池的數據類型是表,每一項的開始都有一個tag(u1),表示常量的類型,常量池的表的類型包括如下14種,這里A(10)表示CONSTANT_Methodref,代表方法引用。
常量類型 | 值 |
CONSTANT_Utf8_info | 1 |
CONSTANT_Integer_info | 3 |
CONSTANT_Float_info | 4 |
CONSTANT_Long_info | 5 |
CONSTANT_Double_info | 6 |
CONSTANT_Class_info | 7 |
CONSTANT_String_info | 8 |
CONSTANT_Fieldref_info | 9 |
CONSTANT_Methodref_info | 10 |
CONSTANT_InterfaceMethodref_info | 11 |
CONSTANT_NameAndType_info | 12 |
CONSTANT_MethodHandle_info | 15 |
CONSTANT_MethodType_info | 16 |
CONSTANT_InvokeDynamic_info | 18 |
每種常量類型對應表結構:
常量 | 項目 | 類型 | 描述 |
CONSTANT_Utf8_info | tag | u1 | 1 |
length | u2 | 字節數 | |
bytes | u1 | utf-8編碼的字符串 | |
CONSTANT_Integer_info | tag | u1 | 3 |
bytes | u4 | int值 | |
CONSTANT_Float_info | tag | u4 | 4 |
bytes | u1 | float值 | |
CONSTANT_Long_info | tag | u1 | 5 |
bytes | u8 | long值 | |
CONSTANT_Double_info | tag | u1 | 6 |
bytes | u8 | double值 | |
CONSTANT_Class_info | tag | u1 | 7 |
index | u2 | 指向全限定名常量項的索引 | |
CONSTANT_String_info | tag | u1 | 8 |
index | u2 | 指向字符串常量的索引 | |
CONSTANT_Fieldref_info | tag | u1 | 9 |
index | u2 | 指向聲明字段的類或接口描述符CONSTANT_Class_info的索引值 | |
index | u2 | 指向CONSTANT_NameAndType_info的索引值 | |
CONSTANT_Methodref_info | tag | u1 | 10 |
index | u2 | 指向聲明方法的類描述符CONSTANT_Class_info的索引值 | |
index | u2 | 指向CONSTANT_NameAndType_info的索引值 | |
CONSTANT_InterfaceMethodref_info | tag | u1 | 11 |
index | u2 | 指向聲明方法的接口描述符CONSTANT_Class_info的索引值 | |
index | u2 | 指向CONSTANT_NameAndType_info的索引值 | |
CONSTANT_NameAndType_info | tag | u1 | 12 |
index | u2 | 指向該字段或方法名稱常量的索引值 | |
index | u2 | 指向該字段或方法描述符常量的索引值 | |
CONSTANT_MethodHandle_info | tag | u1 | 15 |
reference_kind | u1 | 值必須1~9,它決定了方法句柄的的類型 | |
reference_index | u2 | 對常量池的索引 | |
CONSTANT_MethodType_info | tag | u1 | 16 |
description_index | u2 | 對常量池中方法描述符的索引 | |
CONSTANT_InvokeDynamic_info | tag | u1 | 18 |
bootstap_method_attr_index | u2 | 對引導方法表的索引 | |
name_and_type_index | u2 | 對CONSTANT_NameAndType_info的索引 |
CONSTANT_Methodref_info(u2): 00 06,因為tag為A,代表一個方法引用表(CONSTANT_Methodref_info),所以第二項(u2)應該是指向常量池的位置,即常量池的第六項,表示一個CONSTANT_Class_info表的索引,用類似的方法往下分析,可以發現常量池的第六項如下,tag類型為07,查詢上表可知道其即為CONSTANT_Class_info。
07之后的00 1B表示對常量池地27項(CONSTANT_Utf8_info)的引用,查看第27項如下圖,即(java/lang/Object):
CONSTANT_NameAndType_info(u2):00 14,方法引用表的第三項(u2),常量池索引,指向第20項。
CONSTANT_Fieldref_info(u1):tag為09。
.....
常量池的分析都類似,其他的分析由於篇幅問題就不在此一一講述了。跳過常量池就到了訪問標識(u2):
JVM 對訪問標示符的規范如下:
Flag Name | Value | Remarks |
ACC_PUBLIC | 0x0001 | pubilc |
ACC_FINAL | 0x0010 | final |
ACC_SUPER | 0x0020 | 用於兼容早期編譯器,新編譯器都設置該標記,以在使用 invokespecial指令時對子類方法做特定處理。 |
ACC_INTERFACE | 0x0200 | 接口,同時需要設置:ACC_ABSTRACT。不可同時設置:ACC_FINAL、ACC_SUPER、ACC_ENUM |
ACC_ABSTRACT | 0x0400 | 抽象類,無法實例化。不可與ACC_FINAL同時設置。 |
ACC_SYNTHETIC | 0x1000 | synthetic,由編譯器產生,不存在於源代碼中。 |
ACC_ANNOTATION | 0x2000 | 注解類型(annotation),需同時設置:ACC_INTERFACE、ACC_ABSTRACT |
ACC_ENUM | 0x4000 | 枚舉類型 |
這個表里面無法直接查詢到0021這個值,原因是0021=0020+0001,即public+invokespecial指令,源碼中的方法main是public的,而invokespecial是現在的版本都有的,所以值為0021。
接着往下是this_class(u2):是指向constant pool的索引值,該值必須是CONSTANT_Class_info類型,值為00 05,即指向常量池中的第五項,第五項指向常量池中的第26項,即com/paddx/test/asm/HelloWorld:
super_class(u2)):super_class是指向constant pool的索引值,該值必須是CONSTANT_Class_info類型,指定當前字節碼定義的類或接口的直接父類。這里的取值為00 06,根據上面的分析,對應的指向的全限定性類名為java/lang/object,即當前類的父類為Object類。
interfaces_count(u2):接口的數量,因為這里沒有實現接口,所以值為 00 00。
interfaces[interfaces_count]:因為沒有接口,所以就不存在interfces選項。
field_count:屬性數量,00 00。
field_info:因為沒有屬性,所以不存在這個選項。
method_count:00 02,為什么會有兩個方法呢?我們明明只寫了一個方法,這是因為JVM 會自動生成一個 <init>的方法。
method_info:方法表,其結構如下:
Type | Descriptor |
u2 | access_flag |
u2 | name_index |
u2 | descriptor_index |
u2 | attributes_count |
attribute_info | attribute_info[attributes_count] |
HelloWorld.class文件中對應的數據:
access_flag(u2): 00 01
name_index(u2):00 07
descriptor_index(u2):00 08
可以看看 07、08對應的常量池里面的值:
即 07 對應的是 <init>,08 對應的是();
attributes_count:00 01,表示包含一個屬性
attribute_info:屬性表,該表的結構如下:
Type | Descriptor |
u2 | attribute_name_index |
u4 | attribute_length |
u1 | bytes |
attribute_name_index(u2): 00 09,指向常量池中的索引。
attribute_length(u4):00 00 00 2F,屬性的長度47。
attribute_info:具體屬性的分析與上面類似,大家可以對着JVM的規范自己嘗試分析一下。
第一個方法結束后,接着進入第二個方法:
第二個方法的屬性長度為x037,轉換為十進制為55個字節。兩個方法之后緊跟着的是attribute_count和attributes:
attribute_count(u2):值為 00 01,即有一個屬性。
attribute_name_index(u2):指向常量池中的第十二項。
attribute_length(u4):00 00 00 02,長度為2。
分析完畢!
三、基於字節碼的操作:
通過對HelloWorld這個程序的字節碼分析,我們應該能夠比較清楚的認識到整個字節碼的結構。那我們通過字節碼,可以做些什么呢?其實通過字節碼能做很多平時我們無法完成的工作。比如,在類加載之前添加某些操作或者直接動態的生成字節碼,CGlib就是通過這種方式來實現動態代理的。現在,我們就來完成另一個版本的HelloWorld:
package com.paddx.test.asm; public class HelloWorld2 { public static void sayHello(){ } }
我們有個空的方法 sayHello(),現在要實現調該方法的時候打印出“HelloWorld”,怎么處理?如果我們手動去修改字節碼文件,將打印“HelloWorld”的代碼插入到sayHello方法中,原理上肯定沒問題,不過操作過程還是比較復雜的。Java 的最大優勢就在於只要你能想到的功能,基本上就有第三方開源的庫實現過。字節碼操作的開源庫也比較多,這里我就用 ASM 4.0來實現該功能:
package com.paddx.test.asm; import org.objectweb.asm.*; import java.io.IOException; import java.lang.reflect.InvocationTargetException; public class AsmDemo extends ClassLoader{ public static void main(String[] args) throws IOException, IllegalAccessException, InstantiationException, InvocationTargetException { ClassReader classReader = new ClassReader("com.paddx.test.asm.HelloWorld2"); ClassWriter cw=new ClassWriter(ClassWriter.COMPUTE_MAXS); CustomVisitor myv=new CustomVisitor(Opcodes.ASM4,cw); classReader.accept(myv, 0); byte[] code=cw.toByteArray(); AsmDemo loader=new AsmDemo(); Class<?> appClass=loader.defineClass(null, code, 0,code.length); appClass.getMethods()[0].invoke(appClass.newInstance(), new Object[]{}); } } class CustomVisitor extends ClassVisitor implements Opcodes { public CustomVisitor(int api, ClassVisitor cv) { super(api, cv); } @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); if (name.equals("sayHello")) { mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitLdcInsn("HelloWorld!"); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V"); } return mv; } }
運行結果如下:
關於 ASM 4的操作在這就不細說了。有興趣的朋友可以自己去研究一下,有機會,我也可以再后續的博文中跟大家分享。
四、總結
本文通過HelloWorld這樣一個大家都非常熟悉的例子,深入的分析了字節碼文件的結構。利用這些特性,我們可以完成一些相對高級的功能,如動態代理等。這些例子雖然都很簡單,但是“麻雀雖小五臟俱全”,即使再復雜的程序也逃離不了這些最基本的東西。技術層面的東西就是這樣子,只要你能了解一個簡單的程序的原理,舉一反三,就能很容易的理解更復雜的程序,這就是技術“易”的方面。同時,反過來說,即使“HelloWorld”這樣一個簡單的程序,如果我們深入探究,也不一定能特別理解其原理,這就是技術“難”的方面。總之,技術這種東西只要你用心深入地去研究,總是能帶給你意想不到的驚喜~