你的 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 代码解释执行,到达一定的次数后,如果被判定为是热点代码,则会被编译成机器码执行(一般执行效率会更高)。

image

编程语言如何跨平台?

一般而言,有两种跨平台的方式。

第一种方式是「源代码跨平台」

这种方式通过在不同的平台上(例如分别在 Linux、Window)编译源码,生成不同的二进制文件,从而获得跨平台运行的能力。

但缺点也很明显,特定平台上编译出来的二进制无法跨平台运行

如 Linux 编译出来的二进制文件无法在 Windows 上运行。

image

源代码跨平台

第二种方式是「二进制跨平台」

例如 Java 语言,通过讲源代码编译成字节码,从而就能够实现跨平台运行。

image

二进制跨平台

为什么二进制能够跨平台?

一个非常重要的原因是虚拟机的诞生,使得在不同的平台上都能执行相同的字节码文件。

Java、C++、Rust 有哪些区别?

我们以几种常见的编程语言为例,对比一下不同类型的编程语言,看看它们之间的区别。

语言 对程序员态度 优势 劣势
C/C++ 完全相信、惯着程序员 自行管理内存,代码编写很自由 不小心会造成内存泄漏等问题,导致程序崩溃
Java/Golang 完全不相信、但惯着程序员 内存生命周期都由 JVM 运行时统一管理。绝大部分场景,非常自由的写代码,不用关心内存情况;内存使用有问题时,可以通过 JVM 信息进行分析诊断和调整 存在 STW,无法灵活管理内存
Rust 既不相信程序员,也不惯着程序员 写代码时,必须清楚用 Rust 的规则管理好变量,好让机器能明白高效地分析和管理内存 代码不利于人的理解,写代码很不自由,学习成本也很高

字节码、类加载器、虚拟机之间是什么关系?

我们通过对照一张图来说明它们之间的关系。

image

Java 源代码被编译成「字节码文件」(即 xxx.class 文件),然后通过「类加载器(ClassLoader)」将字节码文件加载到 JVM 内存中,然后再实例化为对象,最终被程序使用。

上面,我们简单聊了一下 JVM 的基础知识,为你学习 Java 虚拟机也算是热了个身,接下来我们正式的来聊聊 Java 的字节码技术。

什么是字节码?

Java bytecode 由「单字节(byte)」的指令组成,理论上最多支持 256 个「操作码(opcode)」

实际上 Java 只使用了200左右的操作码, 还有一些操作码则保留给调试操作。

一般来说,根据指令的性质,主要分为四类:

  1. 栈操作指令,包括与局部变量交互的指令

    JVM 是基于栈的,比如 Java 虚拟机栈、局部变量表的操作。

  2. 程序流程控制指令

    例如 if、for、while

  3. 对象操作指令,包括方法调用指令

    例如 invokestatic、invokespecial、invokevirtual、invokeinterface、invokedynamic

  4. 算术运算以及类型转换指令

如何生成字节码?

我们来写一个简单的类,练习一下生成字节码的操作。

/**
 * @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

如下图所示:

image

查看更详细的字节码命令:

javap -verbose HelloByteCode

如下图所示:

image

分析一下字节码

我们先来分析一个简单一点的代码字节码。

image

上图中的 aload 是一个助记符,实际上对应一个操作码(如 76),因为助记符可读性更好,你想啊,如果全部换成数字,看这一段字节码不得查上半天?

其中 a 代表了引用。

另外值得一提的是 load 和 store 之间的关系,如下图。

image

load 指令会将字节码从本地变量表加载到操作数栈,store 则将字节码由操作数栈上存储到本地变量表中。

栈桢由本地变量表、操作数栈、动态链接、方法返回值组成,参考下图。

image

接下来,我们来分析一个复杂一点的字节码。

javap -c -verbose HelloByteCode

效果如下:

image

从上图可以看出,版本号为 52.0(java8),stack=2, locals=2代表了需要深度为 2 的栈和本地变量表。

其他指令的含义可以查阅 Java 虚拟机规范,网上资料很多,这里不再赘述。

字节码的运行时结构是什么样的?

我们现在已经知道, JVM 是一台基于栈的计算器。

每一个线程都有一个独属自己的「线程栈(Stack)」,用于存储「栈桢(Frame)」,如下图所示。

image

每一次方法调用, JVM 会自动创建一个栈桢,位于顶部的即为当前栈桢。

从上图中可以看出,栈桢由局部变量表、操作数栈、动态链接(Class 引用)、返回地址(返回值)组成

动态链接(Class 引用)指定当前方法在运行时常量池中对应的 Class。

具体一个栈桢的构成见下图。

image

助记符到二进制的对应关系

从前面我们知道,通过 javap 命令可以将二进制转换为助记符文件,它们之间的对应关系可以见下图。

image

演示:四则运算的例子

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();
    }
}

数值处理与本地变量表

我们结合下面的字节码,分析一下如何处理数值。

image

结合上图和代码,我们可以看出 iconst_1 对应了代码中的常量1,aload_1 是把本地变量表中的 int 变量值加载到栈上,istore_1 是把栈上的值保存到本地变量表中。

其中 a 代表了引用类型,i 代表了 int 类型,d 代表了 double 类型。

image

一个循环控制例子

/**
 * 循环控制示例演示
 *
 * @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();
    }

}

image

字节码如上,iinc 代表 int 类型的自增加一。

算数操作与类型转换

目前 JVM 有 5 种数据类型,即下面表格4种 + 引用类型(如 aload)

image

⚠️特别提示

  • 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;
    }
}

image

image

从上图可以看到,构造函数调用为 invokespecial,对应 <init>方法。

END
------------

小尹说:
文章的每个字,都是我用心书写的。希望为每一位关注我的朋友带来价值。

如果你觉得有用,欢迎关注 「小尹探世界」 微信公众号,希望我们一起打造一个有知识、有温度、有趣点、有价值的频道,探索技术之外的广袤世界。
image

posted @ 2021-09-08 21:26  Alan-Yin  阅读(284)  评论(0编辑  收藏  举报