深圳幻海软件技术有限公司 欢迎您!

Java字节码,你还可以搲的更深一些!

2023-02-28

Java真的是长盛不衰,拥有顽强的生命力。其中,字节码机制功不可没。字节码,就像是Linux的ELF。有了它,JVM直接摇身一变,变成了类似操作系统的东西。要学习字节码,不能仅仅靠看枯燥的文档。本文会介绍几个有用的工具,可以非常容易的上手,来实际观测class文件这个小魔兽,助你搲的更深一些。1、字

Java真的是长盛不衰,拥有顽强的生命力。其中,字节码机制功不可没。字节码,就像是 Linux 的 ELF。有了它,JVM直接摇身一变,变成了类似操作系统的东西。

要学习字节码,不能仅仅靠看枯燥的文档。本文会介绍几个有用的工具,可以非常容易的上手,来实际观测class文件这个小魔兽,助你搲的更深一些。

1、字节码结构

1.1、基本结构

在开始之前,我们先简要的介绍一下class文件的内容。这个结构,可以使用jclasslib工具来查看。

上图是class文件基本内容。这部分内容枯燥乏味,关于它的细节在Java的官方都能非常容易的找到。

如下图,展示了一个简单方法的字节码描述,我们可以看到真正的执行指令在整个文件结构中的具体位置。

1.2、实际观测

为了让大家避免避免枯燥的二进制对比分析,直接定位到真正的数据结构,这里介绍一个小工具,使用这种方式学习字节码会节省很多时间。

https://wiki.openjdk.java.net/display/CodeTools/asmtools
  • 1.

这个工具就是asmtools,执行下面的命令,将看到类的 JCED 语法结果。

java -jar asmtools-7.0.jar jdec LambdaDemo.class
  • 1.

输出的结果类似于下面的结构,它与我们上面介绍的字节码组成是一一对应的,对照官网或者书籍,学习速度飞快。

