资讯动态

【从0到1学习JVM · 01】JVM 本身根本不跨平台?拆解“一次编译到处运行”的假象与字节码存在的真正理由

发布时间:2026/9/30 15:34:12 来源:尧图企业网站定制
前言学 JVM 的第一课先得搞懂代码到底是怎么在机器上跑起来的。这篇文章拆解 Java“一次编译到处运行”的执行过程聊聊不同系统里 JVM 扮演的角色以及为什么非要多一步编译出字节码。文章目录前言一、Java 跨平台但 JVM 不跨平台1.1 拿微信安装包打个比方1.2 不同系统的 JVM 文件长啥样二、为什么不直接跑 .java 源码2.1 直接解释源码慢在哪2.2 javac 提前干了什么三、让其他语言也能跑在 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.javaOrderPriceCalculator.class统一字节码Windows 版 JVMjava.exe jvm.dllLinux 版 JVMjava libjvm.somacOS 版 JVMjava libjvm.dylibWindows 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-v21)/lib/server/libjvm.dylib /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/lib/server/libjvm.dylib: Mach-O64-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: ELF64-bit LSB shared object, x86-64, version1(GNU/Linux), dynamically linkedmacOS 下是Mach-O arm64格式的libjvm.dylibLinux 下是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 脚本的纯源码解释型语言执行速度会非常慢方案 BJava 真实方案上线前 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字节码packagecom.crayontech.jvm;publicclassOrderPriceCalculator{publicstaticintcalculatePayable(intunitPrice,intcount,intcoupon){inttotalunitPrice*count;returntotal-coupon;}publicstaticvoidmain(String[]args){intpayablecalculatePayable(199,2,50);System.out.println(payable);}}用javac编译后再用javap -c反编译看看里面的指令$ javac OrderPriceCalculator.java $ javap-ccom.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_00x68代表imul。JVM 执行这段字节码时不需要再去猜语法直接照着槽位编号往操作数栈里搬数据、算加减乘除就行JVM 字节码指令流水线无需解析语法按单字节指令直推当前栈帧 - 局部变量表Slot 槽位Slot 0: unitPrice 199Slot 1: count 2Slot 2: coupon 50Slot 3: total 3980: iload_0 / 1: iload_1从 Slot 0、1 取数压入操作数栈2: imul栈顶 199 * 2 3983: istore_3结果 398 写入 Slot 34: iload_3 / 5: iload_2取出 398 与 Slot 2 的 506: 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 运行时各自语言的前端编译器不同语法习惯的高级语言源码javackotlincscalacgroovycJava 源码 (.java)Kotlin 源码 (.kt)Scala 源码 (.scala)Groovy 脚本 (.groovy)统一字节码契约.class 文件 (0xCAFEBABE)同一个字节码解释器 JIT 编译器 GC 垃圾回收器比如用 Kotlin 写一个打折函数OrderDiscount.ktpackagecom.crayontech.jvmclassOrderDiscount{funapplyVipDiscount(price:Int):Int{return(price*85)/100}}用kotlinc编译完之后拿xxd看一眼文件头再用 Java 自带的javap -c拆开$ kotlinc OrderDiscount.kt $ xxd-l8com/autoblogs/jvm/OrderDiscount.class 00000000: cafe babe 0000 0034.......4 $ javap-ccom.autoblogs.jvm.OrderDiscount public final class com.autoblogs.jvm.OrderDiscount{public final int applyVipDiscount(int);Code:0: iload_11: bipush853: imul4: bipush1006: idiv7: 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文件的第 78 个字节都记录着它的主版本号比如 JDK 8 是52JDK 17 是61JDK 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 Nativeepoll库。这些组件底层调用的.dll、.dylib或.so文件是绕过字节码直接跟宿主操作系统打交道的。在 MacARM64上调得好好的代码打包扔进 Linuxx86_64Docker 容器里一旦容器内缺少对应架构的.so文件启动时就会抛出java.lang.UnsatisfiedLinkError。至于在业务代码里把文件路径硬编码成C:\\data\\export\\导致 Linux 下找不到目录的问题写代码时直接用Paths.get()就能避开。五、写在最后聊到这里开头那两个问题其实就归结到一处了Java 代码能跨平台是因为底层的 JVM 替我们在不同操作系统上做了适配多出来的那一层.class字节码则是为了把耗时的语法解析提前在打包阶段解决掉顺便给其他语言留出了接入的口子。平时写完业务代码不妨在终端里顺手敲一行javap -c看看反编译出来的指令。等看惯了局部变量表和操作数栈之间的数据进出后面再学类加载器是怎么把.class文件搬进内存的理解起来就会顺畅得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