学习 JVM 的原因
- JVM – Java Virtual Machine,Java 虚拟机是一个虚拟的体系,它拥有目前最前沿的垃圾回收算法实现。虽然 JVM 也有一些局限性,但学习它之后,在遇到其他基于“虚拟机”的语言时,便能够融会贯通。
- 对 Java 高级工程师来说,JVM 是必须掌握的技能点。
- 在面试和求职过程中,经常会遇到 JVM 相关的知识。
想了解更多,欢迎关注我的微信公众号:Renda_Zhang
JVM 的功能
JVM 和操作系统
- 使用 C++ 开发的程序,编译成二进制文件后,就可以直接执行了,因为操作系统能够识别它。但是,使用
javac
编译 Java 程序成为.class
文件后,还需要 Java 虚拟机识别.class
后缀的文件,并且解析它的指令,然后才会被操作系统识别从而能调用操作系统上的函数。 - JVM 解释的是类似于汇编语言的字节码,需要一个抽象的运行时环境。同时,这个虚拟环境也需要解决字节码加载、自动垃圾回收、并发等一系列问题。
- “JVM” 像是 “操作系统”;“Java 字节码” 像是 “汇编语言”。
- Java 底层虽然比 C++ 要复杂,但因为有了 JVM 这个抽象层之后,起到了跨平台的作用,即 “一次编译,处处运行”。
- 在 Maven 仓库下载同一版本的 jar 包就可以到处运行,不需要在每个平台上再编译一次。
- JVM 的扩展语言,比如
Clojure
、JRuby
、Groovy
等,编译到最后都是.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。其中,
getstatic
、ldc
、invokevirtual
、return
等,就是 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;
-
会保证在子类的初始化方法执行之前,父类的初始化方法已经执行完毕。
所以,JVM 第一个被执行的类初始化方法一定是 java.lang.Object。另外,也意味着父类中定义的 static 语句块要优先于子类的。
-
-
<cinit>
与<init>
<cinit>
:类的初始化。static 字段和 static 代码块,是属于类的,在类的加载的初始化阶段就已经被执行。类信息会被存放在方法区,在同一个类加载器下,这些信息有一份就够了,所以 static 代码块只会执行一次。<init>
:对象的初始化。在对象初始化,即 new 一个新对象的时候,都会调用它的构造方法,用来初始化对象的属性;每一次新建对象的时候,都会执行。
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.jar
、resources.jar
、charsets.jar
等。 - 这些 jar 包的路径是可以指定的,
-Xbootclasspath
参数可以完成指定操作。
- Extention ClassLoader
- “扩展”类加载器,主要用于加载
lib/ext
目录下的 jar 包和.class
文件。同样的,通过系统变量java.ext.dirs
可以指定这个目录。 - 这个类加载器是个 Java 类,继承自
URLClassLoader
。
- “扩展”类加载器,主要用于加载
- Application ClassLoader
- “自定义 Java 类”的默认类加载器,有时候也叫作 System ClassLoader。
- 一般用来加载 classpath 下的其他所有 jar 包和
.class
文件,自定义的 Java 类会首先尝试使用这个类加载器进行加载。
- Custom ClassLoader
- “自定义”类加载器,支持一些个性化的扩展功能。
- Bootstrap ClassLoader
双亲委派机制
- 双亲委派机制的意思是除了顶层的启动类加载器以外,其余的类加载器,在加载之前,都会委派给它的父加载器进行加载。这样一层层向上传递,直到祖先们都无法胜任,它才会真正的加载。
- 查看 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
-
可以看到对象的初始化,首先是调用了 Object 类的初始化方法。注意这里是
<init>
而不是<cinit>
。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
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