☰
【从0到1学习JVM · 02】JVM其实不认识Java,为什么任何语言变成字节码就能跨平台?
2026/9/30 8:34:14 网站建设 项目流程

前言

学上一篇我们知道,编译出.class是为了把繁重的语法解析提前做完。但字节码如果只为 Java 服务,它的价值其实只发挥了一半。这篇文章我们聊聊 JVM 是怎么跟 Java 语言彻底解绑的,为什么任何一门语言只要能生成字节码就能白嫖跨平台,以及真想自己写一门语言在 JVM 上跑到底需要几步。


文章目录

    • 前言
    • 一、JVM 压根不知道什么是 Java
      • 1.1 虚拟机规范里的唯二契约
      • 1.2 为什么 Java 生态没有被新语言割裂
    • 二、跨平台的二次解耦
      • 2.1 传统编译型语言适配多平台的痛苦
      • 2.2 字节码如何帮新语言省掉下半场脏活
    • 三、自己写一门语言跑在 JVM 上要几步?
      • 3.1 极简自制语言的语法与 AST
      • 3.2 用 ASM 库把语法树生成 class 字节码
      • 3.3 动态载入 JVM 并执行自制语言
    • 四、靠字节码跨平台也有它的代价
      • 4.1 运行时环境的内存底噪与冷启动
      • 4.2 语法特性与静态类型体系的摩擦
    • 五、写在最后

一、JVM 压根不知道什么是 Java

很多刚入行的朋友总觉得 JVM 就是专门跑 Java 的虚拟机。

这种直觉很普遍,但它并不准确。

1.1 虚拟机规范里的唯二契约

如果你翻开官方的《Java 虚拟机规范》,在第一章就会读到一句非常干脆的话:Java 虚拟机对 Java 编程语言一无所知,它只认一种特定的二进制文件格式,也就是 Class 文件。

JVM 从诞生之初,在技术实现上就跟 Java 语言划清了界限。

只要一个文件以十六进制魔数0xCAFEBABE开头,并且里面的常量池、字段表、方法表和属性表遵守规范,JVM 就会把它当成合法的程序加载进内存执行。

它不在乎你是用 Java 写的,还是用 Kotlin 写的,更不在乎是你昨晚随手写的一个自制小语言编译器生成的。

下层通用执行引擎(执行层)

各自语言的前端编译器(语法糖消除与指令翻译)

上层丰富的高级语言(表达层)

javac

kotlinc

scalac

自研编译器

Java 源码 (.java)

Kotlin 源码 (.kt)

Scala 源码 (.scala)

自制算式语言 (.expr)

统一二进制契约
.class 字节码 (0xCAFEBABE)

JVM 运行时环境
类加载器 + 内存管理 + GC + 解释器 / JIT 编译器

1.2 为什么 Java 生态没有被新语言割裂

在 C/C++ 时代,一旦出现新语言或者方言,最大的麻烦就是生态割裂。新语言很难直接复用老语言写好的库,开发者不得不把基础工具库全部重写一遍。

但在 JVM 体系里,事情完全不一样。

Kotlin 刚推出来的时候,为什么能迅速在 Android 和服务端铺开?

因为一个 Kotlin 类可以直接引入java.util.concurrent.ConcurrentHashMap,一个 Java 类也能直接new KotlinService()。

它们编译之后都是标准的.class文件。被类加载器搬进内存后,方法区里的类元数据、堆上的对象头结构一模一样。底层 JVM 根本分不出谁是 Java,谁是 Kotlin。

这种二进制层面的互通性,保住了整个生态的积累。

二、跨平台的二次解耦

很多人常说 Java 跨平台,但真正在工程里让多语言都能跨平台的支点,其实是字节码。

2.1 传统编译型语言适配多平台的痛苦

如果我们从零设计一门新语言,像 C 那样直接编译成 CPU 机器码,想要在 Windows、Linux 和 macOS 上跑起来,而且还要兼顾 x86_64、ARM64 乃至 RISC-V 架构,我们要干多少脏活?

前端的词法分析、语法树构建只是起步。真正的苦活在编译器后端。

我们必须为每一种 CPU 架构编写代码生成器,去处理不同的寄存器分配策略、函数调用规约、内存对齐规则和不同操作系统的系统调用。

JVM 字节码中转:N + M 星型解耦

Language A

通用字节码契约 (.class)

Language B

Windows JVM

Linux JVM

macOS JVM

传统原生编译:N 门语言 × M 种平台 = 网状复杂度

Language A

Windows x86_64

Linux ARM64

macOS Apple Silicon

Language B

如果世界上有N NN门语言要适配M MM种硬件系统,全世界的工程师总共要写N × M N \times MN×M个编译器后端。

