Categories
程式開發

硬核系列| 深入剖析字節碼增強


簡介

Java從誕生之日起就努力朝著跨平台解決方案演進並延續至今,其中的支撐技術就是中間代碼(即:字節碼指令)。所謂字節碼增強,實質就是在編譯期或運行期對字節碼進行插樁,以便在運行期影響程序的執行行為。在實際的開發過程中,大部分開發人員都曾直接或間接與其打過交道,比如:典型的AOP技術,或是使用JVM-Sanbox、Arthas、Skywalking等效能工具,甚至是在實現一個編譯器時的中間代碼轉儲。在此大家需要注意,通常字節碼與上層語言的語法指令無關,只要符合JVM規範,目標代碼就允許被裝載至JVM的世界中運行,由此我們可以得出一個結論,那些Java語法層面暫不支持的功能特性,並不代表JVM不支持(比如:協程“),總之,這完全取決於你的腦洞有多大。

通常,這類技術基石類型的文章一直受眾較小,大部分開發人員的聚焦點仍停留在語法層面或功能層面上,然而,恰恰正因如此,注定了這將會是普通Java研發人員永遠的天花板,如果不想被定格,就請努力翻越這一座座的大山。

AOP增強的本質

在正式討論字節碼增強之前,我首先講一下AOP所涉及到的一些相關概念,有助於大家對後續內容有更深刻的理解(儘管早在7年前這類文章我曾在Iteye上講解過無數遍)。 AOP(Aspect Oriented Programming,面向切面編程)的核心概念是以不改動源碼為前提,通過前後“橫切”的方式,動態為程序添加新功能,它的出現,最初是為了解決開發人員所面臨的諸多耦合性問題,如圖1所示。

硬核系列| 深入剖析字節碼增強 21

我們都知道,OOP針對的是業務處理過程的實體及其屬性和行為進行抽象封裝,以獲得更加清晰高效的邏輯單元劃分,那麼程序中各個組件、模塊之間必然會存在著依賴性,也就是通常我們所說的緊耦合,在追求高內聚、低耦合的當下,想要完全消除程序中的耦合似乎是不現實的,但過分的、不必要的耦合又往往容易導致我們的代碼很難被復用,甚至還需為此付出高昂的維護成本。一般來說,一個成熟的系統中往往都會包含但不限於如下6點通用邏輯:

日誌記錄;異常處理;事物處理;權限檢查;性能統計;流量管控。

AOP術語中我們把上述這些通用邏輯稱之為切面(Aspect)。試想一下,如果在系統的各個業務模塊中都充斥著上述這些與自身邏輯無毫瓜葛的共享代碼會產生什麼問題?很明顯,當被依賴方發生改變時,避免不了需要修改程序中所有依賴方的邏輯代碼,著實不利於維護。想要解決這個痛點,就必須將這些共享代碼從邏輯代碼中剝離出來,讓其形成一個獨立的模塊,以一種聲明式的、可插拔式的方式來應用到具體的邏輯代碼中去,以此消除程序中那些不必要的依賴、提升開發效率和代碼的可重用性,這就是我們使用AOP初衷。

在此大家需要注意,AOP和具體的實現技術無關,只要是符合AOP的思想,我們都可以將其稱之為AOP的實現。目前市面上AOP框架的實現方案通常都是基於如下2種形式:

靜態編織;動態編織。

靜態編織選擇在編譯期就將AOP增強邏輯插入到目標類的方法中,以AspectJ為例,當成功定義好目標類和代理類後,通過命令“ajc -d .”進行編譯後調用執行時即會觸發增強邏輯;對字節碼文件進行反編譯後,大家會發現目標類中多出來了一些代碼,這些多出來的代碼實際上就是AspectJ在編譯期就往目標類中插入的AOP字節碼。而動態編織選擇在運行期以動態代理的形式對目標類的方法進行AOP增強,諸如Cglib、Javassist,以及ASM等字節碼生成工具都可用於支撐這一方案的實現。當然,無論是選擇靜態編織還是動態編織方案來實現AOP增強,都會面臨著侵入性和固化性等2個問題,關於這2個問題,我暫時把它們放在下個小節進行討論。

非侵入式運行期AOP增強

