前言

学 JVM 的第一课,先得搞懂代码到底是怎么在机器上跑起来的。这篇文章拆解 Java“一次编译,到处运行”的执行过程,聊聊不同系统里 JVM 扮演的角色,以及为什么非要多一步编译出字节码。


一、Java 跨平台,但 JVM 不跨平台

很多人在面试里被问到为什么 Java 能跨平台,张口就来一句:“因为 JVM 是跨平台的。”

每次听到这个回答我都会直接扣分。这种说法把 Java 语言和 JVM 混在了一起,但凡亲手去官网下过一次 JDK,就知道它根本站不住脚。

1.1 拿微信安装包打个比方

我刚开始学 Java 那会儿也犯过迷糊:既然号称“一次编译,到处运行”,为什么我去下载 JDK 的时候,还得老老实实区分 Windows 的 .exe、Linux 的 x64 tar.gz 和 macOS 的 aarch64 dmg?

这事其实跟装微信差不多。你在 Windows 电脑上装微信得下 .exe,换到 MacBook 上就得下 .dmg。把 Windows 的 .exe 拖进 Mac 里双击肯定跑不起来,但只要各自安装好、登上账号,不管你是发文字还是传文件,两边微信干的活是一模一样的。JVM 在操作系统眼里,也就是个跟微信一样的普通本地程序。

各自翻译成宿主 CPU 能听懂的机器指令

不同操作系统必须安装各自专属的 JVM 安装包(不跨平台)

开发者只写一次、编译一次

javac 编译

转成 Win64 系统调用 + x86_64 指令

转成 Linux 系统调用 + x86_64 指令

转成 Darwin 系统调用 + ARM64 指令

OrderPriceCalculator.java

OrderPriceCalculator.class(统一字节码)

Windows 版 JVM(java.exe + jvm.dll)

Linux 版 JVM(java + libjvm.so)

macOS 版 JVM(java + libjvm.dylib)

Windows + Intel/AMD 芯片

Linux 服务器 + Xeon/EPYC 芯片

macOS + Apple M 系列芯片

1.2 不同系统的 JVM 文件长啥样

直接在 macOS 和 Linux 终端里敲一行 file 命令,就能看到两个系统里 JDK 21 核心虚拟机动态库的底细:

# 在 macOS (Apple M 系列芯片) 上查看 JVM 核心库
$ file $(/usr/libexec/java_home -v 21)/lib/server/libjvm.dylib
/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/lib/server/libjvm.dylib: Mach-O 64-bit dynamically linked shared library arm64

# 在 Linux (Ubuntu x86_64) 上查看 JVM 核心库
$ file /usr/lib/jvm/temurin-21-jdk-amd64/lib/server/libjvm.so
/usr/lib/jvm/temurin-21-jdk-amd64/lib/server/libjvm.so: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked

macOS 下是 Mach-O arm64 格式的 libjvm.dylib,Linux 下是 ELF x86-64 格式的 libjvm.so。底层 CPU 根本不认识什么 .java 或 .class,它只认机器指令。

JVM 的开发团队用 C/C++ 和汇编,针对不同操作系统和 CPU 架构分别写出了对应的安装包。这些不同版本的 JVM 跑起来之后,往上都能读懂同一份 .class 字节码,往下各自翻译成当前机器的指令。脏活累活全让底层的 JVM 扛了,上层的 Java 代码才得以在不同的机器上跑起来。


二、为什么不直接跑 .java 源码?

我当年学到这里的时候,脑子里冒出过另一个问题:既然不同系统上都已经装好了各自的 JVM,为什么不让它们直接去读同一份 .java 源码?

写一份 OrderPriceCalculator.java,直接丢给 Windows 的 JVM 解释成 Windows 机器码,丢给 Linux 的 JVM 解释成 Linux 机器码,照样能跨平台。先把 .java 编译成 .class,再让 JVM 把 .class 翻译成机器指令,中间多倒这一手字节码,到底图什么?

2.1 直接解释源码慢在哪

如果省掉编译这一步,让 JVM 在运行期直接去读 .java 源文件,原理上完全能跑通,但 Java 就会退化成类似早期 Shell 脚本的纯源码解释型语言,执行速度会非常慢:

方案 B:Java 真实方案(上线前 javac 静态编译 + 运行期 JVM 执行 .class)

上线部署

打包期:javac 完成词法/语法/类型检查,输出紧凑 .class

