你的 JVM 基础“大厦”稳健吗?
【从 1 开始学 JVM 系列】
JVM 对于每位 Java 语言编程者来说无疑是“重中之重”,尽管我们每天都在与它打交道,却很少来审视它、了解它,慢慢地,它成为了我们“熟悉的陌生人”。
因此,我计划写一个「从 1 开始学 JVM 系列」 ,主要面向有一定 Java 基础的同学。同时,梳理总结一下自己过去积累的 JVM 体系知识和技能。
从 JVM 基础知识聊起
常见的编程语言是如何分类的?
众多周知,Java 是一门面向对象的编程语言。
对于编程语言,使用不同的标准有不同的分类,我们不妨一起来看看常见的分类。
第一种常见的分类为面向过程、面向对象、面向函数的编程语言。
- 面向过程,如 C
- 面向对象,如 Java、C++
- 面向函数,如 Scala
第二种可以将编程语言分为静态类型、动态类型。
- 静态类型,如 Java
- 动态类型,如 python、javascript
第三种可以将编程语言分为有虚拟机、无虚拟机。
- 有虚拟机,如 Java
- 无虚拟机,如 C、C++
第四种可以将编程语言分为有 GC、无 GC。
-
有 GC,如 Java、Go
-
无 GC,如 C、C++
对于没有 GC 的编程语言人工管理容易出现内存泄漏和野指针,例如 C++,这就要求编程者要足够细心。
通过对前面分类的小结,我们知道,Java 是一种面向对象、静态类型、有虚拟机、有 GC 的高级语言。
此外,Java 同时支持编译执行和解释执行、有运行时、能够跨平台(Write once, run anywhere,即“一次编写,到处执行”)。
- 即时编译执行,将一个方法中包含的所有字节码编译成机器码后再执行
- 解释执行,即逐条将字节码翻译成机器码并执行。
Java 代码解释执行,到达一定的次数后,如果被判定为是热点代码,则会被编译成机器码执行(一般执行效率会更高)。
编程语言如何跨平台?
一般而言,有两种跨平台的方式。
第一种方式是「源代码跨平台」。
这种方式通过在不同的平台上(例如分别在 Linux、Window)编译源码,生成不同的二进制文件,从而获得跨平台运行的能力。
但缺点也很明显,特定平台上编译出来的二进制无法跨平台运行。
如 Linux 编译出来的二进制文件无法在 Windows 上运行。
第二种方式是「二进制跨平台」。
例如 Java 语言,通过讲源代码编译成字节码,从而就能够实现跨平台运行。
为什么二进制能够跨平台?
一个非常重要的原因是虚拟机的诞生,使得在不同的平台上都能执行相同的字节码文件。
Java、C++、Rust 有哪些区别?
我们以几种常见的编程语言为例,对比一下不同类型的编程语言,看看它们之间的区别。
语言 | 对程序员态度 | 优势 | 劣势 |
---|---|---|---|
C/C++ | 完全相信、惯着程序员 | 自行管理内存,代码编写很自由 | 不小心会造成内存泄漏等问题,导致程序崩溃 |
Java/Golang | 完全不相信、但惯着程序员 | 内存生命周期都由 JVM 运行时统一管理。绝大部分场景,非常自由的写代码,不用关心内存情况;内存使用有问题时,可以通过 JVM 信息进行分析诊断和调整 | 存在 STW,无法灵活管理内存 |
Rust | 既不相信程序员,也不惯着程序员 | 写代码时,必须清楚用 Rust 的规则管理好变量,好让机器能明白高效地分析和管理内存 | 代码不利于人的理解,写代码很不自由,学习成本也很高 |
字节码、类加载器、虚拟机之间是什么关系?
我们通过对照一张图来说明它们之间的关系。
Java 源代码被编译成「字节码文件」(即 xxx.class 文件),然后通过「类加载器(ClassLoader)」将字节码文件加载到 JVM 内存中,然后再实例化为对象,最终被程序使用。
上面,我们简单聊了一下 JVM 的基础知识,为你学习 Java 虚拟机也算是热了个身,接下来我们正式的来聊聊 Java 的字节码技术。
什么是字节码?
Java bytecode 由「单字节(byte)」的指令组成,理论上最多支持 256 个「操作码(opcode)」。
实际上 Java 只使用了200左右的操作码, 还有一些操作码则保留给调试操作。
一般来说,根据指令的性质,主要分为四类:
-
栈操作指令,包括与局部变量交互的指令
JVM 是基于栈的,比如 Java 虚拟机栈、局部变量表的操作。
-
程序流程控制指令
例如 if、for、while
-
对象操作指令,包括方法调用指令
例如 invokestatic、invokespecial、invokevirtual、invokeinterface、invokedynamic
-
算术运算以及类型转换指令
如何生成字节码?
我们来写一个简单的类,练习一下生成字节码的操作。
/**
* @author: Alan Yin
* @date: 2021/9/2
*/
public class HelloByteCode {
public static void main(String[] args) {
HelloByteCode helloByteCode = new HelloByteCode();
}
}
编译命令:
javac HelloByteCode.java
查看字节码命令:
javap - c HelloByteCode
如下图所示:
查看更详细的字节码命令:
javap -verbose HelloByteCode
如下图所示:
分析一下字节码
我们先来分析一个简单一点的代码字节码。
上图中的 aload 是一个助记符,实际上对应一个操作码(如 76),因为助记符可读性更好,你想啊,如果全部换成数字,看这一段字节码不得查上半天?
其中 a 代表了引用。
另外值得一提的是 load 和 store 之间的关系,如下图。
load 指令会将字节码从本地变量表加载到操作数栈,store 则将字节码由操作数栈上存储到本地变量表中。
栈桢由本地变量表、操作数栈、动态链接、方法返回值组成,参考下图。
接下来,我们来分析一个复杂一点的字节码。
javap -c -verbose HelloByteCode
效果如下:
从上图可以看出,版本号为 52.0(java8),stack=2, locals=2
代表了需要深度为 2 的栈和本地变量表。
其他指令的含义可以查阅 Java 虚拟机规范,网上资料很多,这里不再赘述。
字节码的运行时结构是什么样的?
我们现在已经知道, JVM 是一台基于栈的计算器。
每一个线程都有一个独属自己的「线程栈(Stack)」,用于存储「栈桢(Frame)」,如下图所示。
每一次方法调用, JVM 会自动创建一个栈桢,位于顶部的即为当前栈桢。
从上图中可以看出,栈桢由局部变量表、操作数栈、动态链接(Class 引用)、返回地址(返回值)组成。
动态链接(Class 引用)指定当前方法在运行时常量池中对应的 Class。
具体一个栈桢的构成见下图。
助记符到二进制的对应关系
从前面我们知道,通过 javap 命令可以将二进制转换为助记符文件,它们之间的对应关系可以见下图。
演示:四则运算的例子
public class MovingAverage {
private int count = 0;
private double sum = 0.0D;
public void submit(double value) {
this.count++;
this.sum += value;
}
public double getAvg() {
if (0 == this.count) {
return sum;
}
return this.sum / this.count;
}
}
/**
* 栈桢的局部变量表字节码分析测试
*
* @author: Alan Yin
* @date: 2021/9/3
*/
public class LocalVariableTest {
public static void main(String[] args) {
MovingAverage ma = new MovingAverage();
int num1 = 1;
int num2 = 2;
ma.submit(num1);
ma.submit(num2);
double avg = ma.getAvg();
}
}
数值处理与本地变量表
我们结合下面的字节码,分析一下如何处理数值。
结合上图和代码,我们可以看出 iconst_1
对应了代码中的常量1,aload_1
是把本地变量表中的 int 变量值加载到栈上,istore_1
是把栈上的值保存到本地变量表中。
其中 a 代表了引用类型,i 代表了 int 类型,d 代表了 double 类型。
一个循环控制例子
/**
* 循环控制示例演示
*
* @author: Alan Yin
* @date: 2021/9/7
*/
public class ForLoopTest {
private static int[] numbers = {1, 6, 8};
public static void main(String[] args) {
MovingAverage ma = new MovingAverage();
for (int number : numbers) {
ma.submit(number);
}
double avg = ma.getAvg();
}
}
字节码如上,iinc 代表 int 类型的自增加一。
算数操作与类型转换
目前 JVM 有 5 种数据类型,即下面表格4种 + 引用类型(如 aload)
⚠️特别提示
- byte、boolean 在字节码都用int 表示,int 是 jvm 中的最小单位
- long 由 2 个 32 位组成,因此 long 操作不是原子性的(在 32 位机器上存在这种可能,比如赋值出错)
方法调用的指令
为了方便查看,我将常见的方法调用指令放在了下面的表格中。
指令 | 含义 | 备注 |
---|---|---|
invokestatic | 用于调用某个类的静态方法,这是方法调用指令中最快的一个。 | |
invokespecial | 用来调用构造函数、同一个类中的 private 方法, 以及可见的超类方法。 | |
invokevirtual | 如果是具体类型的目标对象,invokevirtual 用于调用公共、受保护和 package 级的私有方法 | |
invokeinterface | 当通过接口引用来调用方法时,将会编译为 invokeinterface 指令。 | |
invokedynamic | JDK7 新增指令,是实现“动态类型语言”(Dynamically Typed Language)支持而进行的升级改进,同时也是 JDK8 以后支持 lambda 表达式的实现基础。 |
【小知识】invokevirtual 指令为什么叫 virtual ?
因为子类可以覆盖父类的方法。
todo 查找资料,补充 5 种指令的说明和含义。
演示:动态的例子
/**
* 动态例子演示
*
* @author: Alan Yin
* @date: 2021/9/8
*/
public class Demo {
public static void foo() {
int a = 1;
int b = 2;
int c = (a + b) * 5;
}
}
从上图可以看到,构造函数调用为 invokespecial,对应 <init>
方法。
小尹说:
文章的每个字,都是我用心书写的。希望为每一位关注我的朋友带来价值。
如果你觉得有用,欢迎关注 「小尹探世界」 微信公众号,希望我们一起打造一个有知识、有温度、有趣点、有价值的频道,探索技术之外的广袤世界。