Java 虚拟机基础原理:功能,内存管理,类的加载机制,分析字节码执行过程

时间:2022-07-24
本文章向大家介绍Java 虚拟机基础原理:功能,内存管理,类的加载机制,分析字节码执行过程,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

学习 JVM 的原因

  • JVM -- Java Virtual Machine,Java 虚拟机是一个虚拟的体系,它拥有目前最前沿的垃圾回收算法实现。虽然 JVM 也有一些局限性,但学习它之后,在遇到其他基于“虚拟机”的语言时,便能够融会贯通。
  • 对 Java 高级工程师来说,JVM 是必须掌握的技能点。
  • 在面试和求职过程中,经常会遇到 JVM 相关的知识。

JVM 的功能

JVM 和操作系统

  • 使用 C++ 开发的程序,编译成二进制文件后,就可以直接执行了,因为操作系统能够识别它。但是,使用 javac 编译 Java 程序成为 .class 文件后,还需要 Java 虚拟机识别 .class 后缀的文件,并且解析它的指令,然后才会被操作系统识别从而能调用操作系统上的函数。
  • JVM 解释的是类似于汇编语言的字节码,需要一个抽象的运行时环境。同时,这个虚拟环境也需要解决字节码加载、自动垃圾回收、并发等一系列问题。
  • “JVM” 像是 “操作系统”;“Java 字节码” 像是 “汇编语言”。
  • Java 底层虽然比 C++ 要复杂,但因为有了 JVM 这个抽象层之后,起到了跨平台的作用,即 “一次编译,处处运行”。
    • 在 Maven 仓库下载同一版本的 jar 包就可以到处运行,不需要在每个平台上再编译一次。
    • JVM 的扩展语言,比如 ClojureJRubyGroovy 等,编译到最后都是 .class 文件,Java 语言的维护者,只需要控制好 JVM 这个解析器,就可以将这些扩展语言无缝的运行在 JVM 之上了。
  • 对比 C++ 和 Java 的生产加载过程

JVM 和 JRE、JDK

  • JRE -- Java Runtime Environment,Java 的运行时环境 = JVM 标准 + 基础类库
  • JDK -- Java Development Kit,Java 开发工具包 = JRE + 开发工具

JVM 规范和 Java 语言规范

  • The Java Virtual Machine Specification -- JVM 规范:定义了 .class 文件的结构、加载机制、数据存储、运行时栈等诸多内容,最常用的 JVM 规范实现就是 Hotspot VM
  • The Java Language Specification -- Java 语言规范:定义 Java 语法规范,比如 switch、for、泛型、lambda 等。
  • 两个规范通过 Java 的字节码连接在一起,因为 Java 程序最终都会编译成字节码。

Java 程序的执行过程

  • Java 程序是文本格式的。比如下面的 HelloWorld.java,它遵循的就是 Java 语言规范。其中,它调用了 System.out 等模块,也就是 JRE 里提供的类库。
public class HelloWorld {

    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}
  • 使用 JDK 的工具 javac HelloWorld.java 进行编译后,会产生 HelloWorld 的字节码。
  • 可以使用 javap -v -p HelloWorld 来查看字节码文件内容。下面是System.out.println("Hello World"); 的字节码内容:
...

0 getstatic #2 <java/lang/System.out>
3 ldc #3 <Hello World>
5 invokevirtual #4 <java/io/PrintStream.println>
8 return
...
  • Java 虚拟机采用基于栈的架构,其指令由操作码和操作数组成。这些字节码指令,就叫作 opcode。其中,getstaticldcinvokevirtualreturn 等,就是 opcode。
  • 可以继续使用 Linux 的 hexdump 看一下字节码的二进制内容,与以上字节码对应的二进制,就是下面这几个数字(十六进制展示): b2 00 02 12 03 b6 00 04 b1 对应关系:
0xb2   getstatic       // 获取静态字段的值

