从栈帧看字节码是如何在-JVM-中进行流转的

41次阅读

共计 5083 个字符,预计需要花费 13 分钟才能阅读完成。

工具介绍

工欲善其事,必先利其器。先介绍两个剖析字节码的小工具。

javap

第一个小工具是 javap,javap 是 JDK 自带的反解析工具。它的作用是将 .class 字节码文件解析成可读的文件格式。
在应用 javap 时我个别会增加 -v 参数,尽量多打印一些信息。同时,我也会应用 -p 参数,打印一些公有的字段和办法。应用起来大略是这样:
javap -p -v HelloWorld
在 Stack Overflow 上有一个十分有意思的问题:我在某个类中减少一行正文之后,为什么两次生成的 .class 文件,它们的 MD5 是不一样的?

这是因为在 javac 中能够指定一些额定的内容输入到字节码。常常用的有

  • javac -g:lines 强制生成 LineNumberTable。
  • javac -g:vars  强制生成 LocalVariableTable。
  • javac -g 生成所有的 debug 信息。
    • jclasslib

      如果你不太习惯应用命令行的操作,还能够应用 jclasslib,jclasslib 是一个图形化的工具,可能更加直观的查看字节码中的内容。它还分门别类的对类中的各个局部进行了整顿,十分的人性化。同时,它还提供了 Idea 的插件,你能够从 plugins 中搜寻到它。

      如果你在其中看不到一些诸如 LocalVariableTable 的信息,记得在编译代码的时候加上咱们下面提到的这些参数。

      jclasslib 的下载地址:https:github.comingokegel…

      类加载和对象创立的机会

      接下来,咱们来看一个略微简单的例子,来具体看一下类加载和对象创立的过程。

      首先,咱们写一个最简略的 Java 程序 A.java。它有一个公共办法 test,还有一个动态成员变量和动静成员变量。
      class B {
          private int a = 1234;

          static long C = 1111;

          public long test(long num) {
              long ret = this.a + num + C;
              return ret;
          }
      }

      public class A {
          private B b = new B();

          public static void main(String[] args) {
              A a = new A();
              long num = 4321 ;

              long ret = a.b.test(num);

              System.out.println(ret);
          }
      }
      类的初始化产生在类加载阶段,那对象都有哪些创立形式呢?除了咱们罕用的 new,还有上面这些形式:

      • 应用 Class 的 newInstance 办法。
      • 应用 Constructor 类的 newInstance 办法。
      • 反序列化。
      • 应用 Object 的 clone 办法。
        • 其中,前面两种形式没有调用到构造函数。

          当虚拟机遇到一条 new 指令时,首先会查看这个指令的参数是否在常量池中定位一个符号援用。而后查看这个符号援用的类字节码是否加载、解析和初始化。如果没有,将执行对应的类加载过程。

          拿咱们下面的代码来说,执行 A 代码,在调用 private B b = new B() 时,就会触发 B 类的加载。

          A 和 B 会被加载到元空间的办法区,进入 main 办法后,将会交给执行引擎执行。这个执行过程是在栈上实现的,其中有几个重要的区域,包含虚拟机栈、程序计数器等。

          查看字节码

          命令行查看字节码

          应用上面的命令编译源代码 A.java。如果你用的是 Idea,能够间接将参数追加在 VM options 外面。
          javac -g:lines -g:vars A.java
          这将强制生成 LineNumberTable 和 LocalVariableTable。

          而后应用 javap 命令查看 A 和 B 的字节码。
          javap -p -v A
          javap -p -v B
          这个命令,不仅会输入行号、本地变量表信息、反编译汇编代码,还会输入以后类用到的常量池等信息。

          留神 javap 中的如下字样。

          <1>

          1: invokespecial #1    Method javalangObject.”<init>”:()V
          能够看到对象的初始化,首先是调用了 Object 类的初始化办法。留神这里是 <init> 而不是 <cinit>。

          <2>

          #2 = Fieldref           #6.#27          B.a:I
          它其实间接拼接了 #13 和 #14 的内容。
          #6 = Class             #29            B
          #27 = NameAndType       #8:#9          a:I

          #8 = Utf8               a
          #9 = Utf8               I

          <3>

          你会留神到 :I 这样非凡的字符。它们也是有意义的,如果你常常应用 jmap 这种命令,应该不会生疏。大体包含:

          • B 根本类型 byte
          • C 根本类型 char
          • D 根本类型 double
          • F 根本类型 float
          • I 根本类型 int
          • J 根本类型 long
          • S 根本类型 short
          • Z 根本类型 boolean
          • V 非凡类型 void
          • L 对象类型,以分号结尾,如 LjavalangObject;
          • [LjavalangString; 数组类型,每一位应用一个前置的 ”[“ 字符来形容
            • 咱们留神到 code 区域,有十分多的二进制指令。如果你接触过汇编语言,会发现它们之间其实有肯定的相似性。但这些二进制指令,并不是操作系统可能意识的,它们是提供给 JVM 运行的源资料。

              可视化查看字节码

              接下来,咱们就能够应用更加直观的工具 jclasslib,来查看字节码中的具体内容了。

              咱们以 B.class 文件为例,来查看它的内容。

              <1>

              首先,咱们可能看到 Constant Pool(常量池),这些内容,就寄存于咱们的 Metaspace 区域,属于非堆。

              常量池蕴含 .class 文件常量池、运行时常量池、String 常量池等局部,大多是一些动态内容。

              <2>

              接下来,能够看到两个默认的 <init> 和 <cinit> 办法。以下截图是 test 办法的 code 区域,比命令行版的更加直观。

              <3>

              持续往下看,咱们看到了 LocalVariableTable 的三个变量。其中,slot 0 指向的是 this 关键字。该属性的作用是形容帧栈中局部变量与源码中定义的变量之间的关系。如果没有这些信息,那么在 IDE 中援用这个办法时,将无奈获取到办法名,取而代之的则是 arg0 这样的变量名。

              本地变量表的 slot 是能够复用的。留神一个有意思的中央,index 的最大值为 3,证实了本地变量表同时最多可能寄存 4 个变量。

              另外,咱们察看到还有 LineNumberTable 等选项。该属性的作用是形容源码行号与字节码行号(字节码偏移量)之间的对应关系,有了这些信息,在 debug 时,就可能获取到产生异样的源代码行号。

              test 函数执行过程

              Code 区域介绍

              test 函数同时应用了成员变量 a、动态变量 C,以及输出参数 num。咱们此时说的函数执行,内存其实就是在虚拟机栈上调配的。上面这些内容,就是 test 办法的字节码。
              public long test(long);
                 descriptor: (J)J
                 flags: ACC_PUBLIC
                 Code:
                   stack=4, locals=5, args_size=2
                      0: aload_0
                      1: getfield      #2                   Field a:I
                      4: i2l
                      5: lload_1
                      6: ladd
                      7: getstatic     #3                   Field C:J
                     10: ladd
                     11: lstore_3
                     12: lload_3
                     13: lreturn
                   LineNumberTable:
                     line 13: 0
                     line 14: 12
                   LocalVariableTable:
                     Start  Length  Slot  Name   Signature
                         0      14     0  this   LB;
                         0      14     1   num   J
                        12       2     3   ret   J
              咱们介绍一下比拟重要的 3 三个数值。
              <1>
              首先,留神 stack 字样,它此时的数值为 4,表明了 test 办法的最大操作数栈深度为 4。JVM 运行时,会依据这个数值,来调配栈帧中操作栈的深度。
              <2>
              绝对应的,locals 变量存储了局部变量的存储空间。它的单位是 Slot(槽),能够被重用。其中寄存的内容,包含:

              • this
              • 办法参数
              • 异样处理器的参数
              • 办法体中定义的局部变量
                • <3>
                  args_size 就比拟好了解。它指的是办法的参数个数,因为每个办法都有一个暗藏参数 this,所以这里的数字是 2。

                  字节码执行过程

                  咱们略微回顾一下 JVM 运行时的相干内容。main 线程会领有两个次要的运行时区域:Java 虚拟机栈和程序计数器。其中,虚拟机栈中的每一项内容叫作栈帧,栈帧中蕴含四项内容:局部变量报表、操作数栈、动静链接和实现进口。
                  咱们的字节码指令,就是靠操作这些数据结构运行的。上面咱们看一下具体的字节码指令。

                  (1)0: aload_0
                  把第 1 个援用型局部变量推到操作数栈,这里的意思是把 this 装载到了操作数栈中。
                  对于 static 办法,aload_0 示意对办法的第一个参数的操作。

                  2)1: getfield      #2
                  将栈顶的指定的对象的第 2 个实例域(Field)的值,压入栈顶。#2 就是指的咱们的成员变量 a。

                  #2 = Fieldref           #6.#27          B.a:I
                  ...
                  #6 = Class             #29            B
                  #27 = NameAndType       #8:#9          a:I


                  (3)i2l
                  将栈顶 int 类型的数据转化为 long 类型,这里就波及咱们的隐式类型转换了。图中的信息没有变动,不再详解介绍。
                  (4)lload_1
                  将第一个局部变量入栈。也就是咱们的参数 num。这里的 l 示意 long,同样用于局部变量装载。你会看到这个地位的局部变量,一开始就曾经有值了。

                  (5)ladd
                  把栈顶两个 long 型数值出栈后相加,并将后果入栈。

                  (6)getstatic #3
                  依据偏移获取动态属性的值,并把这个值 push 到操作数栈上。

                  (7)ladd
                  再次执行 ladd。

                  (8)lstore_3
                  把栈顶 long 型数值存入第 4 个局部变量。
                  还记得咱们下面的图么?slot 为 4,索引为 3 的就是 ret 变量。

                  (9)lload_3
                  正好与下面相同。下面是变量存入,咱们当初要做的,就是把这个变量 ret,压入虚拟机栈中。

                  (10)lreturn
                  从以后办法返回 long。
                  到此为止,咱们的函数就实现了相加动作,执行胜利了。JVM 为咱们提供了十分丰盛的字节码指令。具体的字节码指令列表,能够参考以下网址:
                  https:docs.oracle.comjavas...

                  留神点
                  留神下面的第 8 步,咱们首先把变量寄存到了变量报表,而后又拿出这个值,把它入栈。为什么会有这种多此一举的操作?起因就在于咱们定义了 ret 变量。JVM 不晓得前面还会不会用到这个变量,所以只好傻瓜式的程序执行。
                  为了看到这些差别。大家能够把咱们的程序略微改变一下,间接返回这个值。
                  public long test(long num) {
                         return this.a + num + C;
                  }
                  再次看下,对应的字节码指令是不是简略了很多?
                  0: aload_0
                  1: getfield     #2                  Field a:I
                  4: i2l
                  5: lload_1
                  6: ladd
                  7: getstatic     #3                  Field C:J
                  10: ladd
                  11: lreturn
                  那咱们当前编写程序时,是不是要尽量少的定义成员变量?
                  这是没有必要的。栈的操作复杂度是 O(1),对咱们的程序性能简直没有影响。平时的代码编写,还是以可读性作为首要任务。

正文完
 0