基於Spring的AOP增強方案,儘管對業務代碼而言不具備任何侵入性,但這並非是絕對的,因為開發人員仍然需要手動修改Spring的Bean定義信息;除此之外,如果是使用Dubbo的Filter技術,還需要額外在項目的/resources目錄下新建/META-INF/Dubbo/com.alibaba.Dubbo.rpc.Filter文件,因此,從項目維度來看,常規的AOP增強方案似乎並不能滿足我們對零侵入性的要求。另外,固化性問題也對動態增強產生了一定程度上的限制,因為增強邏輯只會對提前約定好的方法生效,無法在運行期重新對一個已有的方法進行增強。大家思考下,由於JVM允許開發人員在運行期對存儲在PermGen空間內(Java8之後為Metaspace空間)的字節碼內容進行某種意義上的修改操作,那麼是否可以對目標類重複加載來解決固化性問題?接下來,我就為大家演示在程序中直接通過sun.misc.Unsafe類的defineClass()方法指定AppClassLoader對目標類進行多次加載,看看會發生什麼,實例1-1:

try {
var temp = Unsafe.class.getDeclaredField("theUnsafe");
temp.setAccessible(true);
var unsafe = temp.get((Object) null);
var byteCode = getByteCode();
for (int i = 0; i < 2; i++) { unsafe.defineClass(name, byteCode, 0, byteCode.length, this.getClass().getClassLoader(), null); } } catch (Throwable e) { e.printStackTrace(); }

執行上述程序必然會導致觸發java.lang.LinkageError異常,從堆棧信息的描述來看,AppClassLoader不允許對相同的類進行重複加載。既然此路不通,那麼是否還有別的方式?值得慶幸的是,從JDK1.5開始,Java的設計者們在java.lang.instrument包下為開發人員提供了基於JVMTI(Java Virtual Machine Tool Interface,Java虛擬機工具接口)規範的Instrumentation-API,使之能夠使用Instrumentation來構建一個獨立於應用的Agent程序,以便於監測和協助運行在JVM上的程序。當然最重要的是,使用Instrumentation可以在運行期對類定義進行修改和替換,換句話來說,相當於我們可以動態對目標類的方法進行AOP增強。

Instrumentation-API提供有2種使用方式,如下所示:

代理已加載;代理已附加。

前者的使用方式要求開發人員在啟動腳本中加入命令行參數“-javaagent”來指定目標jar文件,由應用的啟動來同步帶動Agent程序的啟動。當然,首先需要定義好Agent-Class,示例1-2:

public class AgentLauncher {
public static void premain(String args, Instrumentation inst) throws Throwable {
inst.addTransformer((a,b,c,d,e) -> 增强后的字节码, true);
}
}

當Agent啟動時,首先會觸發對premain()函數的調用。在java.lang.instrument包下有2個非常重要的接口,分別是Instrumentation和ClassFileTransformer。前者作為增強器,其addTransformer()函數用於註冊ClassFileTransformer實例;後者作為類文件轉換器,需自行重寫其transform()函數,用於返回增強內容。執行命令“-XX:+TraceClassLoading”後不難發現,當目標類被加載進方法區之前,會由Instrumentation的實現負責回調transform()函數執行增強(已加載的類則需要手動觸發Instrumentation.retransformClasses( )函數顯式重定義)。為了演示方便,我直接在premain()函數中實現了增強邏輯,但實際的開發過程中,增強邏輯往往非常複雜,並且在某些場景下還需要處理類隔離等問題,因此,通常情況下, Agent-Class所扮演的角色僅僅只是一個Launcher。

最後再對Agent進行打包之前,還需要在pom文件中定義和等標籤指定Agent-Class和允許對類定義做修改,示例1-3:


xx.xx.xx
true
true

除了load方式外,我們還可以通過attach實現運行時的Instrument,也就是說,可以在JVM啟動後,且所有類型均已全部完成加載之後再對目標類進行重定義。兩種Instrument的使用方式基本大同小異,只是在定義Agent-Class時,入口函數為agentmain(),示例1-4:

public class AgentLauncher {
public static void agentmain(String agentArgs, Instrumentation inst) throws Throwable {
inst.addTransformer((a,b,c,d,e) -> 增强后的字节码, true);
inst.retransformClasses(Class.forName(agentArgs));//对目标类进行重定义
}
}

其次,pom文件中所定義的標籤需要更改為。當對Agent進行打包後,我們只需要根據目標進程的PID便能實現動態附著,示例1-5:

var vm = VirtualMachine.attach(pid);
if (Objects.nonNull(vm)) {
try {
vm.loadAgent(path, name);
} finally {
vm.detach();
}
}

示例1-2至1-5中,我為大家簡要介紹了Instrumentation-API的基本使用,但在transform()函數中卻並未實現具體的AOP增強邏輯,那麼接下來,我就為大家演示如何使用字節碼增強工具Javassist對目標類進行重定義,實現真正意義上的非侵入式運行期AOP增強。介於Javassist簡單易用,並且很好的屏蔽了諸多底層技術細節,使得開發人員在即使不懂JVM指令的情況下也能夠正確的操作字節碼(Dubbo的動態代理生成使用的就是Javassist技術) 。使用Javassist創建動態代理有2種方式,一種是基於ProxyFactory的方式,而另一種則是基於動態代碼的實現方式,一般來說,選擇後者可以獲得更好的執行性能,示例1-6 :

public class Transformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer)
throws IllegalClassFormatException {
return enhancement(className);
}

private byte[] enhancement(String className) {
if ((className = className.replaceAll("/", ".")).equals("java.lang.String")) {
try {
var cls = ClassPool.getDefault().get(className);//加载.class文件
var method = cls.getDeclaredMethod("toString");//获取增强方法
//增强逻辑
method.insertBefore("System.out.println("before");");
method.insertAfter("System.out.println("after");");
return cls.toBytecode();//转字节码
} catch (Throwable e) {
e.printStackTrace();
}
}
return null;
}
}

上述程序示例中,我基於load的方式來實現Instrument,介於ClassLoader每加載一個目標類就會調用transform()函數,所以這裡需要在程序中使用equals()函數來判斷目標類型。上述程序示例中,我對java.lang.String.toString()函數進行了增強,當觸發toString()函數時,方法前後都會執行一段特定的增強代碼,如圖2所示。

硬核系列| 深入剖析字節碼增強 22

在此大家需要注意,由於Javassist的抽象層次較高,儘管簡單易用,但靈活性差,當面對一些複雜場景時(比如:需要根據特定的條件來進行插樁),則顯得無能為力。因此,在接下來的小節中,我會重點為大家講解關於字節碼的一些基礎知識,以及常用的JVM指令,以便大家快速上手ASM工具。

字節碼

ASM是一種偏向於指令層面的專用於生成、轉換,以及分析字節碼的底層工具,儘管它的學習門檻和使用成本非常高,但與生俱來的靈活性和高性能卻是它引以為傲的資本,因此,在掌握ASM的使用之前,大家首先需要對字節碼結構以及JVM指令有所了解,否則將會無從下手。我們都知道,Java程序如果想要運行,首先需要由前端編譯器(即:Javac)將符合Java語法規範的源代碼編譯為符合JVM規範的JVM指令,也就是說,語法指令與JVM指令本質上並不對等,編譯器的作用就像是一個翻譯官,將你原本聽不懂的語言轉換為你能夠聽懂並深刻理解的語言。接下來,我們先來看一段簡單的Java代碼,示例1-7:

public class ByteCode {
public String id;
public String getId() {return id;}
}

成功編譯為Class文件後,我們使用文本工具將其打開,如圖3所示。

硬核系列| 深入剖析字節碼增強 23

這一堆密密麻麻的16進制數究竟代表什麼意思?首先,大家需要明確,Class文件是一組由8bit字節單位構成的二進制流,各個數據項之間會嚴格按照固定且緊湊的排列順序組合在一起。或許大家存在一個疑問,既然是以8bit為單位,那麼如果數據項所需佔用的存儲空間超過8bit時應該如何處理?簡單來說,如果是多bit數據項,則會按照big-endian的順序來進行存儲。

既然Class文件中各個數據項之間是按照固定的順序進行排列的,那麼這些數據項究竟代表著什麼?簡單來說,構成Class文件結構的數據項大致包含10種,如圖4所示。