0x12   ldc             // 常量池中的常量值入栈
0xb6   invokevirtual   // 运行时方法绑定调用方法
0xb1   return          // void 函数返回
  • opcode 有一个字节的长度 (0~255),意味着指令集的操作码个数不能超过 256 条。而紧跟在 opcode 后面的是被操作数。比如 b2 00 02,就代表了 getstatic #2 <java/lang/System.out>
  • JVM 就是靠解析这些 opcode 和操作数来完成程序的执行的。当我们使用 Java 命令运行 .class 文件的时候,实际上就相当于启动了一个 JVM 进程。这些 .class 文件会被加载、存放到 元数据(metaspace) 中,执行引擎将会通过 混合模式 执行这些字节码。然后 JVM 会翻译这些字节码为操作系统相关的函数,它有两种执行方式:
    • 常见的就是解释执行,将 opcode + 操作数 翻译成机器代码;
    • 另外一种执行方式就是 JIT(Just In Time),也就是“即时编译”,它会在一定条件下将字节码编译成机器码之后再执行。
  • JVM 作为 .class 文件的黑盒存在,输入字节码,调用操作系统函数。
  • JVM 的生命周期是和 Java 程序的运行是一样的。当程序运行结束,JVM 实例也跟着消失了。
  • Java 程序执行过程如下:Java 文件 -- 编译器 -- 字节码 -- JVM -- 机器码。

JVM 内存管理

