《Java 编程入门》1.3 JVM、JRE、JDK 与字节码

把 JDK、JRE、JVM 三个常被混用的概念拆开:JDK 里到底有什么、JRE 为什么从 JDK 11 起不再单独分发、JVM 的类加载器与运行时数据区各管什么;再用 javap、class 文件头和真实报错,把字节码结构、解释执行与 JIT 编译、常见类加载错误串起来。

本节目标:分清 JDK、JRE、JVM 的边界,知道 java 命令启动后发生了什么,能读懂 class 文件头和最简单的字节码,并认出四类常见的类加载错误。
适用版本:JDK 25 LTS(Temurin 25.0.5)。

三个缩写,三种边界

面试里常问「JDK 和 JRE 有什么区别」,标准答案通常是「JDK 包含 JRE,JRE 包含 JVM」。这句话没错,但太粗。更准确的关系是:

概念包含什么解决什么问题
JVM类加载器、运行时数据区、执行引擎、本地接口执行字节码,把「与平台无关」变成「与平台相关」
JREJVM + 核心类库 + 配置文件提供运行 Java 程序所需的最小环境
JDKJRE + 开发工具(javac、javap、jlink 等)提供编译、调试、打包、诊断的完整工具链

三者的包含关系是概念上的。实际安装时,从 JDK 11 起,Oracle 和主流发行版不再单独分发 JRE。你需要运行时,有两种做法:装完整 JDK,或者用 jlink 从 JDK 里裁出一个只含所需模块的运行时镜像。

这个变化影响了很多旧教程:它们会教你「生产环境只装 JRE」,但在 JDK 25 上,官方并没有一个叫 jre-25 的独立包。生产环境通常直接装 JDK(用不到的工具不调用即可),或者用 jlink/jpackage 产出自定义运行时。

JDK 里到底有什么

装好 JDK 25 后,$JAVA_HOME/bin 下是一组工具。常用的可以分成四类:

类别工具用途
运行java启动 JVM、加载主类、执行程序
编译javac把 .java 编译成 .class
交互jshell不写完整类,直接试跑片段
查看javap、jdeps、jdeprscan反汇编、依赖分析、废弃 API 检查
打包jar、jlink、jpackage打 jar、裁运行时、做安装包
诊断jcmd、jfr、jconsole、jstack线程/堆/GC/飞行记录

这些工具不是「附加福利」,而是 Java 工程能力的一部分。卷四会用 jcmd、jfr、jstack 分析 libraryd 的运行时行为;卷一先用 javac、java、javap、jshell 四个。

jshell 特别适合验证小片段。比如想知道 Integer.MAX_VALUE + 1 的结果:

jshell
|  Welcome to JShell -- Version 25.0.5
|  For an introduction type: /help intro

jshell> Integer.MAX_VALUE + 1
$1 ==> -2147483648

不用建文件、不用写 main,适合验证语法和行为。但 jshell 的片段是「语句级」的,涉及包结构、模块、构建的问题,还是要在真实工程里验证。

JVM 的四个部分

JVM 不是一个黑盒。按职责可以分成四块:

部分主要职责出问题时看什么
类加载子系统加载、链接、初始化 class 文件ClassNotFoundException、NoClassDefFoundError
运行时数据区堆、JVM 栈、PC 寄存器、本地方法栈、方法区/MetaspaceOutOfMemoryError、StackOverflowError
执行引擎解释器、JIT 编译器、垃圾回收器CPU 高、停顿长、吞吐低
本地接口JNI、Foreign Function & Memory API本地库崩溃、符号找不到

卷一只需要建立这个分层印象。后面每一卷会逐层展开:卷三讲类加载、字节码和并发,卷四讲执行引擎、GC 和内存布局。现在先记住一件事:你写的 .java 不会直接变成机器码,中间隔着 class 文件、类加载、解释执行和 JIT 编译四道关。

class 文件里有什么

上一节用 javap -c 看过字节码指令。这次看文件头。用 xxd 取前 16 个字节:

xxd -l 16 LibraryApp.class
00000000: cafe babe 0000 0045 003d 0a00 0200 0307  .......E.=......

前四个字节 CA FE BA BE 是 class 文件的魔数(magic number),固定值,用来快速识别「这是不是一个 class 文件」。接下来两个字节 0000 是 minor version,再两个字节 0045 是 major version。0x45 是十进制的 69,对应 JDK 25。

再往后是常量池、访问标志、类索引、字段表、方法表、属性表。javap -verbose 会把它们翻译成可读形式:

Classfile /private/tmp/java_probe/LibraryApp.class
  Last modified 2026年10月27日; size 1080 bytes
  SHA-256 checksum 92421f00d3d29cbbb75157feefd72efa04fcccad40e239bd22df200f457b8c37
  Compiled from "LibraryApp.java"
public class LibraryApp
  minor version: 0
  major version: 69
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #35                         // LibraryApp
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 3
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V

几个值得注意的点:

  • this_class 和 super_class 都是常量池索引,不是直接存字符串。常量池是 class 文件里最大的结构,字符串、类名、方法描述符都放在里面。
  • 每个类都有一个隐式构造器 <init>,即使你没写。javap 把它列出来了,方法体只有 aload_0、invokespecial、return 三条指令:加载 this、调用父类构造器、返回。
  • 方法描述符用一套紧凑的编码:()V 表示「无参数、返回 void」,(Ljava/lang/String;)V 表示「接收一个 String、返回 void」。卷三讲反射时会再遇到这套编码。

class 文件是 JVM 的「可执行格式」,但它不是机器码。JVM 可以解释执行,也可以把热点代码编译成机器码。下一节看这个切换过程。

从 class 到机器码:解释与 JIT

JVM 启动后,默认是「解释 + 编译」混合模式。可以强制只解释执行:

java -Xint -version
openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, interpreted mode, sharing)

注意最后一行从 mixed mode 变成了 interpreted mode。解释模式启动快,但执行慢,通常只用于调试或排除 JIT 干扰。

想看 JIT 到底编译了哪些方法,加 -XX:+PrintCompilation:

java -XX:+PrintCompilation LibraryApp
23    1       3       java.lang.Object::<init> (1 bytes)
24    2       3       java.lang.String::hashCode (60 bytes)
24    3       3       java.lang.Byte::toUnsignedInt (6 bytes)
24    4     n 0       jdk.internal.misc.Unsafe::getReferenceVolatile (native)
25    5     n 0       jdk.internal.vm.Continuation::enterSpecial (native)   (static)
25    6     n 0       jdk.internal.vm.Continuation::doYield (native)   (static)
25    7       3       java.lang.String::coder (15 bytes)
27    8       3       java.lang.String::isLatin1 (19 bytes)
27    9       3       jdk.internal.util.Preconditions::checkIndex (18 bytes)
27   10       3       java.lang.String::charAt (25 bytes)
27   11       3       java.lang.String::length (11 bytes)
27   12     n 0       java.lang.invoke.MethodHandle::linkToStatic(L)L (native)   (static)

每行的含义大致是:时间戳、编译序号、编译层级(0–4,数字越大优化越激进)、方法名、字节码大小。一个 Hello World 也会触发几十次编译,因为字符串拼接、类加载、MethodHandle 链接这些基础设施本身就要跑。

这也解释了一个常见现象:同一个程序,刚启动时慢,跑一会儿变快。 不是错觉,是 JIT 把热点方法从解释执行换成了机器码。卷四会讲分层编译、内联、逃逸分析和去优化;现在只需要知道这条链路存在。

JVM 不止一个

「JVM」是一个规范,不是某一个程序。常见的实现有:

  • HotSpot:OpenJDK 和绝大多数发行版的默认实现,也是本书使用的实现。
  • OpenJ9:IBM 主导,强调启动速度和内存占用,常见于特定云环境。
  • GraalVM:支持 Native Image,把 Java 程序提前编译成原生二进制,启动快、内存低,但构建复杂、动态特性受限。