硬核系列| 深入剖析字節碼增強 24

magic(魔術)

它作為一個文件標識,當JVM加載目標Class文件時用於判斷其是否是一個標準的Class文件,其16進制的固定值為0xCAFEBABE,佔32bit。

version(版本號)

magic之後是minor_version和major_version數據項,它們用於構成Class文件的格式版本號,分別都佔16bit,在實際的開發過程中,我相信大家都遇見過如下異常,示例1-8:

Exception in thread "main" java.lang.UnsupportedClassVersionError:
ByteCode has been compiled by a more recent version of the Java Runtime (class file version 59.0),
this version of the Java Runtime only recognizes class file versions up to 52.0

從異常堆棧中可以明確,目標Class文件是基於高版本JDK編譯的,超出了當前JVM能夠支持的最大版本範圍,由此可見,通過版本號約束可以在某種程度上避免一些嚴重的運行時錯誤。

constant_pool(常量池)

constant_pool一個表類型的數據項,其入口處還包含一個constant_pool_count(常量池容量計數器),主要用於存放數值、字符串、final常量值等數據,以及類和接口的全限定名、字段及方法的名稱和描述符等信息。相對於其它數據項而言,constant_pool是其中最複雜和繁瑣的一種,因為constant_pool中的各項常量類型自身都具有專有的結構,但值得慶幸的是,在實際的開發過程中,我們幾乎不必與constant_pool打交道,因為ASM很好的屏蔽了與常量池相關的所有細節。

access_flags(訪問標誌)

constant_pool之後是佔16bit的access_flags,在ASM的使用過程中,我們會經常與其打交道,因為在定義類、接口,以及聲明各種修飾符時,均會使用到它。 ASM操作碼中所定義的access_flags,示例1-9:

int ACC_PUBLIC = 0x0001; // class, field, method
int ACC_PRIVATE = 0x0002; // class, field, method
int ACC_PROTECTED = 0x0004; // class, field, method
int ACC_STATIC = 0x0008; // field, method
int ACC_FINAL = 0x0010; // class, field, method, parameter
int ACC_SUPER = 0x0020; // class
int ACC_SYNCHRONIZED = 0x0020; // method
int ACC_VOLATILE = 0x0040; // field
int ACC_BRIDGE = 0x0040; // method
int ACC_VARARGS = 0x0080; // method
int ACC_TRANSIENT = 0x0080; // field
int ACC_NATIVE = 0x0100; // method
int ACC_INTERFACE = 0x0200; // class
int ACC_ABSTRACT = 0x0400; // class, method
int ACC_STRICT = 0x0800; // method
int ACC_SYNTHETIC = 0x1000; // class, field, method, parameter
int ACC_ANNOTATION = 0x2000; // class
int ACC_ENUM = 0x4000; // class(?) field inner
int ACC_MANDATED = 0x8000; // parameter

thisclass(類索引)、superclass(超類索引),以及interfaces(接口索引)

this_class和super_class佔16bit,而interfaces數據項則是一組16bit數據的集合,訪問時通過索引值指向constant_pool來獲取自身、超類,以及相關接口的全限定名,以便於確定一個類的繼承關係。

fields(字段表)

interfaces之後是fields數據項,其入口處還包含一個fields_count(字段計數器),用於表述當前類、接口中包括的所有類字段和實例字段,但不包括從超類繼承的相關字段。

methods(方法表)

同fields和constant_pool等數據項類似,其入口處同樣也包含一個計數器(methods_count),用於表述當前類或接口中所定義的所有方法的完整描述,但不包括從超類繼承的相關方法。

attributes(屬性表)

排列在最後是attributes數據項,主要用於存放Class文件中類和接口所定義屬性的基本信息。

如果想要對Class文件中的數據項有更深入的了解,我建議大家閱讀《Java虛擬機規範》一書,而關於Class文件結構本文則不再過多進行闡述。接下來,我們執行命令“java -v”,將示例1-7的編譯結果進行展開,對比下源代碼和部分中間代碼之間的差異,示例1-10:

{
public java.lang.String id;
descriptor: Ljava/lang/String;
flags: ACC_PUBLIC

public ByteCode();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 22: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LByteCode;

public java.lang.String getId();
descriptor: ()Ljava/lang/String;
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field id:Ljava/lang/String;
4: areturn
LineNumberTable:
line 26: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LByteCode;
}

有幾點大家需要注意,源代碼中的註釋信息(非Annotation)並不會包含在Class文件中,畢竟註釋是給人看的,它是一種增強代碼可讀性的輔助手段,但計算機卻並不需要。其次,package和import部分也不會包含在Class文件中,先前講解索引部分數據項時我曾經提及過,通常這類描述信息都是以全限定名的形式存放於constant_pool中。

細心的同學或許發現了一些端倪,示例1-10中,為什麼類型全限定名中的符號由"."變成了“/”,並且有些前綴還包含有字母(“L”)和符號(“ ()”)?實際上,這是屬於Class文件的一種內部表示形式,比如語法層面Object類的全限定名格式為“java.lang.Object”,但是在Class文件中,符號“.”會被“/”所替換,這樣的表述形式我們稱之為內部名。

如果是引用類型的字段,那麼為什麼內部名之前需要再加上字母“L”呢?這是因為內部名通常僅用於描述類和接口,而諸如字段等類型,Class文件中則提供有另一種表述形式,即類型描述符。其中數組的類型描述符在內部名之前還需要加上符號"[",其维度决定了符号“[”的个数,也就是说,如果是二维数组,那么类型描述符就是“[[Ljava/lang/Object;”,以此类推。除引用类型外,原始数据类型也有自己的类型描述符,Class文件中完整的类型描述符,如图5所示。

硬核系列| 深入剖析字節碼增強 25

既然在描述字段时需要使用到字段描述符,那么在Class文件中,方法同样也具备有类似的描述符,叫做方法描述符,也就是在示例1-10中全限定名以符号(“()”)作为前缀的那部分描述符。方法描述符以符号“(”作为开头,其中包含着>=0个类型描述符,即入参类型,并以符号“)”结束,最后紧跟着方法的返回值类型,同样也是使用类型描述符表述,比如:方法“boolean register(String str1, String str2)”的方法描述符就写作“(Ljava/lang/String;Ljava/lang/String;)Z”形式。Class文件中完整的方法描述符,如图6所示。

硬核系列| 深入剖析字節碼增強 26

基于ASM实现字节码增强

尽管在使用ASM之前,我们需要了解和掌握一些前置知识,并且相对晦涩,但是,这并不代表ASM难以驾驭,就好比Class文件中的复杂数据项constant_pool,难道我们真的需要把其中各项常量类型的结构都弄得一清二楚吗?答案是不用的,你仅需知道Class文件中有一个被称之为constant_pool的数据项,大致了解它的作用即可,所有的底层技术细节,ASM在语法层面上均已屏蔽,开发人员除API用法外,唯一需要掌握的就是在基于栈型架构的执行模式下,如何将上层语法转换为相对应的底层指令集。

我们首先来学习下ASM API的基本用法。之前我曾经以及过,ASM除了能作用于字节码增强外,逆向分析,以及编译器的中间代码生成等任务都能很好的胜任,简而言之,ASM是一个专用于字节码分析、增强,以及生成的底层工具包,其API提供如下2种使用形式:

基于事件模型的API;基于对象模型的API。

相对于后者而言,前者拥有绝对的性能优势,但从使用效率上来说,却不如拥有更高封装层次的后者来的方便。本文我会重点讨论基于事件模型的API,而关于对象模型API的使用,大家可以参考其它的文献资料。在基于事件模型API模式下,ASM的整体架构主要是围绕着分析、转换,以及生成3个方面进行的,如图7所示。

硬核系列| 深入剖析字節碼增強 27

见名知意,ClassReader用于加载任意Class文件中的内容,并将其转发给ClassVisitor实现,也就是说,ClassReader在一个完整的事件转换链中是作为入口程序存在的。而ClassVisitor作为转换类及生成类的超类,其中每一个visit()方法都对应着同名类文件的结构部分,比如:方法(visitMethod)、注解(visitAnnotation)、字段(visitField)等,这是一个典型的Visitor模式。

我首先为大家演示如何基于一个自定义的ClassVisitor实现一个简单的反编译程序,示例1-11:

ClassReader reader = new ClassReader("java.lang.Object");//读取目标类,也可以从二进制流中读取
//调用accept()方法启动
reader.accept(new ClassVisitor(Opcodes.ASM5) {
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] 接口){System.out.printf(“ className:%s extends:%s n”,名稱,superName); } @Override public FieldVisitor visitField(int access,字符串名稱,字符串desc,字符串簽名,對象值){System.out.printf(“ fieldName:%s desc:%s n”,name,desc); 返回super.visitField(訪問,名稱,desc,簽名,值); } @Override public MethodVisitor visitMethod(int access,字符串名稱,字符串desc,字符串簽名,字符串[] 例外){System.out.printf(“ methodName:%s desc:%s”,名稱,desc); System.out.print(“ exceptions:”); 如果(Objects.nonNull(exceptions)){Arrays.stream(exceptions).forEach(System.out :: print); } else {System.out.print(“ null”); } System.out.println(); 返回super.visitMethod(訪問,名稱,desc,簽名,異常); }},0);

ClassVisitor在上述程序示例中作為匿名內部類的形式來處理反編譯任​​務,但在實際的開發過程中,我們卻並沒有太大必要這麼做,因為在org.objectweb.asm.util包下,ASM為開發人員提供有TraceClassVisitor類型專用於處理此類任務,示例1-12:

ClassReader reader = new ClassReader("java.lang.Object");
reader.accept(new TraceClassVisitor(new PrintWriter(System.out)), 0);

ClassVisitor實現可以選擇將相關事件派發給下一個ClassVisitor實現,也可以選擇將其轉發給ClassWriter轉儲,如果選擇後者,那麼一個完整的轉換鏈就構成了。在為大家講解如何使用ClassVisitor實現修改字節碼之前,我會首先為大家演示如何使用ClassWriter生成基於棧的指令集,示例1-13:

ClassWriter writer = new ClassWriter(0);
//创建类标头
writer.visit(V1_8, ACC_PUBLIC, "UserService", null, "java/lang/Object", null);
//创建目标类构造函数
MethodVisitor mv = writer.visitMethod(0, "", "()V", null, null);
mv.visitCode();
mv.visitVarInsn(ALOAD, 0);//将this压入操作数栈栈顶
//调用超类构造函数
mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "", "()V", false);
mv.visitTypeInsn(NEW, "java/lang/Object");//创建Object实例压入栈顶
mv.visitInsn(DUP);//拷贝栈顶元素并压入栈顶
//调用java/lang/Object构造函数
mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "", "()V", false);
mv.visitInsn(POP);//由于后续没有任何指令操作,因此将栈顶元素弹出
mv.visitInsn(RETURN);
mv.visitMaxs(2, 1);//设置操作数栈和局部变量表大小
mv.visitEnd();
writer.visitEnd();

