vlambda博客
学习文章列表

面试最怕问到的 Java 虚拟机,是通往高级 Java 开发的必经之路

在 Java 的面试中,很多人最怕的就是被问到有关 Java 虚拟机的问题。

比如:

  • Class 文件的组成结构,Java 的单继承在 Class 文件结构里是如何体现的?
  • 反射机制是如何实现的,通过类名创建类的实例在虚拟机里是如何操作的?
  • 反编译的字节码指令在虚拟机里是如何实现的,ldc 与 ldc_w 有何区别?
  • 如何区分特性是语法糖还是虚拟机的实现,如何识别 try-with-resources, foreach 循环等语法糖?
  • 方法调用的实现,静态方法调用,私有方法调用,接口方法调用,重载方法调用在实现上的异同点?
  • 栈帧的操作数栈和本地变量表是什么,指令又是如何操作栈帧的数据的?

对于 Java 程序员来讲,Spring、Maven 都是外在的武功招式,而 JVM 就是内在的内功心法。只有熟悉 Java 虚拟机,了解最底层的原理,才能在遇到各种问题时轻松应对,不至于束手无策,或者一脸懵逼地上网找解决办法。

当线上出现性能问题时,JVM 调优更是不可回避的问题,因此对 JVM 的理解程度也是 Java 程序员水平高低的分水岭之一。所以很多公司在面试高级开发时,JVM 都是必考点之一。

了解 Java 虚拟机最好的方式,莫过于亲手开发一个!今天小编就带你用实战的方式,亲手从零编写 Java 虚拟机!

你将从 while 循环开始,构建基本的解释器,逐步的添加虚拟机功能,通过这种循序渐进的实践方式,领悟到 Java 虚拟机的底层原理。


实验介绍

在本节实验中,将会学习到 javacjavap 的基本使用,了解到字节码,解释器的基本概念,以及实现一个基础的解释器。


知识点

  • 解释器是什么

  • javap 命令

  • 机器语言与汇编语言

  • 字节码对应的“汇编语言”

  • 解释器的核心实现

解释器,是一种程序,能够把编程语言一行一行解释运行。解释器像是一位“中间人”,每次运行程序时都要先转成另一种语言再作运行,因此解释器的程序运行速度比较缓慢。它不会一次把整个程序翻译出来,而是每翻译一行程序就立刻运行,然后再翻译下一行,再运行,如此不停地进行下去。

从现实来看, CPU 就是个解释器,CPU 通过解释机器码,操作寄存器和内存,进而控制其他的硬件。

我们将要实现的解释器,解释器需要相对应的数据容器,需要什么样的容器是由指令来确定的,JVM 的字节码指令集根据操作数据的不同分为以下几类,具体的 操作数栈,本地变量表,堆,元空间,程序计数器 等概念,后续会进一步补充。

  • 操作操作数栈,比如上方的 iconst_1 指令,后面会出现的 iadd 指令等。

  • 操作本地变量表,如上方的 istore_0,iload_0 指令等。

  • 操作堆,如 new,putfield 等指令。

  • 操作元空间, 如 getstatic 指令。

  • 程序计数器,每个指令都会操作,用来在当前指令结束后,让计数器指向下一个待执行的指令位置。

同 CPU 需要内存的配合一样, 解释器需要一个存取数据的容器,在 JVM 里,这个容器称为栈帧,栈帧内部有三个重要元素,程序计数器,本地变量表,操作数栈,这部分内容可参阅 Frames。

解释器的核心代码如下。

do {
//获取下一个指令
//解释指令
} while //(还有指令);

解释器的核心架构如下图。

下面介绍一下栈帧的三个重要元素。

  1. 程序计数器:之前介绍指令时提到的指令位置,就是由程序计数器来维护的,标志当前执行到哪一条字节码。在实现上对应为一个 int 类型的变量。

  2. 本地变量表:本地变量表可以理解是个 Map, key 为索引位置,value 为需要存取的值。在实现上可简化为数组,因为字节码里是以类似数组下标的方式进行存取的。

  3. 操作数栈:如名,存放数据的栈,只有 pushpop 两种操作。

实现解释器原型

这一节会实现一个简单的解释器,用来解释之前生成的 test.bc 文件内部的 4 个指令。

实现数据结构,栈帧。

// 栈帧
class Frame {

// 程序计数器,默认值为 0
public int pc;

// 本地变量表
public final Map<Integer, Integer> localVars = new HashMap<>();
// 操作数栈
public final Stack<Integer> operandStack = new Stack<>();
}

如上面的代码,栈帧类为 Frame, 内部包含三个实例变量,pclocalVarsoperandStack。对应为 程序计数器,本地变量表,操作数栈。

实现数据结构,指令。

// 指令接口
interface Instruction {
// offset, 字长, 因为字节码的长度不一致,一般情况下是 1,此处提供默认方法用来获取指定的字长。用来在指令结束时改变栈帧的程序计数器,使之指向下一条指令。
default int offset() {
return 1;
}

// 具体指令需要实现的方法,是指定自身的实现逻辑。
void eval(Frame frame);
}

如上,定义指令接口,以及指令的默认行为。

实现 test.bc 涉及到的 4 个指令。

// iconst_1
class IConst1Inst implements Instruction {

@Override
public void eval(Frame frame) {
frame.operandStack.push(1); //
frame.pc += offset();
}
}

// istore_0
class IStore0Inst implements Instruction {

@Override
public void eval(Frame frame) {
frame.localVars.put(0, frame.operandStack.pop());
frame.pc += offset();
}
}

// iload_0
class ILoad0Inst implements Instruction {

@Override
public void eval(Frame frame) {
frame.operandStack.push(frame.localVars.get(0));
frame.pc += offset();
}
}

// ireturn
// 返回指令涉及到栈帧的一些特殊操作,暂时简单实现,输出操作数栈顶的数值
class IReturnInst implements Instruction {

@Override
public void eval(Frame frame) {
int tmp = frame.operandStack.pop();
System.out.println(tmp);

frame.pc += offset();
}
}

如上,4 个指令的作用之前已经介绍过,此处结合代码和注释,加深理解。

实现解释器。

// 解释器
class Interpreter {

/**
  * 解释器运行
  *
  * @param frame 栈帧
  * @param instructions 指令集合
  */
public static void run(Frame frame, Map<Integer, Instruction> instructions) {
// 核心循环
do {
// 获取指令
Instruction instruction = instructions.get(frame.pc);
// 执行指令
instruction.eval(frame);
} while (instructions.containsKey(frame.pc));
}
}

以上内容来自实验楼新课《从理论到实战,手把手教你构建 JVM》,课程后续还有:

本课程的目标是以 Java 代码实战的方式从零编写 Java 虚拟机,不但讲述了怎么实现,同样讲述了作者的实现思路,通过观察了什么结果,推理了什么结论,基于结论如何实现的全过程。
在编写代码的过程中,力求代码简洁,可读,可测,最终实现的代码大约 6000 多行,模块划分清晰,对于想了解 Java 虚拟机的基本原理的同学是个不错的入门项目。
课程限时 8 折优惠中,高级会员还可免费学习本课程,点击文末的「阅读原文」了解课程更多内容!
👇👇👇