同一个 class 文件在这些 JVM 上都能跑,因为大家都遵守同一套字节码规范和 class 文件格式。但行为不保证完全一致:GC 算法、默认参数、JIT 策略、线程实现都可能不同。跨 JVM 实现做性能对比时,必须把实现和版本都写清楚,否则数字没有意义。

在 library 里落地

library 的第一版用最朴素的方式运行:编译到 out,用 -cp 指定类路径。

javac -d out src/main/java/com/plume/library/LibraryApp.java
java -cp out com.plume.library.LibraryApp list
command = list

-cp(classpath)告诉 JVM 去哪里找类。默认值是当前目录 .,所以早期的小例子不写 -cp 也能跑;一旦有了包结构和多个输出目录,就必须显式指定。

如果漏了类路径,会看到非常典型的错误:

java -cp /nonexistent com.plume.library.LibraryApp list
错误: 找不到或无法加载主类 com.plume.library.LibraryApp
原因: java.lang.ClassNotFoundException: com.plume.library.LibraryApp

中文环境下的报错是本地化的,英文环境会显示 Could not find or load main class。两者是同一件事:JVM 在 classpath 上找不到这个全限定类名。

卷一最后会把程序打成可执行 jar,运行时就不需要手动写 -cp 了。如果想把运行时也一起打包,可以用 jlink 裁一个最小镜像。下面这个命令只保留 java.base 模块:

jlink --add-modules java.base --output minjre \
  --strip-debug --no-header-files --no-man-pages
du -sh minjre
 45M	minjre

裁出来的 minjre/bin/java 可以运行只依赖 java.base 的 LibraryApp。45 MB 听起来还是不小,但相比完整 JDK 已经小了一个量级。卷四会讲 jlink、jpackage 和 CDS 的取舍;卷一知道有这条路即可。

四类常见的类加载错误

报错典型原因排查方向
ClassNotFoundExceptionclasspath 上没有这个类检查 -cp、jar 是否包含、包名是否写错
NoClassDefFoundError编译时有、运行时缺;或静态初始化失败检查依赖是否打进产物、静态块是否抛异常
UnsupportedClassVersionErrorclass 文件版本高于 JVM 支持版本用 --release 降目标版本,或升级运行环境
NoSuchMethodError编译时链接了高版本 API,运行时是低版本用 --release 统一编译目标,检查依赖冲突

前两个是「找不到类」,第三个是「版本不匹配」,第四个是「方法签名不匹配」。它们的共同点是:编译通过不代表运行通过,因为编译期和运行期的 classpath 可能不是同一套。卷二讲依赖治理时,会专门处理这类问题。

小结

  • JDK ⊃ JRE ⊃ JVM 是概念关系;从 JDK 11 起不再单独分发 JRE,运行时可以用完整 JDK 或 jlink 裁剪。
  • JDK 的工具分运行、编译、交互、查看、打包、诊断六类,卷一先用 javac、java、javap、jshell。
  • JVM 由类加载子系统、运行时数据区、执行引擎、本地接口四部分组成。
  • class 文件以 CAFEBABE 开头,major version 69 对应 JDK 25;常量池、方法表、属性表都可以用 javap -verbose 查看。
  • 默认是解释 + JIT 混合模式;-Xint 强制解释,-XX:+PrintCompilation 可以观察 JIT 编译。
  • library 用 javac -d out + java -cp out 起步,后续再打 jar、裁运行时。
  • 四类类加载错误分别指向 classpath、静态初始化、版本、方法签名四个方向。

到这里,Java 的「运行底座」已经清楚了。下一章开始动手:装好 JDK 25、配好 Maven、写出第一个可编译可运行的 library 工程。

阅读导航:上一节:1.2 JDK 25 LTS 与版本节奏 · 下一节:2.1 安装 JDK 25 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发