JVM 内存布局

  • C++ 需要手动管理内存,使用了指针的概念;而 Java 是自动内存管理机制,使用了引用的概念。Java 为了管理内存的申请和释放操作,就必须引入一个池子来延迟这些内存区域的回收操作。这个池子,叫作堆。
  • 随着 Java 的发展,内存布局一直在调整之中。比如,Java 8 及之后的版本,彻底移除了持久代,而使用 Metaspace(元数据) 来进行替代。这也表示着 -XX:PermSize-XX:MaxPermSize 等参数调优,已经没有了意义。但大体上,比较重要的内存区域是固定的。
  • JVM 堆中的数据是共享的,是占用内存最大的一块区域。可以执行字节码的模块叫作执行引擎。执行引擎在线程切换时,依靠程序计数器来恢复。JVM 的内存划分与多线程是息息相关的。像程序中运行时用到的栈(Java 虚拟机栈),以及本地方法栈,它们的维度都是线程。本地内存包含元数据区和一些直接内存。
  • JVM 中存在多个常量池。
    • JDK 7.0 开始,字符串常量池被移到了堆中。
    • 类文件常量池,constant_pool,是每个类每个接口所拥有的,(如字节码中的 getstatic #2 <java/lang/System.out> )。这部分数据在方法区,也就是元数据区。
    • 运行时常量池是在类加载后的一个内存区域,也在元空间。
  • JVM 的运行时区域是栈,而存储区域是堆。很多变量在编译期就已经固定了。

虚拟机栈和本地方法栈

  • 在线程的生命周期中,参与计算的数据会频繁地入栈和出栈,栈的生命周期是和线程一样的。
  • 栈里的每条数据,就是栈帧。在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦完成相应的调用,则出栈。所有的栈帧都出栈后,线程也就结束了。
  • 每个栈帧,都包含四个区域:
    • 局部变量表
    • 操作数栈
    • 动态连接
    • 返回地址
  • 本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。甚至可以认为虚拟机栈和本地方法栈是同一个区域。
  • 虚拟机栈是一个两层的栈。第一层是栈帧,对应着方法;第二层是方法的执行,对应着操作数。
  • 对于 JVM 来说,程序就是存储在方法区的字节码指令,而 returnAddress 类型的值就是指向特定指令内存地址的指针。
  • 所有的字节码指令,都会抽象成对栈的入栈出栈操作。执行引擎只需要按顺序执行,就可以保证它的正确性。

程序计数器

  • 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。它存的就是当前线程执行的进度。
  • 程序计数器也是因为线程而产生的,与虚拟机栈配合完成计算操作。程序计数器还存储了当前正在运行的流程,包括正在执行的指令、跳转、分支、循环、异常处理等。
  • 使用 javap 命令输出字节码内容来查看程序计数器里面的具体内容。可以看到在每个 opcode 前面,都有一个序号,可以认为这些序号是程序计数器的内容:

  • 堆是 JVM 最大的内存区域,申请的几乎所有的对象都是存储在这里。
  • 堆空间一般是程序启动时就申请了,但是并不一定会全部使用。随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。由于对象的大小不一,在长时间运行后,堆空间会被许多细小的碎片占满,造成空间浪费,所以还需要堆空间整理。
  • Java 的对象可以分为基本数据类型和普通对象。
    • 对于普通对象(类,接口,数组)来说,JVM 会首先在堆上创建对象,然后在其他地方使用的是它的引用。这个引用保存在虚拟机栈的局部变量表中。
    • 对于基本数据类型来说(byte、short、int、long、float、double、char),在方法体内声明了基本数据类型的对象,就会在栈上直接分配。
  • 堆是所有线程共享的,如果是多个线程访问,会涉及数据同步问题。

元空间

  • 对象是一个实例,可以参与到程序的运行中;类像是一个模版,定义了一系列属性和操作。在 Java 8 之前,这些类的信息是放在一个叫 Perm 区的内存里面的。更早版本,甚至 String.intern 相关的运行时常量池也放在这里。这个区域有大小限制,很容易造成 JVM 内存溢出,从而造成 JVM 崩溃。Perm 区在 Java 8 中已经被彻底废除,取而代之的是 Metaspace。原来的 Perm 区是在堆上的,现在的元空间是在非堆上的。
  • 对比 Perm 和元空间:
  • 元空间在非堆可以使用操作系统的内存,JVM 不会再出现方法区的内存溢出;但是,无限制的使用会造成操作系统的死亡。所以,一般也会使用参数 -XX:MaxMetaspaceSize 来控制大小。
  • 方法区,是一个概念,它的物理存储的容器,就是元空间。方法区存储的内容,包括:类的信息、常量池、方法数据、方法代码。

堆、非堆、本地内存的关系

  • 堆松是散有弹性,而非堆是紧凑生硬。
  • 非堆又叫堆外内存,分为元空间、直接内存、栈空间、以及其它 JVM 申请的内存。
  • 本地内存指的是除去 ”堆和堆外内存“ 剩下的操作系统内存。本地内存可以通过 DirectBuffer、jni 或者 jna、网络 socket 连接等等,变成堆外内存。
  • 直接内存:一个可以不经过 JVM 内存直接访问系统物理内存的类 —— DirectBuffer。
    • DirectBuffer 类继承自 ByteBuffer,但和普通的 ByteBuffer 不同,普通的 ByteBuffer 仍在 JVM 堆上分配内存,其最大内存受到最大堆内存的限制;而 DirectBuffer 直接分配在物理内存中,并不占用堆空间,其可申请的最大内存受操作系统限制。
    • 直接内存可以通过 JVM 参数 -XX:MaxDirectMemorySize 来控制。
    • 直接内存的读写操作比普通 Buffer 快,但它的创建、销毁比普通 Buffer 慢。
    • 直接内存适用于需要大内存空间且频繁访问的场合,不适用于频繁申请释放内存的场合。
  • 因为非 JVM 进程内存也会有部分受到 JVM 的控制,所以实际的内存占用是大于给 JVM 分配的内存。比如,一台系统内存为 2GB 的主机,可能 JVM 能用的就只有 1GB。

类的加载机制

类加载过程

一个 .class 文件,需要经历 ”加载、验证、准备、解析、初始化“ 的过程,然后才会被 JVM 的执行引擎执行。

加载
  • 加载的主要作用是将外部的 .class 文件,加载到 Java 的方法区内。
  • 加载阶段主要是找到并加载类的二进制数据,比如从 jar 包里或者 war 包里找到它们。
验证
  • 验证阶段起到安全作用,防止受到恶意代码的攻击。
  • 验证阶段在虚拟机整个类加载过程中占了很大一部分,不符合规范的将抛出 java.lang.VerifyError 错误。比如,一些低版本的 JVM,是无法加载一些高版本的类库的。
准备
  • 准备阶段为一些类变量分配内存,并将其初始化为默认值。此时,实例对象还没有分配内存,所以这些动作是在方法区上进行的。
  • 局部变量不像类变量那样存在准备阶段。类变量有两次赋初始值的过程,一次在准备阶段,赋予初始值(也可以是指定值);另外一次在初始化阶段,赋予程序员定义的值。
// 如果没有为类变量赋值,它会有一个默认的初始值。
public class A {
    static int a ;
    public static void main(String[] args) {
        // 输出 0
        System.out.println(a);
    }
}
// 如果没有给局部变量赋初始值,是不能使用的。
public class B {
    public static void main(String[] args) {
        int b ;
        // 编译报错
        System.out.println(b);
    }
}
解析
  • 解析在类加载中是非常重要的一环,它是将符号引用替换为直接引用的过程。
  • 解析阶段的工作大体分为:
    • 类或接口的解析
    • 类方法解析
    • 接口方法解析
    • 字段解析
  • 解析阶段经常发生的异常:
    • java.lang.NoSuchFieldError 根据继承关系从下往上,找不到相关字段时的报错。
    • java.lang.IllegalAccessError 字段或者方法,访问权限不具备时的错误。
    • java.lang.NoSuchMethodError 找不到相关方法时的错误。
  • 解析过程保证了相互引用的完整性,把继承与组合推进到运行时。
初始化
  • 初始化阶段初始化成员变量。到了这一步,才真正开始执行一些字节码。
  • 初始化规则:
    • static 语句块,只能访问到定义在 static 语句块之前的变量。
public class A {

    static int a = 0 ;
    static {
        a = 1;
        b = 1;
    }
    static int b = 0;
    public static void main(String[] args) {
        // 输出 1
        System.out.println(a);
        // 输出 0
        System.out.println(b);
    }
}
static {

    b = b + 1;
}
static int b = 0;
  • <cinit><init>
public class A {

    static {
        System.out.println("1");
    }
    public A(){
        System.out.println("2");
    }
}
public class B extends A {
    static{
        System.out.println("a");
    }
    public B(){
        System.out.println("b");
    }
    public static void main(String[] args){
        // static 代码块只会执行一次,
        // 对象的构造方法执行两次。
        A ab = new B();
        ab = new B();
    }
}
/**
输出:
    1
    a
    2
    b
    2
    b
*/

类加载器

  • 类加载器实现了 ”Loading(加载),Linking(链接),Initializing(初始化)“ 的过程。
  • 类加载器保证了这个过程的安全性,比如 JRE 的类不能轻易被覆盖,防止被利用。
  • 几个不同等级的类加载器:
    • Bootstrap ClassLoader
      • 这是最核心的类加载器,任何类的加载行为,都要经它过问。
      • 它的作用是加载核心类库,也就是 rt.jarresources.jarcharsets.jar 等。
      • 这些 jar 包的路径是可以指定的,-Xbootclasspath 参数可以完成指定操作。
    • Extention ClassLoader
      • “扩展”类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。同样的,通过系统变量 java.ext.dirs 可以指定这个目录。
      • 这个类加载器是个 Java 类,继承自 URLClassLoader
    • Application ClassLoader
      • “自定义”类加载器,支持一些个性化的扩展功能。
      • “自定义 Java 类”的默认类加载器,有时候也叫作 System ClassLoader。
    • Custom ClassLoader
      • 一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,自定义的 Java 类会首先尝试使用这个类加载器进行加载。

双亲委派机制

  • 双亲委派机制的意思是除了顶层的启动类加载器以外,其余的类加载器,在加载之前,都会委派给它的父加载器进行加载。这样一层层向上传递,直到祖先们都无法胜任,它才会真正的加载。
  • 查看 JDK 代码的 ClassLoader#loadClass 方法,可以知道,首先使用 parent 尝试进行类加载,parent 失败后才轮到自己。同时,应当注意到,这个方法是可以被覆盖的,也就是双亲委派机制并不一定生效:
  • 双亲委派模型的好处在于 Java 类有了一种优先级的层次划分关系。比如 Object 类,应该交给最上层的类加载器进行加载,即使覆盖了类加载器,最终也是由系统默认的类加载器进行加载的。

一些自定义类加载器

如果出现一些业务需求比如 “加载一个远程的 .class 文件” 或 “加密 .class 文件”,那么这时候就需要自定义一个新的类加载器。

所以,为了支持一些自定义加载类多功能的需求,Java 设计者作出了一些妥协,即可以打破双亲委派机制。

案例一:Tomcat
  • Tomcat 通过 war 包进行应用的发布,它其实是违反了双亲委派机制原则的。
  • 简单看一下 Tomcat 类加载器的层次结构:
  • 对于一些需要加载的非基础类,会由一个叫作 WebAppClassLoader 的类加载器优先加载。等它加载不到的时候,再交给上层的 ClassLoader 进行加载。这个加载器用来隔绝不同应用的 .class 文件,而两个应用可能会依赖同一个第三方的不同版本,它们是相互没有影响的。
  • Tomcat 通过自定义类加载器,实现同一个 JVM 里运行着不兼容的两个版本。
  • Tomcat 自定义的 WebAppClassLoader 加载自己目录下的 .class 文件,并不会传递给父类的加载器。但是,它却可以使用 SharedClassLoader 所加载的类,实现了共享和分离的功能。
案例二:SPI
  • SPI 机制,全称是 Service Provider Interface,是 Java 提供的一套用来被第三方实现或者扩展的 API,它可以用来启用框架扩展和替换组件。
  • 在使用 JDBC 写程序之前,通常会调用 Class.forName("com.mysql.jdbc.Driver"),用于加载所需要的驱动类。MySQL 通过在 META-INF/services 目录下,创建一个以接口全限定名为命名的文件(内容为实现类的全限定名),即可自动加载这一种实现,这就是 SPI。所以,即使删除了 Class.forName 这一行代码,也能加载到正确的驱动类。
  • DriverManager 类和 ServiceLoader 类都是属于核心类库 rt.jar 的。它们的类加载器是 Bootstrap ClassLoader,也就是最上层的那个。而具体的数据库驱动,却属于业务代码,这个启动类加载器是无法加载的。在数据库驱动加载的源码中,应用启动的时候,把当前的类加载器设置成了线程的上下文类加载器;而对于一个刚刚启动的应用程序来说,当前的加载器就是启动 main 方法的 Application ClassLoader。使用它来加载第三方驱动,是没有什么问题的。
  • SPI 实际上是“基于接口的编程+策略模式+配置文件”组合实现的动态加载机制,主要使用 java.util.ServiceLoader 类进行动态装载。这种方式,同样打破了双亲委派的机制。
案例三:OSGi
  • OSGi 是服务平台的规范,旨在用于需要长运行时间、动态更新和对运行环境破坏最小的系统。OSGi 规范定义了很多关于包生命周期,以及基础架构和绑定包的交互方式。这些规则,通过使用特殊 Java 类加载器来强制执行。
  • 在一般 Java 应用程序中,classpath 中的所有类都对所有其他类可见。但是,OSGi 类加载器基于 OSGi 规范和每个绑定包的 manifest.mf 文件中指定的选项,来限制这些类的交互。这种与直觉相违背的加载方式,只能由专用的类加载器来实现的。

如何替换 JDK 的类

  • HashMap 为例。当 Java 的原生 API 不能满足需求时,比如要修改 HashMap 类,就必须要使用到 Java 的 endorsed 技术。这时候就需要将自己写的 HashMap 类,打包成一个 jar 包,然后放到 -Djava.endorsed.dirs 指定的目录中。注意类名和包名,应该和 JDK 自带的是一样的。但是,java.lang 包下面的类除外,因为这些都是特殊保护的。
  • Java 提供了 endorsed 技术,用于替换 JDK 的原生类。-Djava.endorsed.dirs 指定的目录下的 jar 包,会比核心类库 rt.jar 中的文件,优先级更高,可以被最先加载到。

分析字节码执行过程

工具介绍

分析字节码的小工具

javap
  • javap 是 JDK 自带的反解析工具。它的作用是将 .class 字节码文件解析成可读的文件格式。在使用 javap 时一般会添加 -v 参数,尽量多打印一些信息。同时也会使用 -p 参数,打印一些私有的字段和方法。 javap -p -v HelloWorld
  • 在 javac 中可以指定一些额外的内容输出到字节码。经常用的有:
    • javac -g:lines 强制生成 LineNumberTable
    • javac -g:vars 强制生成 LocalVariableTable
    • javac -g 生成所有的 debug 信息。
jclasslib
  • jclasslib 是一个图形化的工具,能够更加直观的查看字节码中的内容。它还分门别类的对类中的各个部分进行了整理,非常的人性化。
  • 同时,它还提供了 Idea 的插件,可以从 plugins 中搜索到它。

类加载和对象创建的过程

  • 对象创建方式除了常用的 new,还有下面四种方式(后面两种方式没有调用到构造函数):
    • 使用 Class 的 newInstance 方法。
    • 使用 Constructor 类的 newInstance 方法。
    • 反序列化。
    • 使用 Object 的 clone 方法。
  • 一个简单的 Java 程序,它有一个公共方法 test,还有一个静态成员变量和动态成员变量:
// A.java

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 A 代码,在调用 private B b = new B() 时,就会触发 B 类的加载。A 和 B 会被加载到元空间的方法区,进入 main 方法后,将会交给执行引擎执行。这个执行过程是在栈上完成的,其中有几个重要的区域,包括虚拟机栈、程序计数器等。

查看字节码

命令行查看字节码
  • 使用下面的命令编译源代码 A.java。这将强制生成 LineNumberTable 和 LocalVariableTable。如果用的是 Idea,可以直接将参数追加在 VM options 里面。 javac -g:lines -g:vars A.java
  • 然后使用 javap 命令查看 A 和 B 的字节码。这个命令,不仅会输出行号、本地变量表信息、反编译汇编代码,还会输出当前类用到的常量池等信息。由于内容很长,这里就不全部展示了,可以使用下面的命令实际操作一下就可以了:
javap -p -v A

javap -p -v B
1: invokespecial #1   // Method java/lang/Object."<init>":()V
#2 = Fieldref           #6.#27         // B.a:I

...
#6 = Class             #29           // B
#27 = NameAndType       #8:#9         // a:I
...
#8 = Utf8               a
#9 = Utf8               I
  • 注意到 :I 这样特殊的字符。它们也是有意义的,如果经常使用 jmap 这种命令,应该不会陌生。大体包括:
    • B 基本类型 byte
    • C 基本类型 char
    • D 基本类型 double
    • F 基本类型 float
    • I 基本类型 int
    • J 基本类型 long
    • S 基本类型 short
    • Z 基本类型 boolean
    • V 特殊类型 void
    • L 对象类型,以分号结尾,如 Ljava/lang/Object;
    • [Ljava/lang/String; 数组类型,每一位使用一个前置的 [ 字符来描述
  • 注意到 code 区域,有非常多的二进制指令。如果你接触过汇编语言,会发现它们之间其实有一定的相似性。但这些二进制指令,并不是操作系统能够认识的,它们是提供给 JVM 运行的源材料。
可视化查看字节码
  • 可以使用更加直观的工具 jclasslib 来查看字节码中的具体内容。
  • 平常工作中,掌握第一个就够了,后者主要提供更加直观的展示。所以这里就不演示了。

函数执行过程

Code 区域介绍
  • 上面的代码 Class B 的 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
  • 首先,注意 stack 字样,它此时的数值为 4,表明了 test 方法的最大操作数栈深度为 4。JVM 运行时,会根据这个数值,来分配栈帧中操作栈的深度。
  • 相对应的,locals 变量存储了局部变量的存储空间。它的单位是 Slot(槽),可以被重用。其中存放的内容,包括:
    • this
    • 方法参数
    • 异常处理器的参数
    • 方法体中定义的局部变量
  • args_size 指的是方法的参数个数,因为每个方法都有一个隐藏参数 this,所以这里的数字是 2。

字节码执行过程

main 线程会拥有两个主要的运行时区域:Java 虚拟机栈和程序计数器。其中,虚拟机栈中的每一项内容叫作栈帧,栈帧中包含四项内容:局部变量报表、操作数栈、动态链接和完成出口。字节码指令,就是靠操作这些数据结构运行的。下面我们看一下具体的字节码指令。

0: aload_0
  • 把第 1 个引用型局部变量推到操作数栈,这里的意思是把 this 装载到了操作数栈中。
  • 对于 static 方法,aload_0 表示对方法的第一个参数的操作。
1: getfield #2
  • 将栈顶的指定的对象的第 2 个实例域(Field)的值,压入栈顶。#2 就是指成员变量 a。 #2 = Fieldref #6.#27 // B.a:I...#6 = Class #29 // B#27 = NameAndType #8:#9 // a:I
4: i2l
  • 将栈顶 int 类型的数据转化为 long 类型,这里就涉及隐式类型转换了。
5: lload_1
  • 将第一个局部变量入栈。也就是参数 num。这里的 l 表示 long,同样用于局部变量装载。可以看到这个位置的局部变量,一开始就已经有值了。
6: ladd
  • 把栈顶两个 long 型数值出栈后相加,并将结果入栈。
7: getstatic #3
  • 根据偏移获取静态属性的值,并把这个值 push 到操作数栈上。
10: ladd
  • 再次执行 ladd。
11: lstore_3
  • 把栈顶 long 型数值存入第 4 个局部变量。
12: lload_3
  • 与上面相反。上面是变量存入,这里是把这个变量 ret,压入虚拟机栈中。

13: lreturn

  • 从当前方法返回 long。
  • 到此为止,函数就完成了相加动作,执行成功了。

扩展

  • 注意上面的 11: lstore_3,它首先把变量存放到了变量报表,然后又拿出这个值,把它入栈。会有这种多此一举的操作的原因就是:函数定义了 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),对程序性能几乎没有影响。平常的代码编写,还是以可读性作为首要任务。
  • JVM 为我们提供了非常丰富的字节码指令。详细的字节码指令列表,可以参考以下网址:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html