Java之因此能够“一次编译,处处运行”,一是由于JVM针对各类操做系统、平台都进行了定制,二是由于不管在什么平台,均可以编译生成固定格式的字节码(.class文件)供JVM使用。所以,也能够看出字节码对于Java生态的重要性。之因此被称之为字节码,是由于字节码文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取。在Java中通常是用javac命令编译源代码为字节码文件,一个.java文件从编译到运行的示例如图1所示。html
对于开发人员,了解字节码能够更准确、直观地理解Java语言中更深层次的东西,好比经过字节码,能够很直观地看到Volatile关键字如何在字节码上生效。另外,字节码加强技术在Spring AOP、各类ORM框架、热部署中的应用家常便饭,深刻理解其原理对于咱们来讲大有裨益。除此以外,因为JVM规范的存在,只要最终能够生成符合规范的字节码就能够在JVM上运行,所以这就给了各类运行在JVM上的语言(如Scala、Groovy、Kotlin)一种契机,能够扩展Java所没有的特性或者实现各类语法糖。理解字节码后再学习这些语言,能够“逆流而上”,从字节码视角看它的设计思路,学习起来也“易如反掌”。java
本文重点着眼于字节码加强技术,从字节码开始逐层向上,由JVM字节码操做集合到Java中操做字节码的框架,再到咱们熟悉的各种框架原理及应用,也都会一一进行介绍。web
.java文件经过javac编译后将获得一个.class文件,好比编写一个简单的ByteCodeDemo类,以下图2的左侧部分:编程
编译后生成ByteCodeDemo.class文件,打开后是一堆十六进制数,按字节为单位进行分割后展现如图2右侧部分所示。上文说起过,JVM对于字节码是有规范要求的,那么看似杂乱的十六进制符合什么结构呢?JVM规范要求每个字节码文件都要由十部分按照固定的顺序组成,总体结构如图3所示。接下来咱们将一一介绍这十部分:api
(1) 魔数(Magic Number)数组
全部的.class文件的前四个字节都是魔数,魔数的固定值为:0xCAFEBABE。魔数放在文件开头,JVM能够根据文件的开头来判断这个文件是否多是一个.class文件,若是是,才会继续进行以后的操做。缓存
有趣的是,魔数的固定值是Java之父James Gosling制定的,为CafeBabe(咖啡宝贝),而Java的图标为一杯咖啡。安全
(2) 版本号数据结构
版本号为魔数以后的4个字节,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。上图2中版本号为“00 00 00 34”,次版本号转化为十进制为0,主版本号转化为十进制为52,在Oracle官网中查询序号52对应的主版本号为1.8,因此编译该文件的Java版本号为1.8.0。架构
(3) 常量池(Constant Pool)
紧接着主版本号以后的字节为常量池入口。常量池中存储两类常量:字面量与符号引用。字面量为代码中声明为Final的常量值,符号引用如类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符。常量池总体上分为两部分:常量池计数器以及常量池数据区,以下图4所示。
具体以CONSTANT_utf8_info为例,它的结构以下图7左侧所示。首先一个字节“tag”,它的值取自上图6中对应项的Tag,因为它的类型是utf8_info,因此值为“01”。接下来两个字节标识该字符串的长度Length,而后Length个字节为这个字符串具体的值。从图2中的字节码摘取一个cp_info结构,以下图7右侧所示。将它翻译过来后,其含义为:该常量类型为utf8字符串,长度为一字节,数据为“a”。
其余类型的cp_info结构在本文再也不赘述,总体结构大同小异,都是先经过Tag来标识类型,而后后续n个字节来描述长度和(或)数据。先知其因此然,之后能够经过javap -verbose ByteCodeDemo命令,查看JVM反编译后的完整常量池,以下图8所示。能够看到反编译结果将每个cp_info结构的类型和值都很明确地呈现了出来。
(4) 访问标志
常量池结束以后的两个字节,描述该Class是类仍是接口,以及是否被Public、Abstract、Final等修饰符修饰。JVM规范规定了以下图9的访问标志(Access_Flag)。须要注意的是,JVM并无穷举全部的访问标志,而是使用按位或操做来进行描述的,好比某个类的修饰符为Public Final,则对应的访问修饰符的值为ACC_PUBLIC | ACC_FINAL,即0x0001 | 0x0010=0x0011。
(5) 当前类名
访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
(6) 父类名称
当前类名后的两个字节,描述父类的全限定名,同上,保存的也是常量池中的索引值。
(7) 接口信息
父类名称后为两字节的接口计数器,描述了该类或父类实现的接口数量。紧接着的n个字节是全部接口名称的字符串常量的索引值。
(8) 字段表
字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,可是不包含方法内部声明的局部变量。字段表也分为两部分,第一部分为两个字节,描述字段个数;第二部分是每一个字段的详细信息fields_info。字段表结构以下图所示:
以图2中字节码的字段表为例,以下图11所示。其中字段的访问标志查图9,0002对应为Private。经过索引下标在图8中常量池分别获得字段名为“a”,描述符为“I”(表明int)。综上,就能够惟一肯定出一个类中声明的变量private int a。
(9)方法表
字段表结束后为方法表,方法表也是由两部分组成,第一部分为两个字节描述方法的个数;第二部分为每一个方法的详细信息。方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性,以下图所示:
方法的权限修饰符依然能够经过图9的值查询获得,方法名和方法的描述符都是常量池中的索引值,能够经过索引值在常量池中找到。而“方法的属性”这一部分较为复杂,直接借助javap -verbose将其反编译为人能够读懂的信息进行解读,如图13所示。能够看到属性中包括如下三个部分:
(10)附加属性表
字节码的最后一部分,该项存放了在该文件中类或接口所定义属性的基本信息。
在上图13中,Code区的红色编号0~17,就是.java中的方法源代码编译后让JVM真正执行的操做码。为了帮助人们理解,反编译后看到的是十六进制操做码所对应的助记符,十六进制值操做码与助记符的对应关系,以及每个操做码的用处能够查看Oracle官方文档进行了解,在须要用到时进行查阅便可。好比上图中第一个助记符为iconst_2,对应到图2中的字节码为0x05,用处是将int值2压入操做数栈中。以此类推,对0~17的助记符理解后,就是完整的add()方法的实现。
JVM的指令集是基于栈而不是寄存器,基于栈能够具有很好的跨平台性(由于寄存器指令集每每和硬件挂钩),但缺点在于,要完成一样的操做,基于栈的实现须要更多指令才能完成(由于栈只是一个FILO结构,须要频繁压栈出栈)。另外,因为栈是在内存实现的,而寄存器是在CPU的高速缓存区,相较而言,基于栈的速度要慢不少,这也是为了跨平台性而作出的牺牲。
咱们在上文所说的操做码或者操做集合,其实控制的就是这个JVM的操做数栈。为了更直观地感觉操做码是如何控制操做数栈的,以及理解常量池、变量表的做用,将add()方法的对操做数栈的操做制做为GIF,以下图14所示,图中仅截取了常量池中被引用的部分,以指令iconst_2开始到ireturn结束,与图13中Code区0~17的指令一一对应:
若是每次查看反编译后的字节码都使用javap命令的话,好很是繁琐。这里推荐一个Idea插件:jclasslib。使用效果如图15所示,代码编译后在菜单栏”View”中选择”Show Bytecode With jclasslib”,能够很直观地看到当前字节码文件的类信息、常量池、方法区等信息。
在上文中,着重介绍了字节码的结构,这为咱们了解字节码加强技术的实现打下了基础。字节码加强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术。接下来,咱们将从最直接操纵字节码的实现方式开始深刻进行剖析。
对于须要手动操纵字节码的需求,可使用ASM,它能够直接生产 .class字节码文件,也能够在类被加载入JVM以前动态修改类行为(以下图17所示)。ASM的应用场景有AOP(Cglib就是基于ASM)、热部署、修改其余jar包中的类等。固然,涉及到如此底层的步骤,实现起来也比较麻烦。接下来,本文将介绍ASM的两种API,并用ASM来实现一个比较粗糙的AOP。但在此以前,为了让你们更快地理解ASM的处理流程,强烈建议读者先对访问者模式进行了解。简单来讲,访问者模式主要用于修改或操做一些数据结构比较稳定的数据,而经过第一章,咱们知道字节码文件的结构是由JVM固定的,因此很适合利用访问者模式对字节码文件进行修改。
ASM Core API能够类比解析XML文件中的SAX方式,不须要把这个类的整个结构读取进来,就能够用流式的方法来处理字节码文件。好处是很是节约内存,可是编程难度较大。然而出于性能考虑,通常状况下编程都使用Core API。在Core API中有如下几个关键类:
ASM Tree API能够类比解析XML文件中的DOM方式,把整个类的结构读取到内存中,缺点是消耗内存多,可是编程比较简单。TreeApi不一样于CoreAPI,TreeAPI经过各类Node类来映射字节码的各个区域,类比DOM节点,就能够很好地理解这种编程方式。
利用ASM的CoreAPI来加强类。这里不纠结于AOP的专业名词如切片、通知,只实如今方法调用前、后增长逻辑,通俗易懂且方便理解。首先定义须要被加强的Base类:其中只包含一个process()方法,方法内输出一行“process”。加强后,咱们指望的是,方法执行前输出“start”,以后输出”end”。
public class Base { public void process(){ System.out.println("process"); } }
为了利用ASM实现AOP,须要定义两个类:一个是MyClassVisitor类,用于对字节码的visit以及修改;另外一个是Generator类,在这个类中定义ClassReader和ClassWriter,其中的逻辑是,classReader读取字节码,而后交给MyClassVisitor类处理,处理完成后由ClassWriter写字节码并将旧的字节码替换掉。Generator类较简单,咱们先看一下它的实现,以下所示,而后重点解释MyClassVisitor类。
import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter; public class Generator { public static void main(String[] args) throws Exception { //读取 ClassReader classReader = new ClassReader("meituan/bytecode/asm/Base"); ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS); //处理 ClassVisitor classVisitor = new MyClassVisitor(classWriter); classReader.accept(classVisitor, ClassReader.SKIP_DEBUG); byte[] data = classWriter.toByteArray(); //输出 File f = new File("operation-server/target/classes/meituan/bytecode/asm/Base.class"); FileOutputStream fout = new FileOutputStream(f); fout.write(data); fout.close(); System.out.println("now generator cc success!!!!!"); } }
MyClassVisitor继承自ClassVisitor,用于对字节码的观察。它还包含一个内部类MyMethodVisitor,继承自MethodVisitor用于对类内方法的观察,它的总体代码以下:
import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; public class MyClassVisitor extends ClassVisitor implements Opcodes { public MyClassVisitor(ClassVisitor cv) { super(ASM5, cv); } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { cv.visit(version, access, name, signature, superName, interfaces); } @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions); //Base类中有两个方法:无参构造以及process方法,这里不加强构造方法 if (!name.equals("<init>") && mv != null) { mv = new MyMethodVisitor(mv); } return mv; } class MyMethodVisitor extends MethodVisitor implements Opcodes { public MyMethodVisitor(MethodVisitor mv) { super(Opcodes.ASM5, mv); } @Override public void visitCode() { super.visitCode(); mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitLdcInsn("start"); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); } @Override public void visitInsn(int opcode) { if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN) || opcode == Opcodes.ATHROW) { //方法在返回以前,打印"end" mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitLdcInsn("end"); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); } mv.visitInsn(opcode); } } }
利用这个类就能够实现对字节码的修改。详细解读其中的代码,对字节码作修改的步骤是:
<init>
后,将须要被加强的方法交给内部类MyMethodVisitor来进行处理。完成这两个visitor类后,运行Generator中的main方法完成对Base类的字节码加强,加强后的结果能够在编译后的target文件夹中找到Base.class文件进行查看,能够看到反编译后的代码已经改变了(如图18左侧所示)。而后写一个测试类MyTest,在其中new Base(),并调用base.process()方法,能够看到下图右侧所示的AOP实现效果:
利用ASM手写字节码时,须要利用一系列visitXXXXInsn()方法来写对应的助记符,因此须要先将每一行源代码转化为一个个的助记符,而后经过ASM的语法转换为visitXXXXInsn()这种写法。第一步将源码转化为助记符就已经够麻烦了,不熟悉字节码操做集合的话,须要咱们将代码编译后再反编译,才能获得源代码对应的助记符。第二步利用ASM写字节码时,如何传参也很使人头疼。ASM社区也知道这两个问题,因此提供了工具ASM ByteCode Outline。
安装后,右键选择“Show Bytecode Outline”,在新标签页中选择“ASMified”这个tab,如图19所示,就能够看到这个类中的代码对应的ASM写法了。图中上下两个红框分别对应AOP中的前置逻辑于后置逻辑,将这两块直接复制到visitor中的visitMethod()以及visitInsn()方法中,就能够了。
ASM是在指令层次上操做字节码的,阅读上文后,咱们的直观感觉是在指令层次上操做字节码的框架实现起来比较晦涩。故除此以外,咱们再简单介绍另一类框架:强调源代码层次操做字节码的框架Javassist。
利用Javassist实现字节码加强时,能够无须关注字节码刻板的结构,其优势就在于编程简单。直接使用java编码的形式,而不须要了解虚拟机指令,就能动态改变类的结构或者动态生成类。其中最重要的是ClassPool、CtClass、CtMethod、CtField这四个类:
了解这四个类后,咱们能够写一个小Demo来展现Javassist简单、快速的特色。咱们依然是对Base中的process()方法作加强,在方法调用先后分别输出”start”和”end”,实现代码以下。咱们须要作的就是从pool中获取到相应的CtClass对象和其中的方法,而后执行method.insertBefore和insertAfter方法,参数为要插入的Java代码,再以字符串的形式传入便可,实现起来也极为简单。
import com.meituan.mtrace.agent.javassist.*; public class JavassistTest { public static void main(String[] args) throws NotFoundException, CannotCompileException, IllegalAccessException, InstantiationException, IOException { ClassPool cp = ClassPool.getDefault(); CtClass cc = cp.get("meituan.bytecode.javassist.Base"); CtMethod m = cc.getDeclaredMethod("process"); m.insertBefore("{ System.out.println(\"start\"); }"); m.insertAfter("{ System.out.println(\"end\"); }"); Class c = cc.toClass(); cc.writeFile("/Users/zen/projects"); Base h = (Base)c.newInstance(); h.process(); } }
上一章重点介绍了两种不一样类型的字节码操做框架,且都利用它们实现了较为粗糙的AOP。其实,为了方便你们理解字节码加强技术,在上文中咱们拈轻怕重将ASM实现AOP的过程分为了两个main方法:第一个是利用MyClassVisitor对已编译好的class文件进行修改,第二个是new对象并调用。这期间并不涉及到JVM运行时对类的重加载,而是在第一个main方法中,经过ASM对已编译类的字节码进行替换,在第二个main方法中,直接使用已替换好的新类信息。另外在Javassist的实现中,咱们也只加载了一次Base类,也不涉及到运行时重加载类。
若是咱们在一个JVM中,先加载了一个类,而后又对其进行字节码加强并从新加载会发生什么呢?模拟这种状况,只须要咱们在上文中Javassist的Demo中main()方法的第一行添加Base b=new Base(),即在加强前就先让JVM加载Base类,而后在执行到c.toClass()方法时会抛出错误,以下图20所示。跟进c.toClass()方法中,咱们会发现它是在最后调用了ClassLoader的native方法defineClass()时报错。也就是说,JVM是不容许在运行时动态重载一个类的。
显然,若是只能在类加载前对类进行强化,那字节码加强技术的使用场景就变得很窄了。咱们指望的效果是:在一个持续运行并已经加载了全部类的JVM中,还能利用字节码加强技术对其中的类行为作替换并从新加载。为了模拟这种状况,咱们将Base类作改写,在其中编写main方法,每五秒调用一次process()方法,在process()方法中输出一行“process”。
咱们的目的就是,在JVM运行中的时候,将process()方法作替换,在其先后分别打印“start”和“end”。也就是在运行中时,每五秒打印的内容由”process”变为打印”start process end”。那如何解决JVM不容许运行时重加载类信息的问题呢?为了达到这个目的,咱们接下来一一来介绍须要借助的Java类库。
import java.lang.management.ManagementFactory; public class Base { public static void main(String[] args) { String name = ManagementFactory.getRuntimeMXBean().getName(); String s = name.split("@")[0]; //打印当前Pid System.out.println("pid:"+s); while (true) { try { Thread.sleep(5000L); } catch (Exception e) { break; } process(); } } public static void process() { System.out.println("process"); } }
instrument是JVM提供的一个能够修改已加载类的类库,专门为Java语言编写的插桩服务提供支持。它须要依赖JVMTI的Attach API机制实现,JVMTI这一部分,咱们将在下一小节进行介绍。在JDK 1.6之前,instrument只能在JVM刚启动开始加载类时生效,而在JDK 1.6以后,instrument支持了在运行时对类定义的修改。要使用instrument的类修改功能,咱们须要实现它提供的ClassFileTransformer接口,定义一个类文件转换器。接口中的transform()方法会在类文件被加载时调用,而在transform方法里,咱们能够利用上文中的ASM或Javassist对传入的字节码进行改写或替换,生成新的字节码数组后返回。
咱们定义一个实现了ClassFileTransformer接口的类TestTransformer,依然在其中利用Javassist对Base类中的process()方法进行加强,在先后分别打印“start”和“end”,代码以下:
import java.lang.instrument.ClassFileTransformer; public class TestTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { System.out.println("Transforming " + className); try { ClassPool cp = ClassPool.getDefault(); CtClass cc = cp.get("meituan.bytecode.jvmti.Base"); CtMethod m = cc.getDeclaredMethod("process"); m.insertBefore("{ System.out.println(\"start\"); }"); m.insertAfter("{ System.out.println(\"end\"); }"); return cc.toBytecode(); } catch (Exception e) { e.printStackTrace(); } return null; } }
如今有了Transformer,那么它要如何注入到正在运行的JVM呢?还须要定义一个Agent,借助Agent的能力将Instrument注入到JVM中。咱们将在下一小节介绍Agent,如今要介绍的是Agent中用到的另外一个类Instrumentation。在JDK 1.6以后,Instrumentation能够作启动后的Instrument、本地代码(Native Code)的Instrument,以及动态改变Classpath等等。咱们能够向Instrumentation中添加上文中定义的Transformer,并指定要被重加载的类,代码以下所示。这样,当Agent被Attach到一个JVM中时,就会执行类字节码替换并重载入JVM的操做。
import java.lang.instrument.Instrumentation; public class TestAgent { public static void agentmain(String args, Instrumentation inst) { //指定咱们本身定义的Transformer,在其中利用Javassist作字节码替换 inst.addTransformer(new TestTransformer(), true); try { //重定义类并载入新的字节码 inst.retransformClasses(Base.class); System.out.println("Agent Load Done."); } catch (Exception e) { System.out.println("agent load failed!"); } } }
上一小节中,咱们给出了Agent类的代码,追根溯源须要先介绍JPDA(Java Platform Debugger Architecture)。若是JVM启动时开启了JPDA,那么类是容许被从新加载的。在这种状况下,已被加载的旧版本类信息能够被卸载,而后从新加载新版本的类。正如JDPA名称中的Debugger,JDPA实际上是一套用于调试Java程序的标准,任何JDK都必须实现该标准。
JPDA定义了一整套完整的体系,它将调试体系分为三部分,并规定了三者之间的通讯接口。三部分由低到高分别是Java 虚拟机工具接口(JVMTI),Java 调试协议(JDWP)以及 Java 调试接口(JDI),三者之间的关系以下图所示:
如今回到正题,咱们能够借助JVMTI的一部分能力,帮助动态重载类信息。JVM TI(JVM TOOL INTERFACE,JVM工具接口)是JVM提供的一套对JVM进行操做的工具接口。经过JVMTI,能够实现对JVM的多种操做,它经过接口注册各类事件勾子,在JVM事件触发时,同时触发预约义的勾子,以实现对各个JVM事件的响应,事件包括类文件加载、异常产生与捕获、线程启动和结束、进入和退出临界区、成员变量修改、GC开始和结束、方法调用进入和退出、临界区竞争与等待、VM启动与退出等等。
而Agent就是JVMTI的一种实现,Agent有两种启动方式,一是随Java进程启动而启动,常常见到的java -agentlib就是这种方式;二是运行时载入,经过attach API,将模块(jar包)动态地Attach到指定进程id的Java进程内。
Attach API 的做用是提供JVM进程间通讯的能力,好比说咱们为了让另一个JVM进程把线上服务的线程Dump出来,会运行jstack或jmap的进程,并传递pid的参数,告诉它要对哪一个进程进行线程Dump,这就是Attach API作的事情。在下面,咱们将经过Attach API的loadAgent()方法,将打包好的Agent jar包动态Attach到目标JVM上。具体实现起来的步骤以下:
import com.sun.tools.attach.VirtualMachine; public class Attacher { public static void main(String[] args) throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException { // 传入目标 JVM pid VirtualMachine vm = VirtualMachine.attach("39333"); vm.loadAgent("/Users/zen/operation_server_jar/operation-server.jar"); } }
如下为运行时从新载入类的效果:先运行Base中的main()方法,启动一个JVM,能够在控制台看到每隔五秒输出一次”process”。接着执行Attacher中的main()方法,并将上一个JVM的pid传入。此时回到上一个main()方法的控制台,能够看到如今每隔五秒输出”process”先后会分别输出”start”和”end”,也就是说完成了运行时的字节码加强,并从新载入了这个类。
至此,字节码加强技术的可以使用范围就再也不局限于JVM加载类前了。经过上述几个类库,咱们能够在运行时对JVM中的类进行修改并重载了。经过这种手段,能够作的事情就变得不少了:
字节码加强技术至关因而一把打开运行时JVM的钥匙,利用它能够动态地对运行中的程序作修改,也能够跟踪JVM运行中程序的状态。此外,咱们平时使用的动态代理、AOP也与字节码加强密切相关,它们实质上仍是利用各类手段生成符合规范的字节码文件。综上所述,掌握字节码加强后能够高效地定位并快速修复一些棘手的问题(如线上性能问题、方法出现不可控的出入参须要紧急加日志等问题),也能够在开发中减小冗余代码,大大提升开发效率。
泽恩,美团点评研发工程师。
美团到店住宿业务研发团队负责美团酒店核心业务系统建设,致力于经过技术践行“帮你们住得更好”的使命。美团酒店多次刷新行业记录,最近12个月酒店预订间夜量达到3个亿,单日入住间夜量峰值突破280万。团队的愿景是:建设打造旅游住宿行业一流的技术架构,从质量、安全、效率、性能多角度保障系统高速发展。
美团到店事业群住宿业务研发团队现诚聘后台开发工程师/技术专家,欢迎有兴趣的同窗投简历至:tech@meituan.com(注明:美团到店事业群住宿业务研发团队)