这是极大的研发浪费。

2.2 字节码如何帮新语言省掉下半场脏活

字节码把原本的网状复杂度压缩成了星型复杂度:N + M N + MN+M。

新语言的设计者只需要关心语言本身的表达方式,把语法树翻译成 JVM 规范里规定的 200 多个操作码(Opcode),前端工作就算完成了。

至于代码跑起来之后,怎么在不同的操作系统之间调度原生线程?如何在不同的虚拟内存分页机制下分配堆内存?垃圾收集器怎么在毫秒级内完成内存回收?热点循环代码怎么由 JIT 即时编译成本地 CPU 机器码?

这些工业界积累了几十年的下半场技术,JVM 全部打包替你办妥。

三、自己写一门语言跑在 JVM 上要几步?

口说无凭,我们直接用一段代码来验证这个过程。

假设我们设计一门极简的数学算式语言SimpleMath,专门用来计算整数表达式。

3.1 极简自制语言的语法与 AST

比如我们有一行算式:

30 + 20 * 2

在编译器眼里,这段文本经过词法分析和语法分析后,会生成一颗抽象语法树(AST)。

因为乘法优先级高于加法,这棵树的根节点是加法操作符+,它的左子树是常量30,右子树是乘法节点*(由子节点20和2组成)。

而在 JVM 的栈式执行架构里,计算一个表达式不需要寄存器,只需要按照后序遍历操作数栈:

JVM 操作数栈执行流水线

算式抽象语法树:30 + 20 * 2

优先求值

最终求值

+ (加法节点)

30

* (乘法节点)

20

2

1. bipush 30
栈内: [30]

2. bipush 20
栈内: [30, 20]

3. bipush 2
栈内: [30, 20, 2]

4. imul
弹出 2 和 20 相乘,将 40 压栈
栈内: [30, 40]

5. iadd
弹出 40 和 30 相加,将 70 压栈
栈内: [70]

6. ireturn
返回栈顶整数 70

3.2 用 ASM 库把语法树生成 class 字节码

在 Java 生态里,要动态生成合法的.class字节码二进制流,最常用的工业级底层库是org.ow2.asm:asm。Spring 的动态代理、cglib 以及各类 APM 监控探针底层用的都是它。

我们写一个极简的编译器类SimpleMathCompiler.java,把上面的算式直接翻译成真实的.class字节码:

packagecom.crayontech.jvm;importorg.objectweb.asm.ClassWriter;importorg.objectweb.asm.MethodVisitor;importorg.objectweb.asm.Opcodes;publicclassSimpleMathCompilerimplementsOpcodes{publicstaticbyte[]compileExpression(){// ClassWriter.COMPUTE_FRAMES 自动帮我们计算栈帧大小与局部变量槽位ClassWritercw=newClassWriter(ClassWriter.COMPUTE_FRAMES);// 定义类头:JDK 8 兼容版本 (V1_8),public 类,类名为 DynamicCalculatorcw.visit(V1_8,ACC_PUBLIC,"com/autoblogs/jvm/DynamicCalculator",null,"java/lang/Object",null);// 生成默认构造方法:public DynamicCalculator() { super(); }MethodVisitorinit=cw.visitMethod(ACC_PUBLIC,"<init>","()V",null,null);init.visitCode();init.visitVarInsn(ALOAD,0);init.visitMethodInsn(INVOKESPECIAL,"java/lang/Object","<init>","()V",false);init.visitInsn(RETURN);init.visitMaxs(1,1);init.visitEnd();// 生成核心计算方法:public static int calculate()MethodVisitormv=cw.visitMethod(ACC_PUBLIC+ACC_STATIC,"calculate","()I",null,null);mv.visitCode();// 对应算式 30 + 20 * 2mv.visitIntInsn(BIPUSH,30);// 压入 30mv.visitIntInsn(BIPUSH,20);// 压入 20mv.visitIntInsn(BIPUSH,2);// 压入 2mv.visitInsn(IMUL);// 栈顶两数相乘 (20 * 2 = 40)mv.visitInsn(IADD);// 栈顶两数相加 (30 + 40 = 70)mv.visitInsn(IRETURN);// 返回 70mv.visitMaxs(0,0);// COMPUTE_FRAMES 会自动推导操作数栈深度mv.visitEnd();cw.visitEnd();returncw.toByteArray();// 输出符合 JVM 规范的真实 .class 二进制数组}}

这段代码通过调用 ASM 的 API,亲手拼出了一个完整的 Class 文件结构:类签名、默认构造函数,以及包含bipush、imul、iadd、ireturn字节码指令的calculate静态方法。

3.3 动态载入 JVM 并执行自制语言

字节码数组生成出来之后,我们甚至不需要把它写入本地磁盘文件。

直接借助 Java 的自定义类加载器,调用受保护的defineClass方法,就能在 JVM 运行期间把内存中的byte[]变成真正的类并反射执行:

packagecom.crayontech.jvm;importjava.lang.reflect.Method;publicclassSimpleMathRunner{// 自定义类加载器,把内存中的 byte[] 直接转成 JVM ClassstaticclassMemoryClassLoaderextendsClassLoader{publicClass<?>defineClassFromBytes(Stringname,byte[]bytecode){returndefineClass(name,bytecode,0,bytecode.length);}}publicstaticvoidmain(String[]args)throwsException{// 1. 运行自制小编译器,获得字节码二进制数据byte[]classBytes=SimpleMathCompiler.compileExpression();// 2. 内存加载该字节码MemoryClassLoaderloader=newMemoryClassLoader();Class<?>clazz=loader.defineClassFromBytes("com.autoblogs.jvm.DynamicCalculator",classBytes);// 3. 反射调用生成的 calculate 静态方法Methodmethod=clazz.getMethod("calculate");Objectresult=method.invoke(null);// 4. 打印计算结果System.out.println("自制语言在 JVM 上的执行结果: "+result);}}

运行这个类,控制台会输出:

自制语言在 JVM 上的执行结果: 70

你瞧,这就是自制一门语言并在 JVM 上跑起来的核心骨架。

它不仅能在你的 Mac 上跑,把这几行编译出来的字节码拿去 Windows 或者 Linux 服务器上,只要有 JVM,它一样能分毫不差地算出来。

跨平台并不是 Java 源码的特权,而是这套字节码体系赋予所有接入者的基础能力。

四、靠字节码跨平台也有它的代价

天下没有免费的午餐。

既然把代码编译成字节码跑在 JVM 上这么省事,为什么大家没有把所有语言都搬到 JVM 上来?

做技术选型的时候,我有两点体会比较深。

4.1 运行时环境的内存底噪与冷启动

JVM 是一个完整的受控运行时。

哪怕你只写了一个几十行的微型小工具,只要启动 JVM,它就得初始化类加载器体系、分配初始堆栈、启动后台 GC 线程以及准备 JIT 即时编译监控。

这就带来了一个天然的劣势:底噪大,启动慢。

在传统的长时间运行的服务端系统里,几秒钟的启动时间根本不是事,JIT 热点编译还能让代码越跑越快。

但在现在的 Serverless 弹性伸缩场景或者短平快的命令行小工具里,几十毫秒的冷启动容忍度和几兆内存的极致追求,让 Go 或 Rust 这类直接输出平台原生二进制可执行文件的语言占尽了便宜。

JVM 官方后来搞了 GraalVM Native Image,试图把字节码提前做 AOT(Ahead-Of-Time)编译成原生机器码,本质上也是在反思这个问题。

4.2 语法特性与静态类型体系的摩擦

另一个现实问题是语言个性的妥协。

JVM 最初是为 Java 这种纯面向对象的静态类型语言量身定做的。它的指令集充满了对象引用、虚方法分发和强类型检查。

后来动态类型语言(比如 Groovy、JRuby)想要跑在 JVM 上,就会遇到严重的阻碍:动态语言的方法调用在编译期无法确定接收者类型。

为了支持这类语言,Java 官方在 Java 7 破天荒地修改了虚拟机规范,引入了全新的invokedynamic指令与方法句柄(Method Handle)。

即便是现代静态语言,比如 Scala 极其复杂的隐式转换与类型系统、Kotlin 的空安全机制,编译成字节码时也需要前端编译器生成大量的桥接方法和额外的属性元数据。

如果一门语言想要做指针运算、手动分配释放内存,或者追求硬件极速响应,强行塞进 JVM 就会被垃圾回收和内存屏障锁得动弹不得。

五、写在最后

聊到这里,我们对字节码的理解就可以再往前走一步了。

字节码从来不只是“Java 编译后的中间文件”。它更像是一份工业级敲定的硬件抽象协议。

它把上层人类思考表达的自由度,与下层物理硬件架构的碎片化,彻底隔成了两个独立的世界。

上层不管怎么创新语法、怎么引入新特性,只要编译器能把逻辑拍平成这份标准指令集;下层不管是换了新的芯片架构还是换了新的操作系统,只要各大厂商写好对应的虚拟机。

下次再看到团队在工程里把 Java 和 Kotlin 混在一起写,或者听到有人在讨论某门新语言能不能上 JVM,相信你的心里已经有了清晰的底牌。

如果你觉得这个系列对你有帮助,欢迎点个关注,下一篇我们继续深入剖析.class文件的内部结构,看看常量池和方法表里到底藏着什么玄机。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询