上述程序示例中,首先創建ClassWriter實例,然後調用visit()方法創建類標頭,並指定字節碼版本號、訪問修飾符、類名,以及超類等基本信息,這裡我們並不需要指定magic ,並且也不需要單獨指定minor_version和major_version,ASM會自行進行處理。當成功創建好類標頭後,接下來就是調用ClassWriter.visitMethod()方法返回一個MethodVisitor實現為其目標類創建一個缺省的構造函數。其中mv.visitVarInsn(ALOAD, 0)方法用於將this引用壓入操作數棧的棧頂,通過指令INVOKESPECIAL調用它的超類構造函數,至此,缺省構造行為結束,接下來就是一些具體的用戶指令行為操作。

硬核系列| 深入剖析字節碼增強 28

這裡的用戶操作非常簡單,語法層面上僅僅只是一個new Object();操作,但是轉換為字節碼指令後則顯得相對繁冗。首先我們需要使用指令NEW將java/lang/Object實例推入棧頂,然後使用INVOKESPECIAL指令調用其構造函數,最後再使用指令POP彈出棧頂元素,並返回即可。在此大家或許存在一個疑問,當使用NEW指令將目標實例推入棧頂了,為何還需要再使用指令DUP拷貝棧頂元素再次推入棧頂?其實這很好理解,棧頂元素在被用戶訪問之前,執行引擎首先需要彈出棧頂元素調用其“”方法,因此為了避免元素出棧後無法為後續操作提供訪問,所以需要單獨拷貝一份,如圖8所示。接下來,我再為大家演示一個稍微複雜一點的邏輯代碼,示例1-14:

