聊 JVM 模型之前,先把话说在前面:不管是面试被问的 JVM 内存模型、JVM 原理,还是日常调优时见到的各种 JVM 参数,本质上都是在回答同一个问题——Java 程序跑起来以后,内存在哪里分配、多线程之间怎么协作、哪些参数能影响运行行为。JVM 模型不是一个抽象到只能背八股的概念,而是一套可以拿来做排查、做性能优化、做架构设计参考的“运行地图”。这篇东西适合两种人看:一种是刚接触 JVM、想搞懂 JDK、JRE、JVM 到底是什么的初学者;另一种是已经写过 Java 项目,但遇到内存溢出、频繁 Full GC、线程卡死时不知道怎么下手的开发者。先记住一个最简单的结论:JDK 是开发工具箱,JRE 是运行环境,JVM 才是真正执行字节码的虚拟机。把这三者的关系画清楚,再去看 JVM 模型,就不会偏。
1. JVM “模型”到底是什么
1.1 JVM 不是黑盒,是一台“软件机器”
第一次学 Java,很多人背过一句话:Java 是“一处编译,到处运行”。这句话的实现者就是 JVM。JVM 本质上是一台用软件模拟出来的机器,它负责读取 .class 文件里的字节码,再把这些字节码翻译成当前操作系统和 CPU 能执行的机器指令。它有自己的指令集、自己的寄存器(实际是内存里的局部变量表)、自己的栈,甚至有自己的内存管理规则。
所以我理解 JVM 模型,从来不是一张静态图,而是三张图叠在一起:
- 第一张是运行时内存布局图,回答“JVM 启动后内存分成了哪几块,各管什么”;
- 第二张是 Java 内存模型(JMM),回答“多个线程并发读写共享变量时,行为到底怎么约束”;
- 第三张是类加载和即时编译的工作模型,回答“字节码如何变成机器码,方法调用多少次会被编译”。
这三张图拼起来,就是大多数人嘴里说的“JVM 模型”。不管是排查内存溢出,还是面试回答“请说说 JVM 内存模型”,真正考察的都是这三张图有没有建立起来。
1.2 Java 内存模型和 JVM 运行时内存模型不是一回事
这一点特别容易混,我见过很多面试者开开心心背完了堆、栈、方法区,结果面试官一问 volatile 的可见性,就把堆和栈往里套,最后答串了。
严格来说:
- JMM(Java Memory Model)是 Java 语言规范层面的抽象模型,解决的是并发场景下“线程A改了共享变量,线程B多久能看见”的问题。它定义主内存、工作内存、原子性、可见性、有序性、happens-before 规则等。它和 CPU 缓存、寄存器、内存屏障有关系,但不直接对应 JVM 里的堆和栈。
- JVM 运行时内存模型,是 HotSpot 等虚拟机实现层面的东西,描述的是内存区域划分,比如程序计数器、虚拟机栈、本地方法栈、堆、方法区/元空间。
如果做一个类比:JMM 更像“交通规则”,规定车怎么走、谁该让谁;运行时内存模型更像“城市路网”,规划哪里是商场、哪里是住宅。规则和路网有关系,但不能混为一谈。
1.3 不同 JVM 实现之间的模型差异
平时默认讨论的 JVM 基本都是 HotSpot,但 JVM 本身只是一份规范,官方和社区有不同实现,比如 OpenJ9、GraalVM Native Image 等。它们的运行时内存布局可能不完全一样。特别是 Java 8 之后,HotSpot 把原来的永久代改成了元空间,而且行为上有个很关键的差异:永久代是 JVM 堆内存的一部分,元空间却用了本地内存。这个差异直接决定了你还要不要担心方法区内存溢出的问题。
后面所有内容,我默认以 HotSpot 为基准展开。如果是做企业级应用,十有八九也是用的它。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 运行时数据区:一块内存一个坑
2.1 堆、栈、元空间:必须先记牢的三大区域
网上常说的 “JVM 的三大区域” 指堆、虚拟机栈、方法区。Java 8 之后方法区被实现为元空间,所以更准确的说法是:堆、虚拟机栈、元空间。
不过在 JVM 规范层面,运行时数据区其实有五个:
| 区域 | 是否线程共享 | 主要作用 | 常见异常 |
|---|---|---|---|
| 程序计数器 | 线程私有 | 记录当前线程执行到哪一条字节码指令 | 无 |
| 虚拟机栈 | 线程私有 | 方法调用和执行的栈帧结构 | StackOverflowError、OutOfMemoryError |
| 本地方法栈 | 线程私有 | 执行 native 方法时的栈 | StackOverflowError、OutOfMemoryError |
| 堆 | 线程共享 | 存放对象实例,绝大多数对象在这分配 | OutOfMemoryError: Java heap space |
| 方法区/元空间 | 线程共享 | 存放类信息、常量、静态变量、JIT 代码缓存等 | OutOfMemoryError: Metaspace |
这里有个很实际的意义:拿到一个 JVM 内存错误,先看异常里写的是 heap space 还是 Metaspace,基本就能定位出是堆里的对象太多,还是加载的类太多。天天只背“堆栈”两个字,遇事根本没法排查。
2.2 虚拟机栈的栈帧里装了什么
每次一个线程调用方法,JVM 就会在虚拟机栈中压入一个栈帧。一个栈帧由四块区域组成:
- 局部变量表:存放方法参数和方法内定义的局部变量。注意,它存的是基本类型值和对象引用,不是对象本身。对象还在堆里。
- 操作数栈:JVM 执行字节码指令时用来运算的临时工作区。比如执行
a + b,会把 a 和 b 压入操作数栈,相加后结果再放回去。操作数栈深度在编译期就确定好了。 - 动态链接:指向运行时常量池中该方法的引用,用来解决符号引用到实际引用的问题。
- 方法出口:方法正常返回或异常返回时的恢复信息。
理解这个结构,对调参数很有用。-Xss 控制的就是每个线程栈的大小。如果递归层数太深,栈会被打爆,抛 StackOverflowError;如果线程数多且每个线程栈过大,可能导致无法创建新线程。
2.3 堆内存的分代模型与对象“一生”
大多数教材会把堆分成新生代和老年代,新生代里又分 Eden 区和两个 Survivor 区。新对象先在 Eden 分配,Minor GC 之后幸存的对象进入 Survivor,反复熬过几次 GC 后晋升老年代。
这个分代模型是整条 GC 理论的地基,但要注意,G1 收集器出现后,堆不再是物理上连续的“三块”,而是被划分成很多大小相等的 Region,由 Region 动态扮演 Eden、Survivor、Old 的角色。G1 靠维护一个记忆集来跟踪跨 Region 引用,所以它能做到“专注于垃圾对象最多的 Region,优先回收”,这就是它的名字 Garbage First 的含义。
堆还有一个容易被忽略的概念:TLAB(Thread Local Allocation Buffer)。每个线程在 Eden 里有一块独立的分配缓冲,分配对象时先在自己这块缓冲区里分配,避免竞争同一块内存的锁开销。理解 TLAB 之后再去看“对象分配一定是线程安全的吗”这个问题,思路就会更开阔。
3. 对象模型:JVM 眼中的 Object
3.1 Object Header 和压缩指针
“对象在堆里”是一句正确的废话。真要研究 JVM 模型,还得关注对象在内存里具体长什么样。一个 Java 对象在 HotSpot 里由三部分组成:
- 对象头(Object Header):存放 Mark Word、指向 Class 的元数据指针,数组对象还有数组长度字段;
- 实例数据:实际字段值;
- 对齐填充:让对象大小成为 8 的倍数,方便 CPU 读取。
Mark Word 不是一个简单的标记位,它会在运行期不断变化。对象没被锁时,记录 hashCode;线程抢锁时,记录锁状态;GC 时,记录年龄;偏向锁启用时,它又记录持有锁的线程 ID。所以 Mark Word 实际上是一个复用率极高的小标签栏。
再说压缩指针。64 位 JVM 中,如果直接用 64 位存对象引用,内存浪费很严重。开启 -XX:+UseCompressedOops 后,引用可以用 32 位来压缩,堆上限约 32GB。大多数服务堆不太会超过这个数,所以默认开启就是最合理的配置,不需要为“节省”去动它。
3.2 对象创建与访问定位
通常 new 一个对象,JVM 会做四步:
- 检查类是否已经被加载、解析、初始化,如果没有就触发类加载;
- 在堆上分配内存,分配方式有“指针碰撞”和“空闲列表”两种,取决于堆是否规整;
- 将分配给对象的字段默认初始化为零值;
- 设置对象头,然后调用构造函数。
访问对象时,有两种定位方式:句柄访问和直接指针访问。句柄访问是维护一个句柄池,引用先指向句柄,再由句柄指向对象实例;直接指针访问就是引用直接指向对象。HotSpot 默认使用直接指针访问,原因很简单:少一次间接寻址,速度更快。代价是对象移动时需要更新引用,但这在 GC 场景下已经被优化得很成熟。
4. Java 内存模型:并发语义的“协议”
4.1 为什么并发问题离不开 JMM
单个线程的代码,执行顺序基本是确定的。但多线程同时操作同一个变量时,硬件层有 CPU 缓存,编译器层有指令重排序,JVM 层还有各种优化,如果不约定规则,程序的运行结果会变得完全不可预测。
JMM 要解决的就是三件事:
- 原子性:类似“i++ 不是原子操作”这个问题,答案在 JMM 对内存操作的约束里;
- 可见性:一个线程修改了变量,另一个线程什么时候能看到;
- 有序性:指令重排序到什么程度是允许的,什么情况下不允许。
volatile 就是 JMM 里最有代表性的一个关键词。它保证了两点:写 volatile 变量会立刻刷新到主内存,读 volatile 变量会从主内存重新读取;同时它通过内存屏障阻止了指令重排序,所以经典的单例双重检查锁里必须用 volatile 来保证实例发布的完整性。
4.2 主内存和工作内存的关系
JMM 规定,所有变量都保存在主内存中,但每条线程还有自己的工作内存。线程对变量的所有操作,都必须在工作内存中完成,不能直接读写主内存。这和 JVM 运行时数据区不是一一对应的,JMM 的工作内存可以理解为是寄存器、一级缓存、二级缓存、写缓冲区的抽象集合。
为什么会有这套抽象?因为 JVM 规范不能绑定具体 CPU 硬件,只能用一套“抽象出的内存模型”来定义规则。真正跑在 x86 上时,会有更细的锁、原子指令、内存屏障参与配合。
4.3 happens-before 规则
JMM 用 happens-before 关系来约束内存可见性。只要操作 A happens-before 操作 B,那么 A 的写操作对 B 是可见的。常见规则:
- 程序顺序规则:同一个线程里,前面的操作 happens-before 后面的操作;
- 监视器锁规则:解锁操作 happens-before 后续对同一把锁的加锁;
- volatile 变量规则:写 volatile happens-before 后续对这个 volatile 变量的读;
- 传递性:A happens-before B,B happens-before C,则 A happens-before C。
面试和排查问题经常要用到这套规则。比如线程池里提交任务后,任务里读到的数据会不会是主线程刚更新的,看起来像是“玄学”,实际上用 happens-before 一推就能说清楚。
5. 类加载模型与 JIT 编译模型
5.1 类加载不是“读个 class 文件”那么简单
JVM 模型里还有一个绕不开的内容:类加载机制。一个类从被加载到被使用,要经历加载、验证、准备、解析、初始化这几个阶段。
- 加载:把字节码读进内存,生成 Class 对象;
- 验证:检查字节码安全性和规范合法性;
- 准备:为静态变量分配内存,并设置默认零值;
- 解析:把常量池里的符号引用替换为直接引用;
- 初始化:执行静态变量赋值和静态代码块。
类加载器采用双亲委派模型:一个类加载器收到加载请求后,先委派给父加载器,父加载器处理不了才自己加载。这样做最大价值是让核心类(比如 java.lang.String)都由引导类加载器加载,避免用户代码定义同名类造成混乱。
5.2 JIT 编译和 -XX:CompileThreshold
JVM 并不总是解释执行字节码。一个方法被反复调用时,JVM 会把它编译成机器码缓存起来,这就是 JIT(Just In Time)编译。HotSpot 里常见的有 C1 客户端编译器、C2 服务端编译器,现在默认开启分层编译,先用 C1 快速编译,等到方法真正热点后再用 C2 深度优化。
-XX:CompileThreshold 控制的就是“一个方法被调用多少次之后,触发编译”。这个参数在 HotSpot 的 Server 编译模式下,默认值通常是 10000 次,但真实环境可能受分层编译影响,你可以用命令查看:
bash复制java -XX:+PrintFlagsFinal -version | grep -i compilethreshold
实际操作中我不建议盲目把阈值调得很低。阈值太低,方法还没收集到足够的运行监控信息就被编译,优化质量反而差;阈值太高,热点方法一直在解释执行,CPU 和吞吐都会受影响。如果要调,建议先压测,再配合 -XX:TieredCompilation 的分层阈值一起看。
6. JVM 参数、调优与常见问题记录
6.1 调优常用的 JVM 参数一览
JVM 参数并不是越多越好,实际生产环境里最常用的就那么几十个。我把最值得记的整理成了一张表:
| 参数 | 作用 | 使用建议 |
|---|---|---|
-Xms |
初始堆大小 | 和 -Xmx 设成一样,避免扩容抖动 |
-Xmx |
最大堆大小 | 根据机器内存和业务留足余量,不要顶满物理内存 |
-Xss |
每个线程栈大小 | 一般 512k 到 1m,太大容易导致线程数过多时内存爆掉 |
-XX:MetaspaceSize |
元空间初始阈值 | 避免首次加载大量类时扩容频繁 |
-XX:MaxMetaspaceSize |
元空间上限 | 防止失控场景下把本机内存耗尽 |
-XX:+HeapDumpOnOutOfMemoryError |
OOM 时自动导出堆快照 | 建议必开,配合 -XX:HeapDumpPath 指定目录 |
-XX:+PrintGCDetails |
打印 GC 日志 | JDK9 后建议用 -Xlog:gc* |
-XX:MaxGCPauseMillis |
G1 期望最大 GC 停顿时间 | 设太短会增加 GC 频率,需要压测平衡 |
启动参数可以参考类似这样:
bash复制java -Xms4g -Xmx4g -Xss512k \
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/jvm/heap.hprof \
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 \
-jar your-app.jar
这里面我最想强调的一点:堆大小不是越大越好。堆太大,GC 单次扫描时间长;堆太小,频繁 Full GC。真实调优时一定要结合业务对象的存活时间、访问频率来判断,而不是看到“内存多”就往上加。
6.2 G1 收集器实用调优思路
G1 是 JDK 9 之后的默认收集器,常见调优关注点不是“新生代多大、老年代多大”,而是:
- Region 大小:默认根据堆自动算。堆较小可以
-XX:G1HeapRegionSize=1m或 2m,避免 Region 过多; - 预期停顿:
-XX:MaxGCPauseMillis是目标,不是硬性保证,G1 会尽量逼近; - 混合回收周期:G1 通过 Young GC 和 Mixed GC 配合,逐步回收老年代垃圾。如果感觉老年代回收不及时,可以调
-XX:G1MixedGCCountTarget和-XX:G1HeapWastePercent。
G1 比较适合内存较大、希望停顿可控的业务。但如果你的服务内存只有 2-3G,业务对象基本都是临时对象,CMS 甚至 Serial GC 未必不行。收集器选择要配合场景看,不能只跟风。
6.3 我踩过的几个 JVM 排查坑
这里必须说说我实际遇到过的问题,每个都能对应一句热词。
第一类是“no JVM could be found on your system”或“no JVM installation found”。以前装工具或者跑脚本时经常碰到,原因大部分不是 JVM 坏了,而是 JAVA_HOME 没设置,或者 PATH 里指向的 java 版本不对。处理办法是先检查:
bash复制echo $JAVA_HOME
java -version
如果 java 能跑但工具还是找不到,多半是工具脚本用了自己的变量,需要在配置文件里单独指定 JAVA_HOME。
第二类是运行 JVM 相关插件或工具时提示“could not get JVM parameters and dynamic configurations properly”。这种大多出现在监控类工具或者 IDE 服务端连接目标 JVM 时,常见原因是目标进程不是用 Sun/Oracle JVM 启动,或者跨版本不兼容。排查看两点:一是确认目标进程还是不是本机 Java 进程,二是用 jinfo -flags <pid> 手动读取参数,和工具报错内容对比。不要直接重装工具,多半没用。
第三类是容器里部署 Java 程序,程序异常重启了但不知道日志去哪里。容器环境不像物理机那样有固定的 catalina.out,默认标准输出会被 Docker 接管。先看:
bash复制docker logs --tail 200 <container_name>
如果容器已经退出,说明日志仍然在 stdout,用 docker logs 还可以拉。但如果是 JVM 内部错误,比如无法加载共享库、堆栈信息异常,建议在启动命令里加:
bash复制-XX:ErrorFile=/data/logs/jvm/hs_err_pid_%p.log
这样 native crash 的 hs_err_pid*.log 就有固定落盘位置,比从容器文件系统里翻找靠谱得多。
第四类是 Tomcat 7 部署的老项目,CPU 飙高,线程卡死。这种别急着改业务代码,先用 jstack <pid> 把线程快照导出来,连续导三四份,对比哪些线程一直在 RUNNABLE 或 BLOCKED。Tomcat 7 默认线程池参数比较保守,如果请求量突然上来,线程争抢会很明显。但“线程太多”和“线程阻塞”是两码事,必须先用日志和数据区分,再决定调 maxThreads 还是查数据库慢 SQL。
6.4 最后留一个实操习惯
我个人经验是:任何 JVM 问题,第一时间不是看业务代码,而是先做三件事——拿到进程 PID,确认启动参数,导出 GC 日志。用 jcmd <pid> VM.flags 可以直接看到进程当前生效的 JVM 参数,不用去猜。用 jstat -gc <pid> 1000 可以看每秒 GC 情况。只有先把 JVM 这层事实摆清楚,后面的分析才不会是瞎猜。JVM 模型再复杂,落到实际操作里,无非就是搞清楚内存、线程、类加载、JIT 这几条线,再配合日志一口一口啃下来。
