当前位置: 首页 > 科技观察

Java字节码,你可以深入挖掘!

时间:2023-03-12 09:07:41 科技观察

Java真的是经久不衰,有着顽强的生命力。其中,字节码机制功不可没。字节码,例如Linux的ELF。有了它,JVM就直接变成了类似于操作系统的东西。学习字节码,不能光靠枯燥的文档。本文将介绍几款好用的工具,可以非常方便的使用它们来实际观察类文件这个小野兽,帮助你深入挖掘。一、字节码结构1.1.基本结构在开始之前,我们先简单介绍一下类文件的内容。可以使用jclasslib工具查看此结构。上图是class文件的基本内容。这部分内容比较枯燥,在Java官网可以很容易地找到关于它的详细内容。下图是一个简单方法的字节码描述,我们可以看到整个文件结构中实际执行指令的具体位置。1.2.实战观察为了避免枯燥的二进制对比分析,直接定位到真正的数据结构,这里提供一个小工具。使用这种方法学习字节码会节省很多时间。https://wiki.openjdk.java.net/display/CodeTools/asmtools这个工具就是asmtools,执行下面的命令,你会看到类的JCED语法结果。java-jarasmtools-7.0.jarjdecLambdaDemo.class的输出类似于下面的结构,对应我们上面介绍的字节码组成。相比官网或者书籍,学习速度非常快。LambdaDemo类{0xCAFEBABE;0;//次要版本52;//version[]{//常量池;//第一个元素为空方法#8#25;//#1InvokeDynamic0s#30;//#2接口方法#31#32;//#3字段#33#34;//#4字符串#35;//#5方法#36#37;//#6类#38;//#7类#39;//#8Utf8"";//#9Utf8"()V";//#10Utf8“代码”;//#11了解了类的文件组织,我们来看看类文件加载到内存后的表现形式是怎样的。2.内存表示准备如下代码,使用javac-gInvokeDemo.java编译。然后使用java命令执行。程序会阻塞在sleep函数上,我们来看看它的内存分布情况。接口I{defaultvoidinfMethod(){}voidinf();}abstractclassAbs{abstractvoidabs();}publicclassInvokeDemoextendsAbsimplementsI{staticvoidstaticMethod(){}privatevoidprivateMethod(){}publicvoidpublicMethod(){}@Overridepublicvoidinf(){}@Overridevoidabs(){}publicstaticvoidmain(String[]args)throwsException{InvokeDemodemo=newInvokeDemo();InvokeDemo.staticMethod();演示.abs();((Abs)演示).abs();演示.inf();((I)演示).inf();演示.privateMethod();演示.publicMethod();demo.infMethod();((I)演示).infMethod();Thread.sleep(Integer.MAX_VALUE);}}为了更清楚地看到这个过程,我们来介绍一下jhsdb工具,它是继Java9工具之后第一个加入JDK的调试工具,我们可以在命令行中使用jhsdbhsdb来启动它。注意加载对应进程时,必须保证是应用进程的同一版本,否则会报错。附加启动的Java进程后,您可以在类浏览器菜单中查看所有加载的类信息。我们在搜索框中输入InvokeDemo,找到我们要查看的类。@符号后就是具体的内存地址,我们可以复制一份,然后在Inspector视图中查看具体的属性。一般可以认为这是类在方法区的具体存放。在Inspector视图中,我们找到了方法相关的属性_methods,可惜无法打开查看。接下来,你可以使用命令行查看这个数组中的值。在菜单中打开控制台,然后输入检查命令。可以看到这个数组中的内容,对应的地址就是Class视图中的方法地址。examine0x000000010e650570/10我们可以在Inspect视图中看到方法对应的内存信息,这确实是Method方法的一种表现形式。相比之下,对象就简单了,只需要保存一个指向Class对象的指针即可。我们需要先从对象视图进入,然后找到它,一步步进入Inspect视图。从上面的分析,我们可以得出如下图。如果执行引擎要运行一个对象的方法,需要先在栈上找到该对象的引用,然后通过该对象的指针找到对应的方法字节码。3、方法调用指令关于方法调用,Java一共提供了5条指令,用于调用不同类型的函数。invokestaticinvokevirtualinvokeinterface与上面的指令类似,但是作用于接口类。invokespecial用于调用私有实例方法、构造函数和super关键字。invokedynamic用于调用动态方法。我们还是用上面的代码片段来看一下前四个指令的使用场景。代码中包含一个接口I,一个抽象类Abs,以及一个实现并继承了两者的类InvokeDemo。参考Java的类加载机制,类文件加载到方法区后,就完成了从符号引用到具体地址的转换过程。我们可以看看main方法编译后的字节码。尤其要注意接口方法的调用。调用的字节码指令分别是invokevirtual和invokeinterface,它们是不同的。publicstaticvoidmain(java.lang.String[]);descriptor:([Ljava/lang/String;)Vflags:ACC_PUBLIC,ACC_STATICCode:stack=2,locals=2,args_size=10:new#2//classInvokeDemo3:dup4:invokespecial#3//方法"":()V7:astore_18:invokestatic#4//方法staticMethod:()V11:aload_112:invokevirtual#5//方法abs:()V15:aload_116:invokevirtual#6//方法Abs.abs:()V19:aload_120:invokevirtual#7//方法inf:()V23:aload_124:invokeinterface#8,1//InterfaceMethodI.inf:()V29:aload_130:invokespecial#9//方法privateMethod:()V33:aload_134:invokevirtual#10//方法publicMethod:()V37:aload_138:invokevirtual#11//MethodinfMethod:()V41:aload_142:invokeinterface#12,1//InterfaceMethodI.infMethod:()V47:return还有一点,和我们想象的不一样,最普通的方法调用使用了invokevirtual指令,其实和invokeinterface类似,都属于虚方法调用。很多时候,JVM需要根据调用者的动态类型来确定要调用的目标方法。这就是动态绑定的过程。.invokevirtual指令具有多态查找机制。该指令的运行时分析过程步骤如下:查找操作数栈顶第一个元素指向的对象的实际类型,记为c。如果在类型c中发现了同时匹配描述符和常量中的简单名称的方法,则执行访问权限检查。如果通过,则返回该方法的直接引用,搜索过程结束。如果失败,则返回java.lang.IllegalAccessError。否则,按照继承关系从下往上依次对c的各个父类进行第二次查找验证过程。没有找到合适的方法,抛出java.lang.AbstractMethodError异常。这就是java语言中方法重写的本质。相反,invokestatic指令与invokespecial指令一起属于静态绑定过程。因此,静态绑定是指可以直接识别目标方法的情况,而动态绑定是指运行时需要根据调用者的类型来确定目标方法的情况。可以想象,动态绑定调用比静态绑定方法调用更耗时。因为方法的调用非常频繁,所以JVM对动态调用的代码做了更多的优化。示例包括使用方法表来加速特定方法的寻址,以及使用更快的缓冲区进行直接寻址(内联缓存)。4.invokedynamic有时候在写一些python脚本或者js脚本的时候,会特别羡慕这些动态语言。如果将寻找目标方法的决定权从虚拟机转移到用户代码中,我们将拥有更高的自由度。我们把invokedynamic分开来介绍,因为比较复杂。和反射类似,用在一些动态调用的场景,但是和反射有本质的区别,它的效率比反射高很多。这条指令通常出现在lambda语法中,我们来看一小段代码。publicclassLambdaDemo{publicstaticvoidmain(String[]args){Runnabler=()->System.out.println("HelloLambda");r.run();}}使用命令javap-p-v查看main方法中的invokedynamic指令。publicstaticvoidmain(java.lang.String[]);描述符:([Ljava/lang/String;)Vflags:ACC_PUBLIC,ACC_STATICCode:stack=1,locals=2,args_size=10:invokedynamic#2,0//InvokeDynamic#0:run:()Ljava/lang/可运行;5:astore_16:aload_17:invokeinterface#3,1//InterfaceMethodjava/lang/Runnable.run:()V12:return此外,我们在javap的输出中发现了一些奇怪的东西。BootstrapMethods:0:#27invokestaticjava/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;方法参数:#28()V#29invokestaticLambdaDemo.lambda$main$0:()V#28()VBootstrapMethods属性只有在Java1.7之后才有,位于类文件的属性列表中。该属性用于保存invokedynamic指令引用的引导方法限定符。与上面描述的四个指令不同,invokedynamic没有一个确切的接收对象,而是一个名为CallSite的对象。静态CallSite引导程序(MethodHandles.Lookup调用者,字符串名称,MethodType类型);实际上,invokedynamic指令的底层是使用方法句柄(MethodHandle)实现的。方法句柄是对静态和实例方法以及虚构的get和set方法的可执行引用,它们在IDE中是可见的。句柄类型(MethodType)就是我们对方法的具体描述。有了方法名,就可以定位到一类函数。访问方法句柄与调用原始指令基本相同,但它的调用异常,包括一些权限检查,只能在运行时发现。lambda语言其实是通过方法句柄完成的,调用链上自然多了一些调用步骤。那么从性能上来说,是不是就说明lambda的性能低了呢?对于大多数“非捕获”的lambda表达式,JIT编译器的转义分析可以优化这部分差异,性能与传统方法相同;但对于“捕获”表达式,需要通过方法handle,不断生成适配器,性能自然会低很多(但相对于方便,稍微损失一点性能是可以接受的)。除了lambda表达式,我们没有其他方法来生成invokedynamic指令。但是我们可以使用一些外部的字节码修改工具,比如ASM,用这个指令生成一些字节码,通常可以完成一些很酷的功能,比如完成一个带有弱类型检查的JVM-Base语言。END本文从Java字节码的顶层结构介绍入手,通过一段实际代码,了解类加载后在JVM内存中的表现形式,了解jhsdb是如何观察Java进程的。我们学习了Java7之后的invokedynamic指令,其实就是通过一个方法句柄来实现的。与我们最相关的是Lambda语法。了解了这些原理之后,我们就可以忽略那些关于Lambda性能的争论,尝试写一些“非捕获”的Lambda表达式。什么?你问什么是非捕获?然后你需要自己去捡。作者简介:品味小姐姐(xjjdog),一个不允许程序员走弯路的公众号。专注于基础架构和Linux。十年架构,每天百亿流量,与你探讨高并发世界,给你不一样的滋味。