【从0到1学习JVM · 02】JVM其实不认识Java,为什么任何语言变成字节码就能跨平台?
发布时间:2026/9/30 8:34:20来源:尧图网络
前言学上一篇我们知道编译出.class是为了把繁重的语法解析提前做完。但字节码如果只为 Java 服务它的价值其实只发挥了一半。这篇文章我们聊聊 JVM 是怎么跟 Java 语言彻底解绑的为什么任何一门语言只要能生成字节码就能白嫖跨平台以及真想自己写一门语言在 JVM 上跑到底需要几步。文章目录前言一、JVM 压根不知道什么是 Java1.1 虚拟机规范里的唯二契约1.2 为什么 Java 生态没有被新语言割裂二、跨平台的二次解耦2.1 传统编译型语言适配多平台的痛苦2.2 字节码如何帮新语言省掉下半场脏活三、自己写一门语言跑在 JVM 上要几步3.1 极简自制语言的语法与 AST3.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 写的更不在乎是你昨晚随手写的一个自制小语言编译器生成的。下层通用执行引擎执行层各自语言的前端编译器语法糖消除与指令翻译上层丰富的高级语言表达层javackotlincscalac自研编译器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 BWindows JVMLinux JVMmacOS JVM传统原生编译N 门语言 × M 种平台 网状复杂度Language AWindows x86_64Linux ARM64macOS Apple SiliconLanguage B如果世界上有N NN门语言要适配M MM种硬件系统全世界的工程师总共要写N × M N \times MN×M个编译器后端。这是极大的研发浪费。2.2 字节码如何帮新语言省掉下半场脏活字节码把原本的网状复杂度压缩成了星型复杂度N M N MNM。新语言的设计者只需要关心语言本身的表达方式把语法树翻译成 JVM 规范里规定的 200 多个操作码Opcode前端工作就算完成了。至于代码跑起来之后怎么在不同的操作系统之间调度原生线程如何在不同的虚拟内存分页机制下分配堆内存垃圾收集器怎么在毫秒级内完成内存回收热点循环代码怎么由 JIT 即时编译成本地 CPU 机器码这些工业界积累了几十年的下半场技术JVM 全部打包替你办妥。三、自己写一门语言跑在 JVM 上要几步口说无凭我们直接用一段代码来验证这个过程。假设我们设计一门极简的数学算式语言SimpleMath专门用来计算整数表达式。3.1 极简自制语言的语法与 AST比如我们有一行算式30 20 * 2在编译器眼里这段文本经过词法分析和语法分析后会生成一颗抽象语法树AST。因为乘法优先级高于加法这棵树的根节点是加法操作符它的左子树是常量30右子树是乘法节点*由子节点20和2组成。而在 JVM 的栈式执行架构里计算一个表达式不需要寄存器只需要按照后序遍历操作数栈JVM 操作数栈执行流水线算式抽象语法树30 20 * 2优先求值最终求值 (加法节点)30* (乘法节点)2021. 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返回栈顶整数 703.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 自动帮我们计算栈帧大小与局部变量槽位ClassWritercwnewClassWriter(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(); }MethodVisitorinitcw.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()MethodVisitormvcw.visitMethod(ACC_PUBLICACC_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[]classBytesSimpleMathCompiler.compileExpression();// 2. 内存加载该字节码MemoryClassLoaderloadernewMemoryClassLoader();Class?clazzloader.defineClassFromBytes(com.autoblogs.jvm.DynamicCalculator,classBytes);// 3. 反射调用生成的 calculate 静态方法Methodmethodclazz.getMethod(calculate);Objectresultmethod.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试图把字节码提前做 AOTAhead-Of-Time编译成原生机器码本质上也是在反思这个问题。4.2 语法特性与静态类型体系的摩擦另一个现实问题是语言个性的妥协。JVM 最初是为 Java 这种纯面向对象的静态类型语言量身定做的。它的指令集充满了对象引用、虚方法分发和强类型检查。后来动态类型语言比如 Groovy、JRuby想要跑在 JVM 上就会遇到严重的阻碍动态语言的方法调用在编译期无法确定接收者类型。为了支持这类语言Java 官方在 Java 7 破天荒地修改了虚拟机规范引入了全新的invokedynamic指令与方法句柄Method Handle。即便是现代静态语言比如 Scala 极其复杂的隐式转换与类型系统、Kotlin 的空安全机制编译成字节码时也需要前端编译器生成大量的桥接方法和额外的属性元数据。如果一门语言想要做指针运算、手动分配释放内存或者追求硬件极速响应强行塞进 JVM 就会被垃圾回收和内存屏障锁得动弹不得。五、写在最后聊到这里我们对字节码的理解就可以再往前走一步了。字节码从来不只是“Java 编译后的中间文件”。它更像是一份工业级敲定的硬件抽象协议。它把上层人类思考表达的自由度与下层物理硬件架构的碎片化彻底隔成了两个独立的世界。上层不管怎么创新语法、怎么引入新特性只要编译器能把逻辑拍平成这份标准指令集下层不管是换了新的芯片架构还是换了新的操作系统只要各大厂商写好对应的虚拟机。下次再看到团队在工程里把 Java 和 Kotlin 混在一起写或者听到有人在讨论某门新语言能不能上 JVM相信你的心里已经有了清晰的底牌。如果你觉得这个系列对你有帮助欢迎点个关注下一篇我们继续深入剖析.class文件的内部结构看看常量池和方法表里到底藏着什么玄机。
网站建设高端定制企业官网