资讯动态

JVM核心机制从入门到实践:运行时数据区、类加载与字节码

发布时间:2026/10/21 5:38:46 来源:尧图企业网站定制
JVM 在我眼里从来不是“Java 虚拟机”五个字那么简单。它更像一台运行在内存里的抽象计算机只要喂给它一份符合规范的.class字节码它就能在 Linux、macOS、Windows 上跑出同样的结果把指令集和操作系统的差异全部挡在外面。这也是我准备写“Java技术体系全貌”时坚持把 JVM 放在最前面的原因语言和框架可以快速迭代但 JVM 撑起的运行时基础几乎贯穿了所有 Java 程序的一生。这篇文章适合两类人一类是刚学完 Java 语法想知道main方法到底怎么被层层加载、执行的初学者另一类是已经写了几年业务代码但被类加载冲突、元空间溢出、字节码增强折腾得头疼的开发者。上篇不深入垃圾收集细节先把运行时数据区、类加载机制、字节码指令、对象生命周期这几块地基讲清楚。地基稳了下篇聊 GC 和调优才有判断力。1. 先看懂JVM在Java技术体系里的位置1.1 “一次编写到处运行”的底气不是语言而是字节码规范很多人在 Java 入门第一天就听到“一次编写到处运行”这句话顺理成章把功劳记到 Java 语言头上。实际上翻译这句话的并不是语言本身而是 JVM 和它背后的字节码规范。javac负责把.java文件翻译成.class文件.class文件里装的是 JVM 指令不是 x86 指令也不是 ARM 指令。不同操作系统上有不同版本的 JVM 实现但只要能识别同一份字节码就能运行同一个程序。这也是“Java 技术体系”里很有意思的一点JVM 其实不关心前端语言是谁。Kotlin、Scala、Groovy 甚至 JRuby 最终都能编译成 JVM 字节码然后在同一套运行时里执行。所以把 JVM 理解成“Java 专属虚拟机”是窄了更准确的说法是一台与语言无关的抽象计算机。只要你遵守它的字节码规范任何语言都能在这台机器上安家。1.2 JDK、JRE与JVM别再让术语绕晕JDK、JRE、JVM 这三个词经常被混着用实际上是从大到小的包含关系。JDK 是面向开发者的完整工具包里面除了运行环境还有javac、jar、jcmd、jhsdb这些编译和排查工具JRE 是面向普通程序运行环境的运行时包含 JVM、类库和一些基础组件JVM 则是 JRE 里真正加载字节码、分配内存、执行指令的那个引擎。所以面试题问“JDK 和 JRE 有什么区别”不要只背结论。编译发生在 JDK运行依赖 JRE但 JVM 是真正干活的角色。当前主流的 JVM 实现是基于同一套开源代码构建的多个发行版它们的核心功能大同小异但参数默认值、许可证和补充工具会有差异。实际项目里选定一个发行版后最好把版本固定下来因为很多 JVM 参数在不同版本下的行为并不完全一致排查问题时第一步永远应该是确认你跑在哪个版本上。1.3 上篇划定的范围以及为什么先讲这里“Java 技术体系全貌”是个大题目。如果拿一栋楼打比方JVM 是地基核心类库是承重墙框架和业务代码是装修。上篇只做地基部分的四件事运行时数据区、类加载机制、字节码指令、对象生命周期。下篇再补上垃圾收集、内存分配策略和 JIT 编译。这样拆分不是因为 GC 不重要而是因为很多问题都是连锁反应不懂运行时数据区就不知道元空间为什么溢出不懂类加载机制就解释不了ClassCastException为什么时有时无不懂字节码就只能把动态代理和 AOP 当成“魔法”。先把地基打牢后面看 GC 日志和调优参数会顺很多。2. JVM运行时数据区JVM所有动作的舞台2.1 程序计数器线程私有的“下一条指令地址”程序计数器是运行时数据区里最小的一块区域也是唯一规定不会抛出OutOfMemoryError的地方。它的作用可以简单理解成书签解释器每执行一条字节码指令都需要知道下一条要执行哪一条线程被切换出去再切回来也要能从上一次的位置继续执行。所以每个线程都维护自己独立的程序计数器。有两个细节值得注意第一如果线程正在执行的是 native 方法程序计数器的值是 undefined因为原生方法不通过字节码解释执行不再需要记录“字节码行号”第二你在 Java 代码里无法直接操作程序计数器它的变化都由 JVM 内部管理。虽然写业务代码时几乎感知不到它但理解线程切换和异常栈信息时它是个重要背景。2.2 虚拟机栈一个方法一个栈帧虚拟机栈描述的是 Java 方法执行的内存模型每个方法被调用时JVM 会同步创建一个栈帧方法执行完就弹出。栈帧内部主要包含局部变量表、操作数栈、动态链接、方法返回地址。局部变量表以 slot 为基本单位int、short、byte、boolean、float、reference都占一个 slotlong和double占两个 slot。操作数栈是字节码指令的工作台比如计算a b会先把a和b压入操作数栈再用iadd指令弹出两个值、计算、把结果压回栈顶。这样设计的好处是一个方法需要多大的操作数栈、多少个局部变量 slot在编译期就能确定并写入 class 文件。所以线程栈深度超过虚拟机能承受的极限时会抛出StackOverflowError如果栈支持动态扩展但内存不足才会抛出OutOfMemoryError。实际排障中我们遇到的大部分是无限递归导致的栈溢出解决方案不是调大栈容量而是检查递归逻辑和栈帧大小。2.3 本地方法栈给native方法预留的座位本地方法栈与虚拟机栈职责相似区别在于它为 native 方法服务。主流 JVM 实现里虚拟机栈和本地方法栈往往是合二为一的JNI 调用本地方法时也会占用线程栈空间。Java 代码里Object.hashCode()、System.nanoTime()这类方法没有 Java 实现背后就是 native 方法在支撑。看线程转储时如果发现栈顶夹杂着一些看起来不像 Java 方法的帧很可能就是本地方法栈在执行 JNI 调用。排查内存问题时native 栈同样是潜在增长点尤其是用到了图片处理、加密算法等本地库时要留意本地方法导致的内存占用这类问题在堆 dump 里往往看不到只能结合操作系统层面的监控判断。2.4 堆与方法区绝大多数对象和类型的栖身地堆和线程栈是两种完全不同的内存气质。堆是线程共享的重点是生命周期管理绝大多数对象实例和数组都在这里分配方法区同样线程共享但存放的是类型信息、常量、静态变量、JIT 编译后的代码缓存等类级数据。物理上堆和方法区都可以不连续但逻辑上是连续的。分代收集器把堆划分为新生代和老年代这是分代 GC 的产物不是 JVM 规范的强制要求。方法区在 JDK8 之后改由元空间承载元空间直接使用本地内存默认上限由系统可用内存决定所以“永久代不够用”的经典问题大幅减少。这里还有一个极易混淆的点字符串常量池从 JDK7 开始被移到了堆中因此回答“字符串常量池在哪”必须加一句“看你运行的版本”。2.5 运行时常量池与字符串常量池别搞混.class文件里有一张常量池表记录类名、方法名、字段名、字面量、符号引用等内容。这个常量池在类加载后被搬运进方法区并成为运行时常量池。既然是“运行时”的它就可以动态增长String.intern()就是典型的动态加入方式。字符串常量池是运行时常量池中对 String 字面量和intern()字符串专门管理的子集。写代码时String a abc的字面量在类加载后进入字符串常量池new String(abc)则会额外在堆上创建一个新对象。所以用比较两个内容相同的字符串时一个来自字面量、一个来自new结果往往是false。背下“字符串要用 equals 比较”很容易但不理解常量池和堆分配的区别下次换个写法还是会掉坑。3. 类加载机制从.class字节流到内存中的Class对象3.1 类加载的五个阶段到底做了什么一个类从.class文件变成 JVM 中可以直接使用的Class对象需要经历加载、验证、准备、解析、初始化五个阶段。注意五个阶段并不严格串行解析也可能被延迟到字节码指令真正引用某个符号时才发生。加载阶段通过类全限定名获取二进制字节流再把它转换成方法区里的运行时数据结构最后生成一个Class对象。验证阶段检查字节流格式、元数据、字节码语义防止恶意或损坏的字节码混进来。准备阶段为static变量分配内存并设置零值注意此时还不是程序员写的初始值。解析阶段把符号引用替换为直接引用比如把某个方法名转成实际内存地址的引用。初始化阶段才执行clinit方法把静态变量赋值成代码里写的初始值并且先初始化父类。所以static int value 10在准备阶段是0初始化阶段才变成10。这个顺序经常被面试官拿来考察基本功也是理解“类初始化失败会导致什么”的起点。3.2 双亲委派模型自下而上委托自上而下加载类加载器不是只有应用类加载器一个。主流 JVM 默认有三个层次启动类加载器加载核心类库平台类加载器加载模块化的扩展部分应用类加载器负责classpath下的业务类。双亲委派模型英文叫 parents delegation但它并不是“从父到子找类”而是子加载器收到加载请求后先把这个请求向上抛给父加载器父加载器找不到再自己尝试。这样做最重要的意义是保证核心类只被启动类加载器加载。假如业务代码里自己写了一个名为java.lang.String的类通过双亲委派启动类加载器已经加载过标准 String应用类加载器就不会再加载自定义版本核心 API 不容易被篡改。这里有一个容易忽略的细节JVM 判断两个类是否相同用的是“类加载器实例 类全限定名”双重标识。同一个.class文件被两个不同的类加载器分别加载在 JVM 里就是两个不同的类这也是后面很多类型强转异常的根源。3.3 打破双亲委派SPI、热部署和容器隔离双亲委派是推荐的默认策略但不是不能打破。拿 JDK 的 ServiceLoader 机制举例核心接口类由启动类加载器加载实现类却可能在应用classpath下。如果严格执行双亲委派父加载器看不到子加载器能加载的类就出现了“反向找不到实现”的问题。于是 JDK 引入线程上下文类加载器允许核心代码在需要时调用应用类加载器去找实现。业务容器里的线程上下文类加载器则是另一个方向为了隔离不同应用的同名类库容器会实现自己的类加载器先加载应用目录下的类再委托父加载器。热部署也依赖打破双亲委派每次部署重新创建一个类加载器把新版本的类加载进来旧类加载器连同旧类变成可回收对象。打破双亲委派是有代价的最常见的副作用是同一个接口被多个加载器重复加载导致instanceof结果不符合预期。所以在设计自定义类加载器时一定想清楚哪些依赖要共享、哪些必须隔离。4. 字节码技术虚拟机能看懂的语言4.1 javac之后发生了什么看一个最简单的加法方法public class Simple { public int add(int a, int b) { return a b; } }编译后用javap -c Simple查看方法字节码public int add(int, int); Code: 0: iload_1 1: iload_2 2: iadd 3: ireturniload_1和iload_2分别把局部变量表中下标 1、2 的两个参数压入操作数栈iadd弹出两个 int 并相加压回ireturn把结果返回。这里没有物理 CPU 上的通用寄存器一切计算依赖操作数栈。Java 编译器在编译阶段就能算出某个方法最深的操作数栈有多深这个信息会被写进 class 文件供字节码校验器使用。4.2 字节码的指令族与方法调用指令JVM 指令集非常规整前缀字母直接表示数据类型i代表 intl代表 longf代表 floatd代表 doublea代表 reference。于是你会看到iload、istore、fadd、dreturn、areturn这一族指令。虽然指令总数超过 200 条但很多操作只是类型前缀不同拆成组记忆并不难。方法调用指令是另一个重点。invokestatic调用静态方法invokevirtual用于虚方法分派也就是常见的多态调用invokespecial调用构造方法、private 方法和当前类的父类方法invokeinterface调接口方法invokedynamic是 JDK7 引入的动态调用机制现代 Java 里 lambda 表达式最终会翻译成invokedynamic。分支指令也值得留意tableswitch适合 case 值连续的switch可以直接索引跳转lookupswitch适合稀疏 case通过匹配查找跳转。看清这些之后再看反编译出来的字节码就不会一头雾水。4.3 看字节码的工具链最底的工具是javap-c反汇编方法体-verbose显示 class 文件完整结构、常量池、行号表和各类属性。把.class文件用十六进制编辑器打开开头固定是CAFEBABE四个字节这是 JVM 识别 class 文件的魔数也是很多安全扫描工具判断文件类型的关键标记。如果想在运行时修改字节码工具就分层了。ASM 是底层访问者框架通过visitMethod、visitInsn这类事件回调来生成和修改类性能好但学习曲线陡。Javassist 允许直接用 Java 源码字符串拼接方法体上手快适合快速做类增强但灵活性和性能不如 ASM。Byte Buddy 则在更高层提供 DSL很多 Mock 框架和链路追踪组件都依赖它。我自己的使用习惯是研究机制用javap快速验证想法用 Javassist做真正的框架级增强再切到 ASM 或 Byte Buddy。4.4 字节码增强的典型应用动态代理是字节码增强但大家感知不到。运行时生成一个代理类的字节码再用类加载器加载外部拿到一个看起来是接口实现的对象实际上 JVM 里多了一个全新的类。AOP 的织入也离不开字节码Spring AOP 默认用动态代理而 AspectJ 风格则可以在类加载期修改目标类字节码把通知逻辑直接织进方法体。热部署工具也用了同样的思路创建新类加载器重新加载新版本字节码实现类替换。字节码增强最忌讳“只改不看”。常见问题包括字节码版本不匹配、StackMapTable 校验失败、修改过的方法在低版本 JVM 上不兼容。我建议先在一个小工程里用 ASM 生成一个最简单的类打印它的字节码和运行结果再去碰框架源码否则很容易在复杂代码里迷失方向。5. 对象在JVM中的一生创建、布局与定位5.1 new一个对象的完整过程Java 里写一个new关键字背后并不是简单的内存分配。对象创建可以细分成几步先做类加载检查确认这个类已经完成加载、解析和初始化然后为对象分配内存分配方式要么是指针碰撞要么是空闲列表具体取决于堆是否规整接着把分配到的内存初始化为零值这样字段即使在构造方法里没赋值默认也是0或null随后设置对象头写入哈希码、GC 分代年龄、锁状态等元数据最后执行构造函数完成程序员定义的初始化。分配内存时的线程安全问题也在这里体现。多线程同时new对象必须保证分配指针同步。主流实现采用 CAS 加失败重试或者用 TLAB——每个线程先在堆内占用一块私有区域在自己的 TLAB 里分配减少竞争。理解了这个流程再去读-XX:TLABSize这类参数就有据可依了。5.2 对象头与对象布局一个 Java 对象在内存里通常由三部分组成对象头、实例数据、对齐填充。对象头里的 Mark Word 很有设计感它用一块空间在不同状态下表达不同含义无锁状态下可以存放哈希码和分代年龄偏向锁状态下存放持有锁的线程 ID锁升级后又会存放指向锁记录的指针。也就是说JVM 没有为每种锁状态单独开一块内存而是复用同一个 word这正好体现了 JVM 对内存利用率的偏执。实例数据保存真实的字段值排列顺序受字段声明顺序和 JVM 对齐策略影响。对齐填充则是为了满足 8 字节对齐要求让对象大小成为 8 的倍数方便内存指针计算。想直观看到这些内容可以借助 JOLJava Object Layout工具。引入依赖后调用ClassLayout.parseInstance(obj).toPrintable()就能打印出对象头每个 bit 的偏移、字段位置和整体大小。实测时看一次输出比背十遍概念更有用。5.3 栈上分配与TLAB对象分配的两种优化“对象都分配在堆上”这句话在主流 JVM 实现里并不绝对。开启逃逸分析后JIT 编译器会判断对象引用是否逃出了当前方法。如果对象不逃逸编译器可以把它打散成独立的基本类型字段完全在栈上完成分配。举个例子private long sum() { Point p new Point(1, 2); return p.x p.y; }p既没有作为返回值也没有放进全局集合它就可以通过标量替换变成两个局部变量方法执行完直接消失连 GC 都不需要管它。这是 JIT 优化里非常实用的一个策略。TLAB 则是在堆内做文章每个线程预申请一小块私有区域new对象时优先在自己的 TLAB 中分配不够了再向堆申请下一块。这样多线程创建对象时不用一直争抢同一个分配指针吞吐量会稳定很多。6. 常见问题与排查技巧初学JVM时踩过的坑6.1 ClassNotFoundException和NoClassDefFoundError别当作一回事这两个异常长得像但性质完全不同。ClassNotFoundException是受检异常通常发生在类加载过程中找不到指定名称的类典型场景是Class.forName加载动态类失败或者classpath漏了依赖。NoClassDefFoundError是Error说明某个类之前成功加载过现在却无法加载了常见原因是静态初始化块抛了异常导致后续引用这个类时直接失败。排查时先确认-cp是否覆盖了目标类再看是否有版本冲突。依赖树冲突比物理缺失更隐蔽同名类可能被不同的传递依赖带来多个版本所以打印依赖树往往是第一步而不是肉眼翻 lib 目录。另一个好用的小技巧是抓线程 dump看当前加载请求到底发生在哪个类加载器里很多奇怪问题最后都会落到自定义类加载器身上。6.2 双亲委派被破坏后的类重复加载很多人在自研插件系统或动态部署工具时会自定义类加载器。一旦没有稳妥遵循双亲委派就容易出现同一个接口被多个类加载器各加载一次。结果外部明明看着类型是对的一执行instanceof就返回false强转就抛ClassCastException。排查这种问题先看完整异常栈里的加载器信息再确认哪些包被多个加载器加载了。常见的修法是公共依赖都交给父加载器业务扩展类才由子加载器管理让隔离边界足够清晰。我在做多应用共享平台时遇到过很多次类似的“幽灵类”问题最后都发现不是代码逻辑问题而是加载器边界没有设好。6.3 元空间溢出与反射滥用运行期动态生成大量类但缓存和回收没跟上元空间就会持续膨胀最终抛出OutOfMemoryError: Metaspace。典型背景包括反射生成大量代理类、CGLIB 生成过多子类、脚本引擎反复编译同一个脚本。排查时先给元空间设一个合理的上限比如-XX:MaxMetaspaceSize512m让问题更容易暴露再用jcmd pid VM.metaspace观察使用曲线。如果确认是持续增长下一步要查到底谁在创建类、类加载器是否被静态引用导致无法回收。这往往和类加载器泄漏绑定在一起。不要一上来就扩大元空间上限那只会推迟问题而不是解决问题。6.4 快速查看运行时数据区的简单姿势一般遇到眼前一黑的 JVM 问题我会先执行一组命令jcmd pid Thread.print看线程栈jcmd pid GC.heap_info看堆使用情况jcmd pid VM.metaspace看元空间jcmd pid Compiler.codecache看 JIT 代码缓存。如果觉得信息不够直观再用jhsdb hsdb pid打开底层调试接口。还有一个小习惯排查前先跑java -version确认当前 JVM 版本。JDK 版本之间参数差异不小方法区从永久代改成元空间、字符串常量池从方法区挪到堆、类加载器层级调整都是版本变化带来的。拿老规则套新版本往往浪费大量时间在错误方向上。

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

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

免费获取报价 →
↑