运行期:JVM 直接按单字节 Opcode 查表映射/JIT 编译机器码

方案 A:假如 JVM 在运行期直接解释 .java 源码(每次运行都要重走一遍)

读取 .java 文本字符

词法分析切分 Token

语法分析构建 AST 树

类型校验与符号解析

翻译为 CPU 机器码

.java 是写给人看的高级语言,越贴近人类阅读习惯的代码,机器现场解析的代价就越高。如果每次方法调用都要在运行期做字符扫描、括号匹配、抽象语法树(AST)构建和类型检查,线上高并发服务器的 CPU 算力都会耗在解析文本上,根本没空处理真正的业务逻辑。

2.2 javac 提前干了什么

我们写一段计算订单实付金额的 OrderPriceCalculator.java,对比一下 .java 源码和编译后的 .class 字节码:

package com.crayontech.jvm;

public class OrderPriceCalculator {
    public static int calculatePayable(int unitPrice, int count, int coupon) {
        int total = unitPrice * count;
        return total - coupon;
    }

    public static void main(String[] args) {
        int payable = calculatePayable(199, 2, 50);
        System.out.println(payable);
    }
}

用 javac 编译后,再用 javap -c 反编译看看里面的指令:

$ javac OrderPriceCalculator.java
$ javap -c com.autoblogs.jvm.OrderPriceCalculator

反编译出来的 calculatePayable 方法体只有 8 行指令:

public class com.autoblogs.jvm.OrderPriceCalculator {
  public static int calculatePayable(int, int, int);
    Code:
       0: iload_0       // 0x1a:把第 0 号槽位的参数 (unitPrice) 压入操作数栈
       1: iload_1       // 0x1b:把第 1 号槽位的参数 (count) 压入操作数栈
       2: imul          // 0x68:弹出栈顶两个整数相乘,结果压回栈顶
       3: istore_3      // 0x3e:把乘积弹出,存进第 3 号局部变量槽位 (total)
       4: iload_3       // 0x1d:把第 3 号槽位的 total 重新压栈
       5: iload_2       // 0x1c:把第 2 号槽位的参数 (coupon) 压栈
       6: isub          // 0x64:栈顶两数相减 (total - coupon)
       7: ireturn       // 0xac:返回栈顶计算结果
}

源码里那些 unitPrice、count、coupon 变量名全没了,在编译期统统被替换成了定长的局部变量表槽位编号(0、1、2、3)。复杂的表达式和语法树,也全被拍平成了 1 个字节长度的操作码(比如 0x1a 代表 iload_0,0x68 代表 imul)。

JVM 执行这段字节码时,不需要再去猜语法,直接照着槽位编号往操作数栈里搬数据、算加减乘除就行:

JVM 字节码指令流水线(无需解析语法,按单字节指令直推)

当前栈帧 - 局部变量表(Slot 槽位)

Slot 0: unitPrice = 199

Slot 1: count = 2

Slot 2: coupon = 50

Slot 3: total = 398

0: iload_0 / 1: iload_1
从 Slot 0、1 取数压入操作数栈

2: imul
栈顶 199 * 2 = 398

3: istore_3
结果 398 写入 Slot 3

4: iload_3 / 5: iload_2
取出 398 与 Slot 2 的 50

6: isub / 7: ireturn
计算 398 - 50 = 348 并返回

把这三种执行方式放在一张表里,各自的取舍就很清楚了:

对比项假如 JVM 直接跑 .java 源码先编译成 .class 字节码再由 JVM 执行像 C/C++ 一样直接编译成机器码
语法检查与类型推导在哪做运行期每次跑的时候现场做**编译期(打包阶段)**一次性做完**编译期(打包阶段)**一次性做完
喂给执行引擎的格式人类可读的长字符串文本1 字节紧凑操作码(Opcode)+ 常量池索引CPU 原生指令(x86_64 / ARM64)
运行期翻译速度极慢,CPU 全浪费在解析文本上快,单字节查表即可转机器码,还能配合 JIT 热点编译极快,操作系统拿过来直接上 CPU 跑
跨平台分发得把全部源码发给目标机器只发一个 .class 或 .jar 包就行换个系统就得重新编译一份二进制包

说白了,把 .java 编译成 .class 字节码,就是用打包上线前那几秒钟的编译时间,去换线上服务器运行期成千上万次调用的执行效率。


三、让其他语言也能跑在 JVM 上