//创建方法login
mv = writer.visitMethod(ACC_PUBLIC, "login", "(Ljava/lang/String;Ljava/lang/String;)V", null, null);
mv.visitCode();
mv.visitVarInsn(ALOAD, 1);//将局部变量表中的入参1压入栈顶
mv.visitLdcInsn("admin");//将字面值压入栈顶
//调用Ljava/lang/Object.equals()比较入参和目标字面值是否相等
mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "equals", "(Ljava/lang/Object;)Z", false);
Label l0 = new Label();//定义标签l0
//IFEQ表示栈顶int类型数值等于0时跳转到标签l0处,也就是说,如果equals不成立就跳转到标签l0处
//boolean类型在JVM中的表示形式为1(表示true)和0(表示false)
mv.visitJumpInsn(IFEQ, l0);
mv.visitVarInsn(ALOAD, 2);//将局部变量表中的入参2压入栈顶
mv.visitLdcInsn("123456");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "equals", "(Ljava/lang/Object;)Z", false);
mv.visitJumpInsn(IFEQ, l0);
//将System.out静态字段压入栈顶
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("login success");
//将字面值和System.out出栈,并调用实例方法println()输出字面值
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
mv.visitLabel(l0);//标签l0处逻辑
mv.visitFrame(F_SAME, 0, null, 0, null);
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("login fial");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(2, 3);//设置操作数栈和局部变量表大小
mv.visitEnd();

上述程序示例相對1-13來說要復雜得多,整體來看就是定義了多個流程控制語句的邏輯代碼。首先,我們先將局部變量表中的入參1壓入棧頂,然後將一個String類型的值壓入棧頂,緊接著通過指令INVOKEVIRTUAL調用Ljava/lang/Object.equals()方法驗證這2個值是否相等。在此大家需要注意,在JVM指令中,沒有if-else流程控制語句這樣的命令,都是通過定義Label的方式執行jump的。如果表達式不成立,就直接跳轉到標籤l0處,l0處的邏輯實際上就是輸出System.out.println("login fail");,反之繼續向下執行,再定義相同的指令繼續驗證另外2個String類型的值是否相等,不匹配時跳轉到標籤l0處,反之輸出System.out.println("login success");。

或許有些同學會存在疑問,實現一個簡單的邏輯代碼時都需要編寫這麼複雜的指令集,並且在書寫指令的過程中,在所難免會出現一些錯誤,那麼有什麼好辦法可以在生成字節碼之前檢測出異常指令呢(比如:對方法的調用順序是否恰當,以及參數是否合理有效)?值得慶幸的是,ASM在org.objectweb.asm.util包下為開發人員提供了CheckClassAdapter類型和TraceClassVisitor類型來協助減少指令編碼時的異常情況,並且它們可以出現在整個轉換鏈的任何地方,示例1 -15:

ClassWriter writer = new ClassWriter(0);
//将事件传递给ClassWriter
CheckClassAdapter check = new CheckClassAdapter(writer);
//将事件传递给CheckClassAdapter
TraceClassVisitor trace = new TraceClassVisitor(check, new PrintWriter(System.out));
writer.visitEnd();

在本小節的最後,我再為大家演示下如何通過自定義ClassVisitor實現來對目標字節碼進行增強,其增強邏輯的主要內容為,對實現了java.lang.Runnable接口的任意類型的run( )方法前後插樁一段println()函數。首先我們需要在Transformer.transform()函數中進行相應的判斷(基於Agent on load),只有Runnable實現才會執行相關的增強邏輯,示例1-16:

//由transform()函数触发调用
private byte[] enhancement(byte[] classfileBuffer) {
ClassReader reader = new ClassReader(classfileBuffer);
String className = reader.getClassName();
String[] interfaces = reader.getInterfaces();//获取目标类接口
if (Objects.nonNull(interfaces) && interfaces.length > 0) {
boolean isEnhancement = false;
for (String interfaceName : interfaces) {//只有标记有Runnable接口的类型才需要增强
if (interfaceName.equals("java/lang/Runnable")) {
isEnhancement = true;
break;
}
}
try {
//相关增强逻辑
if (isEnhancement) {
ClassWriter writer = new ClassWriter(0);//声明生成类
reader.accept(new ClassEnhancementAdapter(Opcodes.ASM5,
new TraceClassVisitor(writer, new PrintWriter(System.out)), className), 0);//分析入口
return writer.toByteArray();//返回增强后的字节码
}
} catch (Throwable e) {
log.error("method:{},Enhancement failed!!!", className, e);
}
}
return classfileBuffer;
}

在ClassVisitor實現中,我們需要重寫其visitMethod()方法,判斷目標run()方法和實現增強邏輯,示例1-17:

MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions);
/**
* 方法增强需要满足几个条件:
* 1、非接口类型
* 2、MethodVisitor非空
* 3、非函数
* 4、匹配run()函数
*/
if (!isInterface && Objects.nonNull(methodVisitor)
&& !name.equals("")
&& name.equals("run")) {
methodVisitor = new MethodEnhancementAdapter(methodVisitor, className);
}
return methodVisitor;

增強邏輯具體由MethodEnhancementAdapter來負責,它是一個MethodVisitor的實現,before邏輯需要在其visitCode()方法中進行插樁,而after邏輯則需要在visitInsn()方法中進行插樁,示例1-18:

@Override
public void visitCode() {
mv.visitCode();
mv.visitFieldInsn(GETSTATIC,"java/lang/System","out","Ljava/io/PrintStream;");
mv.visitLdcInsn("before");
mv.visitMethodInsn(INVOKEVIRTUAL,"Ljava/io/PrintStream;","println","(Ljava/lang/String;)V",false);
}

@Override
public void visitInsn(int opcode) {
if(opcode == RETURN){
mv.visitFieldInsn(GETSTATIC,"java/lang/System","out","Ljava/io/PrintStream;");
mv.visitLdcInsn("after");
mv.visitMethodInsn(INVOKEVIRTUAL,"Ljava/io/PrintStream;","println","(Ljava/lang/String;)V",false);
}
super.visitInsn(opcode);
}

在visitInsn()方法中進行插樁時我們需要對當前指令進行判斷,也就是說,after的增強邏輯應該發生在RETURN指令執行之前。至此,關於字節碼增強和ASM的整體使用就暫時介紹到這裡,而關於一些更複雜的增強用法,建議大家閱讀硬核系列| 深入剖析Java 協程“。

類隔離策略

大家需要注意,在很多情況下,我們的Agent包中大概率會包含一些與目標程序相衝突的第三方構件依賴,在這種情況下,為了避免產生類污染,衝突等問題,Advice則只能由自定義類加載器來負責裝載,那麼這時就會面臨一個問題,由子類加載器負責加載的類對父類加載器而言是不可見的,那麼業務代碼中應該如何調用Advice的代碼呢?出於對效率等多方面因素的考慮,我們可以在最頂層的類加載器Bootstrap ClassLoader中註冊一個對虛擬機中所有類加載器都具備可見性的間諜類Spy,如圖9所示。

硬核系列| 深入剖析字節碼增強 29

間諜類的作用是用來打通類隔離後的“通訊”操作,而對目標類進行增強時,並不會直接把增強邏輯固化到目標類上,而是持有一個對間諜類的引用,由間諜類負責持有對隔離類的方法引用(java.lang.reflect.Method),通過反射的方式來調用增強邏輯。

至此,本文內容全部結束。如果在閱讀過程中有任何疑問,歡迎加入微信群聊和小伙伴們一起參與討論。

硬核系列| 深入剖析字節碼增強 30

推薦文章:

硬核系列| 深入剖析Java 協程白玉試毒| 灰度架構設計方案新時代背景下的Java 語法特性剖析Java15 新語法特性看門狗| 分佈式鎖架構設計方案-01看門狗| 分佈式鎖架構設計方案-02

碼字不易,歡迎轉發