硬核系列 | 深入剖析字节码增强
简介
Java从诞生之日起就努力朝着跨平台解决方案演进并延续至今,其中的支撑技术就是中间代码(即:字节码指令)。所谓字节码增强,实质就是在编译期或运行期对字节码进行插桩,以便在运行期影响程序的执行行为。在实际的开发过程中,大部分开发人员都曾直接或间接与其打过交道,比如:典型的AOP技术,或是使用JVM-Sanbox、Arthas、Skywalking等效能工具,甚至是在实现一个编译器时的中间代码转储。在此大家需要注意,通常字节码与上层语言的语法指令无关,只要符合JVM规范,目标代码就允许被装载至JVM的世界中运行,由此我们可以得出一个结论,那些Java语法层面暂不支持的功能特性,并不代表JVM不支持(比如:Coroutine),总之,这完全取决于你的脑洞有多大。
通常,这类技术基石类型的文章一直受众较小,大部分开发人员的聚焦点仍停留在语法层面或功能层面上,然而,恰恰正因如此,注定了这将会是普通Java研发人员永远的天花板,如果不想被定格,就请努力翻越这一座座的大山。
AOP增强的本质
在正式讨论字节码增强之前,我首先讲一下AOP所涉及到的一些相关概念,有助于大家对后续内容有更深刻的理解(尽管早在7年前这类文章我曾在Iteye上讲解过无数遍)。AOP(Aspect Oriented Programming,面向切面编程)的核心概念是以不改动源码为前提,通过前后“横切”的方式,动态为程序添加新功能,它的出现,最初是为了解决开发人员所面临的诸多耦合性问题,如图1所示。
我们都知道,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:
执行上述程序必然会导致触发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种使用方式,如下所示:
Agent on load;
Agent on attach。
前者的使用方式要求开发人员在启动脚本中加入命令行参数“-javaagent”
来指定目标jar文件,由应用的启动来同步带动Agent程序的启动。当然,首先需要定义好Agent-Class,示例1-2:
当Agent启动时,首先会触发对premain()函数的调用。在java.lang.instrument包下有2个非常重要的接口,分别是Instrumentation和ClassFileTransformer。前者作为增强器,其addTransformer()函数用于注册ClassFileTransformer实例;后者作为类文件转换器,需自行重写其transform()函数,用于返回增强内容。执行命令“-XX:+TraceClassLoading”
后不难发现,当目标类被加载进方法区之前,会由Instrumentation的实现负责回调transform()函数执行增强(已加载的类则需要手动触发Instrumentation.retransformClasses()函数显式重定义)。为了演示方便,我直接在premain()函数中实现了增强逻辑,但实际的开发过程中,增强逻辑往往非常复杂,并且在某些场景下还需要处理类隔离等问题,因此,通常情况下,Agent-Class所扮演的角色仅仅只是一个Launcher。
最后再对Agent进行打包之前,还需要在pom文件中定义<Premain-Class/>
和<Can-Retransform-Classes/>
等标签指定Agent-Class和允许对类定义做修改,示例1-3:
除了load方式外,我们还可以通过attach实现运行时的Instrument,也就是说,可以在JVM启动后,且所有类型均已全部完成加载之后再对目标类进行重定义。两种Instrument的使用方式基本大同小异,只是在定义Agent-Class时,入口函数为agentmain(),示例1-4:
其次,pom文件中所定义的<Premain-Class/>
标签需要更改为<Agent-Class>
。当对Agent进行打包后,我们只需要根据目标进程的PID便能实现动态附着,示例1-5:
示例1-2至1-5中,我为大家简要介绍了Instrumentation-API的基本使用,但在transform()函数中却并未实现具体的AOP增强逻辑,那么接下来,我就为大家演示如何使用字节码增强工具Javassist对目标类进行重定义,实现真正意义上的非侵入式运行期AOP增强。介于Javassist简单易用,并且很好的屏蔽了诸多底层技术细节,使得开发人员在即使不懂JVM指令的情况下也能够正确的操作字节码(Dubbo的动态代理生成使用的就是Javassist技术)。使用Javassist创建动态代理有2种方式,一种是基于ProxyFactory的方式,而另一种则是基于动态代码的实现方式,一般来说,选择后者可以获得更好的执行性能,示例1-6:
上述程序示例中,我基于load的方式来实现Instrument,介于ClassLoader每加载一个目标类就会调用transform()函数,所以这里需要在程序中使用equals()函数来判断目标类型。上述程序示例中,我对java.lang.String.toString()函数进行了增强,当触发toString()函数时,方法前后都会执行一段特定的增强代码,如图2所示。
在此大家需要注意,由于Javassist的抽象层次较高,尽管简单易用,但灵活性差,当面对一些复杂场景时(比如:需要根据特定的条件来进行插桩),则显得无能为力。因此,在接下来的小节中,我会重点为大家讲解关于字节码的一些基础知识,以及常用的JVM指令,以便大家快速上手ASM工具。
字节码
ASM是一种偏向于指令层面的专用于生成、转换,以及分析字节码的底层工具,尽管它的学习门槛和使用成本非常高,但与生俱来的灵活性和高性能却是它引以为傲的资本,因此,在掌握ASM的使用之前,大家首先需要对字节码结构以及JVM指令有所了解,否则将会无从下手。我们都知道,Java程序如果想要运行,首先需要由前端编译器(即:Javac)将符合Java语法规范的源代码编译为符合JVM规范的JVM指令,也就是说,语法指令与JVM指令本质上并不对等,编译器的作用就像是一个翻译官,将你原本听不懂的语言转换为你能够听懂并深刻理解的语言。接下来,我们先来看一段简单的Java代码,示例1-7:
成功编译为Class文件后,我们使用文本工具将其打开,如图3所示。
这一堆密密麻麻的16进制数究竟代表什么意思?首先,大家需要明确,Class文件是一组由8bit字节单位构成的二进制流,各个数据项之间会严格按照固定且紧凑的排列顺序组合在一起。或许大家存在一个疑问,既然是以8bit为单位,那么如果数据项所需占用的存储空间超过8bit时应该如何处理?简单来说,如果是多bit数据项,则会按照big-endian的顺序来进行存储。
既然Class文件中各个数据项之间是按照固定的顺序进行排列的,那么这些数据项究竟代表着什么?简单来说,构成Class文件结构的数据项大致包含10种,如图4所示。
magic(魔术)
它作为一个文件标识,当JVM加载目标Class文件时用于判断其是否是一个标准的Class文件,其16进制的固定值为0xCAFEBABE,占32bit。
version(版本号)
magic之后是minor_version和major_version数据项,它们用于构成Class文件的格式版本号,分别都占16bit,在实际的开发过程中,我相信大家都遇见过如下异常,示例1-8:
从异常堆栈中可以明确,目标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:
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:
有几点大家需要注意,源代码中的注释信息(非Annotation)并不会包含在Class文件中,毕竟注释是给人看的,它是一种增强代码可读性的辅助手段,但计算机却并不需要。其次,package和import部分也不会包含在Class文件中,先前讲解索引部分数据项时我曾经提及过,通常这类描述信息都是以全限定名的形式存放于constant_pool中。
细心的同学或许发现了一些端倪,示例1-10中,为什么类型全限定名中的符号由"."变成了“/”,并且有些前缀还包含有字母(“L”)和符号(“()”)?实际上,这是属于Class文件的一种内部表示形式,比如语法层面Object类的全限定名格式为“java.lang.Object”,但是在Class文件中,符号“.”会被“/”所替换,这样的表述形式我们称之为内部名。
如果是引用类型的字段,那么为什么内部名之前需要再加上字母“L”呢?这是因为内部名通常仅用于描述类和接口,而诸如字段等类型,Class文件中则提供有另一种表述形式,即类型描述符。其中数组的类型描述符在内部名之前还需要加上符号"[",其维度决定了符号“[”的个数,也就是说,如果是二维数组,那么类型描述符就是“[[Ljava/lang/Object;”,以此类推。除引用类型外,原始数据类型也有自己的类型描述符,Class文件中完整的类型描述符,如图5所示。
既然在描述字段时需要使用到字段描述符,那么在Class文件中,方法同样也具备有类似的描述符,叫做方法描述符,也就是在示例1-10中全限定名以符号(“()”)作为前缀的那部分描述符。方法描述符以符号“(”作为开头,其中包含着>=0个类型描述符,即入参类型,并以符号“)”结束,最后紧跟着方法的返回值类型,同样也是使用类型描述符表述,比如:方法“boolean register(String str1, String str2)”的方法描述符就写作“(Ljava/lang/String;Ljava/lang/String;)Z”形式。Class文件中完整的方法描述符,如图6所示。
基于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所示。
见名知意,ClassReader用于加载任意Class文件中的内容,并将其转发给ClassVisitor实现,也就是说,ClassReader在一个完整的事件转换链中是作为入口程序存在的。而ClassVisitor作为转换类及生成类的超类,其中每一个visit()方法都对应着同名类文件的结构部分,比如:方法(visitMethod)、注解(visitAnnotation)、字段(visitField)等,这是一个典型的Visitor模式。
我首先为大家演示如何基于一个自定义的ClassVisitor实现一个简单的反编译程序,示例1-11:
ClassVisitor在上述程序示例中作为匿名内部类的形式来处理反编译任务,但在实际的开发过程中,我们却并没有太大必要这么做,因为在org.objectweb.asm.util包下,ASM为开发人员提供有TraceClassVisitor类型专用于处理此类任务,示例1-12:
ClassVisitor实现可以选择将相关事件派发给下一个ClassVisitor实现,也可以选择将其转发给ClassWriter转储,如果选择后者,那么一个完整的转换链就构成了。在为大家讲解如何使用ClassVisitor实现修改字节码之前,我会首先为大家演示如何使用ClassWriter生成基于栈的指令集,示例1-13:
上述程序示例中,首先创建ClassWriter实例,然后调用visit()方法创建类标头,并指定字节码版本号、访问修饰符、类名,以及超类等基本信息,这里我们并不需要指定magic,并且也不需要单独指定minor_version和major_version,ASM会自行进行处理。当成功创建好类标头后,接下来就是调用ClassWriter.visitMethod()方法返回一个MethodVisitor实现为其目标类创建一个缺省的构造函数。其中mv.visitVarInsn(ALOAD, 0)
方法用于将this引用压入操作数栈的栈顶,通过指令INVOKESPECIAL
调用它的超类构造函数,至此,缺省构造行为结束,接下来就是一些具体的用户指令行为操作。
这里的用户操作非常简单,语法层面上仅仅只是一个new Object();
操作,但是转换为字节码指令后则显得相对繁冗。首先我们需要使用指令NEW
将java/lang/Object实例推入栈顶,然后使用INVOKESPECIAL
指令调用其构造函数,最后再使用指令POP
弹出栈顶元素,并返回即可。在此大家或许存在一个疑问,当使用NEW
指令将目标实例推入栈顶了,为何还需要再使用指令DUP
拷贝栈顶元素再次推入栈顶?其实这很好理解,栈顶元素在被用户访问之前,执行引擎首先需要弹出栈顶元素调用其“<init>”方法,因此为了避免元素出栈后无法为后续操作提供访问,所以需要单独拷贝一份,如图8所示。接下来,我再为大家演示一个稍微复杂一点的逻辑代码,示例1-14:
上述程序示例相对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:
在本小节的最后,我再为大家演示下如何通过自定义ClassVisitor实现来对目标字节码进行增强,其增强逻辑的主要内容为,对实现了java.lang.Runnable接口的任意类型的 run()方法前后插桩一段println()函数。首先我们需要在Transformer.transform()函数中进行相应的判断(基于Agent on load),只有Runnable实现才会执行相关的增强逻辑,示例1-16:
在ClassVisitor实现中,我们需要重写其visitMethod()方法,判断目标run()方法和实现增强逻辑,示例1-17:
增强逻辑具体由MethodEnhancementAdapter来负责,它是一个MethodVisitor的实现,before逻辑需要在其visitCode()方法中进行插桩,而after逻辑则需要在visitInsn()方法中进行插桩,示例1-18:
在visitInsn()方法中进行插桩时我们需要对当前指令进行判断,也就是说,after的增强逻辑应该发生在RETURN
指令执行之前。至此,关于字节码增强和ASM的整体使用就暂时介绍到这里,而关于一些更复杂的增强用法,建议大家阅读硬核系列 | 深入剖析 Java 协程。
类隔离策略
大家需要注意,在很多情况下,我们的Agent包中大概率会包含一些与目标程序相冲突的第三方构件依赖,在这种情况下,为了避免产生类污染,冲突等问题,Advice则只能由自定义类加载器来负责装载,那么这时就会面临一个问题,由子类加载器负责加载的类对父类加载器而言是不可见的,那么业务代码中应该如何调用Advice的代码呢?出于对效率等多方面因素的考虑,我们可以在最顶层的类加载器Bootstrap ClassLoader中注册一个对虚拟机中所有类加载器都具备可见性的间谍类Spy,如图9所示。
间谍类的作用是用来打通类隔离后的“通讯”操作,而对目标类进行增强时,并不会直接把增强逻辑固化到目标类上,而是持有一个对间谍类的引用,由间谍类负责持有对隔离类的方法引用(java.lang.reflect.Method),通过反射的方式来调用增强逻辑。
至此,本文内容全部结束。如果在阅读过程中有任何疑问,欢迎加入微信群聊和小伙伴们一起参与讨论。
推荐文章:
码字不易,欢迎转发
版权声明: 本文为 InfoQ 作者【高翔龙】的原创文章。
原文链接:【http://xie.infoq.cn/article/d367c19896e4cef6fbb661cf7】。文章转载请联系作者。
评论 (3 条评论)