中间隔一层字节码,还顺带让 JVM 和 Java 语言解了绑。

现在的 JVM 只认 .class 文件格式,根本不管这份文件是从什么语言编译出来的。只要一门语言的编译器产出的二进制文件以魔数 0xCAFEBABE 开头,并且遵守《Java 虚拟机规范》里的常量池和方法表结构,就能直接塞进 JVM 里运行。

任意操作系统的 JVM 运行时

各自语言的前端编译器

不同语法习惯的高级语言源码

javac

kotlinc

scalac

groovyc

Java 源码 (.java)

Kotlin 源码 (.kt)

Scala 源码 (.scala)

Groovy 脚本 (.groovy)

统一字节码契约
.class 文件 (0xCAFEBABE)

同一个字节码解释器 + JIT 编译器 + GC 垃圾回收器

比如用 Kotlin 写一个打折函数 OrderDiscount.kt:

package com.crayontech.jvm

class OrderDiscount {
    fun applyVipDiscount(price: Int): Int {
        return (price * 85) / 100
    }
}

用 kotlinc 编译完之后,拿 xxd 看一眼文件头,再用 Java 自带的 javap -c 拆开:

$ kotlinc OrderDiscount.kt
$ xxd -l 8 com/autoblogs/jvm/OrderDiscount.class
00000000: cafe babe 0000 0034                      .......4

$ javap -c com.autoblogs.jvm.OrderDiscount
public final class com.autoblogs.jvm.OrderDiscount {
  public final int applyVipDiscount(int);
    Code:
       0: iload_1
       1: bipush        85
       3: imul
       4: bipush        100
       6: idiv
       7: ireturn
}

文件头照样是 cafe babe,里面的指令也是熟悉的 iload_1、bipush、imul、idiv 和 ireturn。对底层的 JVM 来说,它分不清、也不需要关心这段代码当初是用 Java 还是用 Kotlin 写的。哪怕你自己设计一门新语言,只要写出能输出合规 .class 文件的编译器,就能直接复用 JVM 现成的垃圾回收器和 JIT 编译器。


四、我做跨平台时踩过的两个坑

虽然字节码屏蔽了绝大部分底层差异,但真到了发版上线的时候,有两个地方一旦没对齐,打出来的 .jar 包换台机器照样跑不起来。

最常碰到的是本地 javac 编译版本比线上服务器的 JVM 版本高。很多人在自己的 Mac 上装了 JDK 21 写代码、打 Jar 包,结果测试服或者老生产机上跑的还是 JDK 8。每个 .class 文件的第 7~8 个字节都记录着它的主版本号(比如 JDK 8 是 52,JDK 17 是 61,JDK 21 是 65)。高版本的 JVM 能向下兼容老版本的字节码,反过来则不行。把 JDK 21 编译出来的字节码(版本 65.0)丢给 JDK 8(最高只认 52.0)的 JVM,类加载阶段就会直接报错退出:

Exception in thread "main" java.lang.UnsupportedClassVersionError: com/autoblogs/jvm/OrderPriceCalculator has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 52.0

另一个容易翻车的点,是项目里引入了带 C/C++ 本地动态库(JNI)的依赖,比如用到了 OpenCV 图像处理或者指定了某个平台的 Netty Native epoll 库。这些组件底层调用的 .dll、.dylib 或 .so 文件,是绕过字节码直接跟宿主操作系统打交道的。在 Mac(ARM64)上调得好好的代码,打包扔进 Linux(x86_64)Docker 容器里,一旦容器内缺少对应架构的 .so 文件,启动时就会抛出 java.lang.UnsatisfiedLinkError。至于在业务代码里把文件路径硬编码成 "C:\\data\\export\\" 导致 Linux 下找不到目录的问题,写代码时直接用 Paths.get() 就能避开。


五、写在最后

聊到这里,开头那两个问题其实就归结到一处了:Java 代码能跨平台,是因为底层的 JVM 替我们在不同操作系统上做了适配;多出来的那一层 .class 字节码,则是为了把耗时的语法解析提前在打包阶段解决掉,顺便给其他语言留出了接入的口子。

平时写完业务代码,不妨在终端里顺手敲一行 javap -c 看看反编译出来的指令。等看惯了局部变量表和操作数栈之间的数据进出,后面再学类加载器是怎么把 .class 文件搬进内存的,理解起来就会顺畅得多。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