class LambdaDemo {
  0xCAFEBABE;
  0; // minor version
  52; // version
  [] { // Constant Pool
    ; // first element is empty
    Method #8 #25; // #1
    InvokeDynamic 0s #30; // #2
    InterfaceMethod #31 #32; // #3
    Field #33 #34; // #4
    String #35; // #5
    Method #36 #37; // #6
    class #38; // #7
    class #39; // #8
    Utf8 "<init>"; // #9
    Utf8 "()V"; // #10
    Utf8 "Code"; // #11
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

了解了类的文件组织方式,下面我们来看一下,类文件在加载到内存中以后,是一个什么表现形式。

2、内存表示

准备以下代码,使用javac -g InvokeDemo.java进行编译。然后使用java命令执行。程序将阻塞在sleep函数上,我们来看一下它的内存分布。

interface I {
    default void infMethod() { }

    void inf();
}

abstract class Abs {
    abstract void abs();
}

public class InvokeDemo extends Abs implements I {


    static void staticMethod() { }

    private void privateMethod() { }

    public void publicMethod() { }

    @Override
    public void inf() { }

    @Override
    void abs() { }

    public static void main(String[] args) throws Exception{
        InvokeDemo demo = new InvokeDemo();

        InvokeDemo.staticMethod();
        demo.abs();
        ((Abs) demo).abs();
        demo.inf();
        ((I) demo).inf();
        demo.privateMethod();
        demo.publicMethod();
        demo.infMethod();
        ((I) demo).infMethod();


        Thread.sleep(Integer.MAX_VALUE);
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.

为了更加明显的看到这个过程,下面介绍一下 jhsdb 这个工具,这是在 Java9 之后 JDK 先加入的调试工具,我们可以在命令行使用 jhsdb hsdb 来启动它。注意,要加载相应的进程时,必须确保是同一个版本的应用进程,否则会产生报错。

attach启动后的Java进程后,可以在 Class Browser 菜单查看加载的所有类信息。我们在搜索框输入 InvokeDemo,找到要查看的类。

@符号后面的,就是具体的内存地址,我们可以复制一个,然后在Inspector 视图查看具体的属性。可以大体认为这就是类在方法区的具体存储。

在Inspector视图中,我们找到方法相关的属性 _methods,可惜的是它无法点开,也无法查看。

接下来可以使用命令行来检查这个数组里面的值。打开菜单中Console,然后输入examine命令。可以看到这个数组里的内容,对应的地址就是Class视图中的方法地址。

examine 0x000000010e650570/10
  • 1.

我们可以在Inspect视图看到方法所对应的内存信息,这确实是一个Method方法的表示。

相比较起来,对象就简单的,它只需要保存一个到达Class对象的指针即可。我们需要先从对象视图进入,然后找到它,一步步进入Inspect视图。

由以上的这些分析,我们可以得出下面这张图。执行引擎想要运行某个对象的方法,需要先在栈上找到这个对象的引用,然后再通过的对象的指针,找到相应的方法字节码。

3、方法调用指令

关于方法的调用,Java一共提供了5个指令,用来调用不同类型的函数。

  1. invokestatic 
  2. invokevirtual 
  3. invokeinterface 和上面这条指令类似,不过是作用于接口类。
  4. invokespecial 用于调用私有实例方法、构造器,以及super关键字等。
  5. invokedynamic 用于调用动态方法。

我们依然使用上面的代码片段看一下前四个指令的使用场景。代码中包含一个接口I,一个抽象类Abs,一个实现和继承了两者的类InvokeDemo。

参考Java的类加载机制,在class文件被加载到方法区以后,就完成了从符号引用到具体地址的转换过程。

我们可以看一下编译后的main方法字节码。尤其需要注意的是对于接口方法的调用。使用实例对象直接调用,和强制转化成接口调用,所调用的字节码指令分别是 invokevirtual 和invokeinterface,它们是不同的。

QQkQ">

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class InvokeDemo
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: invokestatic  #4                  // Method staticMethod:()V
        11: aload_1
        12: invokevirtual #5                  // Method abs:()V
        15: aload_1
        16: invokevirtual #6                  // Method Abs.abs:()V
        19: aload_1
        20: invokevirtual #7                  // Method inf:()V
        23: aload_1
        24: invokeinterface #8,  1            // InterfaceMethod I.inf:()V
        29: aload_1
        30: invokespecial #9                  // Method privateMethod:()V
        33: aload_1
        34: invokevirtual #10                 // Method publicMethod:()V
        37: aload_1
        38: invokevirtual #11                 // Method infMethod:()V
        41: aload_1
        42: invokeinterface #12,  1           // InterfaceMethod I.infMethod:()V
        47: return
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.

另外还有一点,和我们想象中的不同,大多数普通方法调用,使用的是 invokevirtual 指令,它其实是和invokeinterface 一类的,都属于虚方法调用。很多时候,JVM需要根据调用者的动态类型,来确定调用的目标方法,这就是动态绑定的过程。

invokevirtual指令有多态查找的机制,该指令的运行时解析过程步骤如下:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做c。
  2. 如果在类型c中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,不通过则返回java.lang.IllegalAccessError。
  3. 否则,按照继承关系从下往上依次对c的各个父类进行第二步的搜索和验证过程。
  4. 始终没找到合适的方法,抛出java.lang.AbstractMethodError异常。这就是java语言中方法重写的本质。

相对比,invokestatic指令,加上invokespecial指令,就属于静态绑定过程。

所以静态绑定,指的是能够直接识别目标方法的情况,而动态绑定指的是需要在运行过程中根据调用者的类型来确定目标方法的情况。

可以想象,相对于静态绑定的方法调用来说,动态绑定的调用就更加耗时一些。由于方法的调用非常的频繁,JVM对动态调用的代码进行了比较多的优化。比如使用方法表来加快对具体方法的寻址,以及使用更快的缓冲区来直接寻址( 内联缓存)。

4、invokedynamic

有时候在写一些python脚本或者js脚本的时候,会特别羡慕这些动态语言。如果把查找目标方法的决定权,从虚拟机转嫁给用户代码,我们就会有更高的自由度。

我们单独把invokedynamic抽离出来介绍,是因为它比较复杂。和反射类似,它用于一些动态的调用场景,但它和反射有着本质的不同,效率也比反射要高的多。

这个指令通常在lambda语法中出现,我们来看一下一小段代码。

public class LambdaDemo {
    public static void main(String[] args) {
        Runnable r = () -> System.out.println("Hello Lambda");
        r.run();
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

使用javap -p -v 命令可以在main方法中看到invokedynamic指令。

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         0: invokedynamic #2,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
         5: astore_1
         6: aload_1
         7: invokeinterface #3,  1            // InterfaceMethod java/lang/Runnable.run:()V
        12: return
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

另外,我们在javap的输出中找到了一些奇怪的东西。

BootstrapMethods:
  0: #27 invokestatic java/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;
    Method arguments:
      #28 ()V
      #29 invokestatic LambdaDemo.lambda$main$0:()V
      #28 ()V
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

BootstrapMethods属性在Java1.7以后才有,位于类文件的属性列表中,这个属性用于保存 invokedynamic 指令引用的引导方法限定符。

和上面介绍的四个指令不同,invokedynamic并没有确切的接收对象,取而代之的,是一个叫做 CallSite 的对象。

static CallSite bootstrap(MethodHandles.Lookup caller, String name, MethodType type);
  • 1.

其实,invokedynamic指令的底层,是使用方法句柄(MethodHandle)来实现的。方法句柄是一个能够被执行的引用,它可以指向静态方法和实例方法,以及虚构的get和set方法,从IDE中可以看到这些函数。

句柄类型(MethodType)就是我们对方法的具体描述,配合方法名称,能够定位到一类函数。访问方法句柄和调用原来的指令是基本一致的,但它的调用异常,包括一些权限检查,是在运行时才能被发现的。

lambda语言实际上是通过方法句柄来完成的,在调用链上自然也多了一些调用步骤,那么在性能上,是否就意味着lambda性能低呢?对于大部分“非捕获”的lambda表达式来说,JIT编译器的逃逸分析能够优化这部分差异,性能和传统方式无异;但对于“捕获型”的表达式来说,就需要通过方法句柄,不断的生成适配器,性能自然就低了很多(不过和便捷性相比,一丁点性能损失是可接受的)。

除了lambda表达式,我们还没有其他的方式来产生invokedynamic指令。但是我们可以使用一些外部的字节码修改工具,比如ASM,来生成一些带有这个指令的字节码,这通常能够完成一些非常酷的功能,比如完成一门弱类型检查的JVM-Base语言。

END

本文从Java字节码的顶层结构介绍开始,通过一个实际代码,了解了类加载以后,在JVM内存里的表现形式,并了解了jhsdb对Java进程的观测方式。

我们了解到Java7之后的invokedynamic指令,它实际上是通过方法句柄来实现的。和我们关系最大的就是Lambda语法,了解了这些原理,可以忽略那些对Lambda性能高低的争论,要尽量写一些“非捕获”的Lambda表达式。

什么?你问什么叫非捕获?那就需要你自己搲了。

 作者简介:小姐姐味道  (xjjdog),一个不允许程序员走弯路的公众号。聚焦基础架构和Linux。十年架构,日百亿流量,与你探讨高并发世界,给你不一样的味道。