当你在黑马点评上下单一份黄焖鸡米饭时,JVM在背后做了什么? —— 一篇用外卖场景串讲JVM核心原理的深度长文
阅读时间:约50分钟 | 涵盖:内存模型 / 类加载 / GC算法 / JIT编译 / 性能调优 / OOM排查
📑 目录
🎬 序章:一份外卖订单的JVM之旅

假设你正在学习Java开发,跟着黑马程序员的教程做了一个"黑马点评"项目——一个类似大众点评的本地生活服务平台。你用Spring Boot搭建后端,Redis做缓存,MySQL存数据,跑在JVM上。
现在,用户打开App,搜索"黄焖鸡米饭",点击一家店,下单,支付。
这一个看似简单的操作,在JVM内部引发了一场"风暴":
今天,我们就化身成一个Java对象,在JVM的世界里来一次"深度游"。 你会发现,JVM不是一个抽象的概念,而是一个精密运转的"餐厅后厨"——有采购(内存分配)、有厨师(执行引擎)、有保洁(垃圾回收)、有菜谱管理(类加载)。
为什么学JVM?
你可能觉得:“我写业务代码,了解JVM有什么用?”
用处大了。当你的黑马点评项目遇到这些问题时,不懂JVM你只能干瞪眼:
- 应用运行越来越慢 → 可能是GC频繁(内存泄漏)
- 启动时报 OutOfMemoryError → 堆/栈/元空间不够
- 某个接口偶尔超时 → 可能是Full GC的Stop-The-World
- 线上CPU飙高 → 可能是死循环或频繁GC
- 类加载冲突 → 不同ClassLoader加载了同名类
不懂JVM的Java程序员,就像不懂发动机原理的司机——平时没事,出了故障就抓瞎。
好,让我们开始这场JVM之旅!
🏗️ 第一幕:JVM内存模型 — 餐厅的"后厨布局"

1.1 先搞清楚:JDK、JRE、JVM的关系
很多初学者分不清这三个概念,用餐厅来类比:
| JDK (Java Development Kit) | 整个餐厅(含厨房+办公区) | 开发+运行的完整工具包,包含javac编译器、jdb调试器、jconsole监控工具等 |
| JRE (Java Runtime Environment) | 只有厨房 | 运行Java程序的最小环境,包含JVM + 核心类库(java.lang, java.util等) |
| JVM (Java Virtual Machine) | 厨房里的"烹饪系统" | 执行字节码的虚拟机,是JRE的核心组件 |
📌 JDK 11+的变化: 从JDK 11开始,Oracle不再单独提供JRE下载。JDK本身就包含了运行时环境。这是因为模块化(Project Jigsaw)后,可以用 jlink 工具自定义裁剪出最小运行时。
1.2 JVM运行时数据区全景
JVM的内存分为两大类:线程私有(每个线程独享一份)和线程共享(所有线程共用)。
表1:JVM运行时数据区分类
| 程序计数器 (PC Register) | 私有 | 当前执行的字节码指令地址 | 无(唯一不会OOM的区域) | 无参数 |
| 虚拟机栈 (VM Stack) | 私有 | 栈帧(局部变量表、操作数栈、动态链接、返回地址) | StackOverflowError / OOM | -Xss256k |
| 本地方法栈 (Native Stack) | 私有 | Native方法的调用栈 | StackOverflowError / OOM | -Xoss(通常不设置) |
| 堆 (Heap) | 共享 | 对象实例、数组 | OOM: Java heap space | -Xms512m -Xmx512m |
| 方法区 (Method Area) | 共享 | 类元数据、常量池、静态变量、JIT代码缓存 | OOM: Metaspace | -XX:MetaspaceSize=256m |
1.3 虚拟机栈详解 — “每道菜的制作过程”
虚拟机栈是JVM中与方法调用最密切相关的区域。每当一个方法被调用,JVM就创建一个**栈帧(Stack Frame)**压入栈中;方法执行完毕,栈帧弹出销毁。
栈帧包含四个核心组件:
① 局部变量表(Local Variable Table)
存储方法的参数和局部变量。对于实例方法,索引0存的是 this 引用。
以黑马点评的 BlogController.getBlogById(Long id) 为例:
局部变量表:
[0] this → BlogController实例的引用
[1] id → Long类型的参数(实际是long基本类型+自动装箱)
基本类型(int, long, float, double, byte, char, short, boolean)直接存值,引用类型存对象在堆中的地址。
② 操作数栈(Operand Stack)
方法执行时的"工作台"。字节码指令在这里进行计算。比如执行 a + b:
iload_1 // 把局部变量1(a)压入操作数栈
iload_2 // 把局部变量2(b)压入操作数栈
iadd // 弹出栈顶两个值,相加,结果压回栈顶
istore_3 // 弹出栈顶值,存入局部变量3(c)
③ 动态链接(Dynamic Linking)
指向运行时常量池中该栈帧所属方法的引用。在Java中,方法调用分为:
- 静态解析:编译期就能确定调用哪个方法(private方法、static方法、final方法)
- 动态绑定:运行时才能确定调用哪个方法(多态,比如 Animal.speak() 实际调用的是 Dog.speak())
④ 返回地址(Return Address)
方法正常退出时,返回到调用方的下一条指令地址。异常退出时,通过异常处理器表确定。
1.4 栈帧的生命周期 — 以黑马点评为例
用户请求: GET /api/blog/123
调用链:
BlogController.getBlogById(123) ← 栈帧1入栈
-> BlogService.getBlogById(123) ← 栈帧2入栈
-> BlogMapper.selectById(123) ← 栈帧3入栈
-> SqlSession.selectOne(…) ← 栈帧4入栈
-> Connection.prepareStatement() ← 栈帧5入栈
<- 返回PreparedStatement ← 栈帧5出栈
<- 返回Blog对象 ← 栈帧4出栈
<- 返回Blog ← 栈帧3出栈
<- 返回Blog ← 栈帧2出栈
<- 返回ResponseEntity<Blog> ← 栈帧1出栈
🍳 餐厅类比: 栈帧就像一道菜的"制作工序单"。每开始做一道菜,就开一张工序单(入栈);菜做好了,工序单归档(出栈)。如果工序太复杂(递归调用太深),工序单堆得太高,就会StackOverflowError——就像厨房操作台堆不下了。
1.5 虚拟机栈的大小与异常
// StackOverflowError 示例:无限递归
public class StackOverflowDemo {
private int count = 0;
public void recursive() {
count++; // 每调用一次,栈帧+1
recursive(); // 永远不退出
}
// 默认 -Xss256k 约能递归 2000-3000 次
// -Xss1m 约能递归 10000+ 次
}
表2:不同-Xss参数下的递归深度(近似值)
| 128k | 128KB | ~1000-1500 | 生产环境不推荐 |
| 256k | 256KB | ~2000-3000 | JDK默认值 |
| 512k | 512KB | ~5000-7000 | 适中 |
| 1m | 1MB | ~10000-15000 | 栈深调用场景 |
| 2m | 2MB | ~20000+ | 浪费内存 |
🥩 第二幕:堆内存与对象生命周期 — 食材从采购到上桌

2.1 堆内存的分代模型
堆是JVM中最大的一块内存区域,所有对象实例都在这里分配。为了高效管理内存和GC,堆被分为:
年轻代(Young Generation) — “食材采购区”
- Eden区(伊甸园):新对象在这里诞生。绝大多数对象"朝生夕灭"——比如一次查询创建的DTO对象、分页参数对象等
- Survivor 0(S0)和 Survivor 1(S1):从Eden中存活下来的对象被复制到这里。两个S区交替使用
老年代(Old Generation) — “常备食材库”
- 在Survivor区存活了足够多次(默认15次)的对象被"晋升"到这里
- 存放长期存活的对象:Spring Bean(单例)、数据库连接池、线程池、缓存对象等
表3:堆内存分代参数
| -Xms | 初始堆大小 | 与-Xmx相同 | 512m-2g |
| -Xmx | 最大堆大小 | 根据应用内存 | 512m-2g |
| -Xmn | 年轻代大小 | 堆的1/3到1/2 | 256m |
| -XX:SurvivorRatio | Eden:S区比例 | 8(即8:1:1) | 8 |
| -XX:MaxTenuringThreshold | 晋升老年代的年龄阈值 | 15(最大值) | 15 |
| -XX:PretenureSizeThreshold | 大对象直接进老年代的阈值 | 根据场景 | 1m |
2.2 对象的"一生" — 从出生到死亡

一个Java对象在JVM中的完整生命周期:
① 对象创建(在Eden区)
当你写 new Blog() 时,JVM做了这些事:
- 指针碰撞(Bump the Pointer):如果内存是规整的(使用Serial、ParNew等带Compact的收集器),用一个指针标记已用和未用的边界,移动指针即可
- 空闲列表(Free List):如果内存不规整(使用CMS等基于Mark-Sweep的收集器),维护一个空闲列表,从列表中找一块够大的空间
- Mark Word:哈希码、GC分代年龄、锁状态标志、线程持有的锁
- 类型指针:指向方法区中的类元数据
- 数组长度:如果是数组,还需要记录长度
② 对象在Eden中存活
新创建的Blog对象在Eden区"生活"。在黑马点评中,一次查询请求可能会创建几十个临时对象:
// 一次查询创建的对象(都是短命的)
PageDTO pageDTO = new PageDTO(1, 10); // 分页参数
QueryWrapper<Blog> wrapper = new QueryWrapper<>(); // 查询条件
List<Blog> blogList = blogMapper.selectList(wrapper); // 结果列表
BlogVO blogVO = BeanUtil.copyProperties(blog, BlogVO.class); // VO转换
这些对象大多在方法返回后就不再被引用,变成垃圾。
③ Minor GC — “食材筛选”
当Eden区满了,触发Minor GC(也叫Young GC):
④ 晋升老年代
当对象的年龄达到 MaxTenuringThreshold(默认15),或者Survivor区放不下了,对象被晋升到老年代。
⑤ Major GC / Full GC
当老年代满了,触发Major GC(也叫Full GC)。Full GC会同时清理年轻代和老年代,通常耗时较长(几十毫秒到几秒),期间应用会暂停(Stop-The-World)。
2.3 TLAB — “每人一个小灶台”
在多线程环境中,多个线程同时在Eden区分配对象会产生竞争。JVM通过TLAB(Thread Local Allocation Buffer) 来解决这个问题:
- 每个线程在Eden区预先分配一小块私有内存(默认是Eden的1%)
- 线程创建对象时,先在自己的TLAB中分配,无需加锁
- TLAB用完了,再从Eden中申请新的TLAB
这就像餐厅给每个厨师分配了一个小灶台,不用每次做菜都去公共灶台排队。
-XX:+UseTLAB # 启用TLAB(默认开启)
-XX:TLABSize=512k # 设置TLAB大小
-XX:-ResizeTLAB # 禁止自动调整TLAB大小
2.4 对象的内存布局
一个Java对象在堆中占用的内存由三部分组成:
表4:Java对象内存布局
| 对象头 (Object Header) | 12-16字节 | Mark Word (8字节) + Klass Pointer (4/8字节) |
| 实例数据 (Instance Data) | 取决于字段 | 对象的字段值(int=4, long=8, 引用=4/8字节) |
| 对齐填充 (Padding) | 0-7字节 | 使对象总大小为8字节的倍数 |
Mark Word的结构(64位JVM,无锁状态):
|————————————————————–|
| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |
|————————————————————–|
黑马点评对象大小估算:
- Blog 对象(10个字段):~64字节
- User 对象(8个字段):~48字节
- Shop 对象(12个字段):~80字节
- 一个 ArrayList(空):~48字节
- 一个 HashMap.Node:~48字节
🧮 算笔账: 如果一次查询创建100个临时对象,平均每个64字节,一次查询就是6.4KB。QPS=100时,每秒在Eden区分配640KB。Eden区256MB约能撑400秒(约7分钟)才触发一次Minor GC。这就是为什么大多数Web应用的Minor GC间隔是几分钟到几十分钟。
♻️ 第三幕:垃圾回收机制 — 垃圾分类的艺术

3.1 什么是"垃圾"?
在JVM中,垃圾就是不再被任何引用指向的对象。判断对象是否存活有两种算法:
① 引用计数法(Reference Counting)
给每个对象维护一个引用计数器。有人引用它,计数+1;引用失效,计数-1。计数为0就是垃圾。
- 优点:实现简单,判断效率高
- 缺点:无法解决循环引用问题
// 循环引用示例
class Node {
Node next;
}
Node a = new Node(); // a引用计数=1
Node b = new Node(); // b引用计数=1
a.next = b; // b引用计数=2
b.next = a; // a引用计数=2
a = null; // a引用计数=1(b.next还引用着)
b = null; // b引用计数=1(a.next还引用着)
// 引用计数都不为0,但实际上这两个对象已经不可达了!
JVM不使用引用计数法。
② 可达性分析法(Reachability Analysis)— JVM实际使用的算法
从一组GC Roots出发,沿着引用链向下搜索。能到达的对象是存活的,不能到达的就是垃圾。
可以作为GC Roots的对象:
- 虚拟机栈中引用的对象(局部变量表中的引用)
- 方法区中类静态属性引用的对象(static 变量)
- 方法区中常量引用的对象(static final 变量)
- 本地方法栈中JNI引用的对象
- 被同步锁(synchronized)持有的对象
- JVM内部的引用(基本数据类型对应的Class对象、系统类加载器等)
3.2 对象的"两次机会" — finalize()
即使对象被判定为不可达,它还有一次"自救"的机会——finalize() 方法。GC第一次标记后,如果对象重写了 finalize() 且之前没调用过,JVM会把它放入一个队列,由一个低优先级的Finalizer线程执行 finalize()。如果在 finalize() 中重新建立了引用(比如 this 赋值给某个类变量),对象就能"复活"。
⚠️ 强烈不推荐使用finalize()! 原因:
3.3 三种GC算法
① 标记-清除(Mark-Sweep)
分两步:先标记存活对象,再清除未标记的对象。
- 优点:实现简单
- 缺点:产生内存碎片;清除后内存不连续,大对象可能找不到足够的连续空间
② 标记-整理(Mark-Compact)
先标记存活对象,然后将所有存活对象向内存一端移动,清理边界外的内存。
- 优点:没有内存碎片
- 缺点:移动对象需要更新所有引用,效率较低
③ 复制算法(Copying)
将内存分为两块,每次只使用一块。GC时,将存活对象复制到另一块,然后清空当前块。
- 优点:没有碎片;只复制存活对象,效率高(适合存活率低的场景)
- 缺点:浪费50%的内存空间
实际应用:年轻代用复制算法(因为大部分对象朝生夕灭),老年代用标记-整理或标记-清除。
3.4 分代收集策略
JVM的分代收集结合了上述算法的优点:
| 年轻代 | 复制算法 | 90%+的对象在Eden中死亡,只需复制少量存活对象 |
| 老年代 | 标记-整理 或 标记-清除 | 存活率高,复制算法浪费空间 |
年轻代的优化: Eden:S0:S1 = 8:1:1(而不是1:1:1)。这样只浪费10%的内存空间(S0或S1中总有一个是空的),而Eden区可以使用80%的空间。这就是"Appel式回收"。
3.5 GC收集器全家福

JVM提供了多种GC收集器,每种都有不同的设计目标。让我们对比一下:
表5:GC收集器全面对比
| Serial | Young | 复制 | 单线程 | 完全STW | 简单高效 | 小堆、客户端模式 |
| Serial Old | Old | 标记-整理 | 单线程 | 完全STW | CMS的后备方案 | 小堆 |
| ParNew | Young | 复制 | 多线程 | 完全STW | Serial的多线程版 | 配合CMS |
| Parallel Scavenge | Young | 复制 | 多线程 | 完全STW | 关注吞吐量 | 后台计算 |
| Parallel Old | Old | 标记-整理 | 多线程 | 完全STW | JDK8默认 | 后台计算 |
| CMS | Old | 标记-清除 | 并发 | 部分STW | 低延迟 | 已废弃(JDK9) |
| G1 | Both | 分Region | 并发 | 可控STW | 平衡型 | JDK9+默认 |
| ZGC | Both | 着色指针 | 并发 | <1ms | 超低延迟 | 大堆、低延迟 |
| Shenandoah | Both | Brooks指针 | 并发 | <1ms | 类似ZGC | OpenJDK |
3.6 G1收集器详解 — 黑马点评的最佳选择
G1(Garbage-First)是JDK 9+的默认GC收集器,也是黑马点评这类Web应用的最佳选择。
G1的核心设计:
G1将整个堆划分为多个大小相等的Region(默认2048个Region),每个Region可以是Eden、Survivor、Old或Humongous(存大对象)。
G1堆布局示例(2048个Region,每个1MB):
[E][E][S][E][O][O][E][H][H][O][S][E][O][O][E][E]…
| | | | | | | |__| | | | | | | |
年轻代 大对象 老年代
G1的特点:
- 可预测的停顿:通过 -XX:MaxGCPauseMillis=200 设置目标停顿时间,G1会优先回收价值最大的Region
- 不需要连续的Region:大对象可以跨多个Region存放
- 全局标记:并发标记阶段标记整个堆的存活对象
- Mixed GC:同时回收年轻代和部分老年代Region
G1的关键参数:
-XX:+UseG1GC # 启用G1
-XX:MaxGCPauseMillis=200 # 目标最大停顿时间(毫秒)
-XX:G1HeapRegionSize=1m # Region大小(1-32MB,2的幂)
-XX:InitiatingHeapOccupancyPercent=45 # 老年代占比超过45%触发并发标记
-XX:G1NewSizePercent=5 # 年轻代最小比例
-XX:G1MaxNewSizePercent=60 # 年轻代最大比例
📦 第四幕:类加载机制 — 菜谱是怎么进厨房的

4.1 类的生命周期
一个类从被加载到JVM中,到被卸载,经历以下阶段:
① 加载(Loading)
通过类的全限定名(如 com.heima.dianping.service.BlogService)找到对应的 .class 文件,读取字节码,在方法区创建对应的 Class 对象。
② 链接(Linking)
- 验证(Verification):检查字节码是否符合JVM规范(文件格式、元数据、字节码、符号引用验证)
- 准备(Preparation):为类的静态变量分配内存并设置零值(static int count = 10 此时设为0,不是10)
- 解析(Resolution):将符号引用替换为直接引用
③ 初始化(Initialization)
执行类构造器 <clinit>() 方法,按代码顺序执行静态变量赋值和静态代码块。
public class BlogService {
static int count = 10; // 准备阶段count=0,初始化阶段count=10
static final String NAME = "DP"; // 编译期常量,准备阶段就=DP
static {
System.out.println("BlogService loaded!");
}
}
④ 使用(Using)
创建实例、调用方法、访问字段。
⑤ 卸载(Unloading)
类被卸载需要同时满足三个条件:
4.2 类加载器的双亲委派模型
JVM有三层类加载器,采用双亲委派模型(Parent Delegation Model):
Bootstrap ClassLoader (C++实现)
↑ 加载 rt.jar 中的核心类 (java.lang.*, java.util.*)
Extension ClassLoader (Platform ClassLoader, JDK9+)
↑ 加载 jre/lib/ext 中的扩展类
Application ClassLoader (System ClassLoader)
↑ 加载 classpath 中的应用类
Custom ClassLoader (自定义)
↑ Tomcat ClassLoader, Spring Boot Fat-JAR Loader, etc.
双亲委派的工作流程:
当一个类加载器收到加载请求时:
为什么需要双亲委派?
防止核心类被篡改。比如有人写了一个 java.lang.String 类,如果没有双亲委派,应用类加载器可能会加载这个假的String类,导致安全问题。有了双亲委派,Bootstrap ClassLoader会优先加载rt.jar中的真正String类。
4.3 打破双亲委派的场景
虽然双亲委派是推荐的模型,但在某些场景下需要打破它:
| SPI机制 | Thread.getContextClassLoader() | 核心类需要加载应用实现(如JDBC驱动) |
| Tomcat | 每个Web应用独立的ClassLoader | 不同应用可以使用不同版本的类库 |
| 热部署 | 自定义ClassLoader重新加载 | 代码修改后不重启生效 |
| OSGi | 网状委托结构 | 模块化,双向依赖 |
4.4 类加载实战
# 查看类加载信息
java -verbose:class -jar dianping.jar
# 输出示例:
# [Loaded com.heima.dianping.BlogController from file:/app/classes/]
# [Loaded com.heima.dianping.service.BlogService from file:/app/classes/]
# [Loaded java.lang.Object from /jdk/jre/lib/rt.jar]
🔥 第五幕:执行引擎 — 厨师怎么做菜的

5.1 三种执行方式
JVM的执行引擎有三种执行字节码的方式:
① 解释执行(Interpreter)
逐条读取字节码指令,翻译成机器码执行。
- 优点:启动速度快(不需要编译),内存占用小
- 缺点:执行速度慢(每条指令都要翻译一次)
- 类比:同声传译——听到一句翻译一句,速度快但不精确
② 编译执行(JIT, Just-In-Time Compilation)
将热点代码(被频繁调用的方法或循环)编译成机器码,存入代码缓存。下次执行时直接用机器码,不需要再翻译。
- 优点:执行速度接近原生代码
- 缺点:编译需要时间和内存;启动时比解释执行慢
- 类比:先把菜谱背下来,做菜时不用再翻书
③ 混合执行(Mixed Mode)— JVM的默认模式
JVM同时使用解释器和JIT编译器:
- 启动时用解释器快速启动
- 运行时识别热点代码,用JIT编译器编译
- 这就是 Tiered Compilation(分层编译)
5.2 分层编译详解
JDK 8+默认开启分层编译(-XX:+TieredCompilation),将编译分为5个级别:
| Level 0 | 解释执行 | 收集基本性能数据 | 默认 |
| Level 1 | C1编译,无profiling | 快速编译,不收集性能数据 | 简单方法 |
| Level 2 | C1编译,有限profiling | 编译+收集部分调用/分支计数 | 中等热点 |
| Level 3 | C1编译,完全profiling | 编译+收集完整性能数据 | 热点方法 |
| Level 4 | C2编译 | 完全优化编译 | 高度热点 |
编译阈值(默认):
-XX:CompileThreshold=10000 # 方法被调用10000次后触发JIT编译
-XX:OnStackReplacePercentage=1400 # OSR编译阈值
5.3 C2编译器的优化手段
C2编译器(Server Compiler)做了大量优化:
- 方法内联(Inlining):把小方法的代码直接嵌入调用者,消除方法调用开销
- 逃逸分析(Escape Analysis):分析对象是否逃出方法/线程。如果没有:
- 栈上分配:对象在栈上分配,方法结束自动释放,不需要GC
- 标量替换:把对象拆成基本类型变量
- 同步消除:如果对象只在单线程中使用,去除同步锁
- 循环展开(Loop Unrolling):减少循环判断次数
- 空值检查消除(Null Check Elimination):如果能证明引用不为null,去除空值检查
- 内联缓存(Inline Cache):缓存虚方法的目标,加速多态调用
// 逃逸分析示例
public int calculate() {
Point p = new Point(1, 2); // p没有逃出方法
return p.x + p.y;
// C2优化后:直接 int x=1, int y=2; return x+y;
// 不需要创建Point对象,不需要GC
}
5.4 JIT编译的观察
# 打印JIT编译信息
java -XX:+PrintCompilation -jar dianping.jar
# 输出示例:
# 78 1 3 java.lang.String::charAt (29 bytes)
# 123 2 4 com.heima.dianping.service.BlogService::getBlogById (45 bytes)
# 150 3 3 java.util.HashMap::hash (20 bytes)
📝 第六幕:字符串常量池 — 食材的标准化管理

6.1 String的特殊地位
String是Java中最常用的类,也是面试的"常客"。它的特殊之处在于不可变性(Immutable) 和 常量池机制。
// String的不可变性
public final class String {
private final char[] value; // JDK8用char[],JDK9+用byte[]
// final + private = 不可变
}
为什么String要设计成不可变?
6.2 字面量 vs new String()
String s1 = "Hello"; // 字面量,存入字符串常量池
String s2 = "Hello"; // 从常量池中取,和s1是同一个对象
String s3 = new String("Hello"); // 在堆中创建新对象,不在常量池中
s1 == s2 // true (同一个引用)
s1 == s3 // false (不同的对象)
s1.equals(s3) // true (值相同)
字符串常量池的位置变迁:
- JDK 6及之前:在永久代(PermGen)中
- JDK 7+:移到了堆中(因为永久代空间太小,容易OOM)
6.3 String.intern() — 慎用!
String s1 = new String("Hello"); // 堆中创建
s1.intern(); // 把"Hello"放入常量池(如果不存在)
String s2 = "Hello"; // 从常量池取
s1 == s2 // JDK7+: false (s1指向堆中对象,s2指向池中)
⚠️ 黑马点评中的陷阱: 如果你在循环中对大量不同的字符串调用 intern(),常量池会膨胀,导致GC压力增大甚至OOM。比如拼接Redis key "blog:" + blogId,每调用一次intern就往常量池加一个新字符串。
6.4 String拼接的性能
// 方式1:+ 号拼接(JDK9+编译器优化为invokedynamic)
String result = "blog:" + id + ":detail"; // OK in JDK9+
// 方式2:StringBuilder(推荐在循环中使用)
StringBuilder sb = new StringBuilder();
for (Blog blog : blogList) {
sb.append(blog.getTitle()).append(",");
}
// 方式3:String.join(JDK8+)
String result = String.join(",", titles);
// 方式4:String.format
String key = String.format("blog:%d:detail", id);
💥 第七幕:OOM排查实战 — 餐厅爆单了怎么办

7.1 常见的OOM类型
表6:JVM常见OOM类型与原因
| 堆内存不足 | Java heap space | 内存泄漏、堆太小 | MAT, jmap |
| 元空间不足 | Metaspace | 类加载过多、动态代理 | jstat, -verbose:class |
| 栈溢出 | StackOverflowError | 无限递归、栈帧太大 | jstack |
| 直接内存不足 | Direct buffer memory | NIO Buffer泄漏 | -XX:MaxDirectMemorySize |
| GC开销超限 | GC overhead limit exceeded | GC时间占比>98% | GC日志 |
| 无法创建线程 | unable to create new native thread | 线程数超限 | ulimit, /proc/sys |
7.2 黑马点评OOM实战案例
场景: 黑马点评上线后,用户量增长,某天凌晨突然告警,应用OOM崩溃。
① 第一步:收集现场
# 查看GC日志
tail -1000 /tmp/gc.log | grep "Full GC"
# 输出:
# 2025-07-13T02:15:30.123+0800: [Full GC (Allocation Failure)
# [PSYoungGen: 256000K->256000K(262144K)]
# [ParOldGen: 262144K->262144K(262144K)]
# 518144K->518144K(524288K)]
② 第二步:获取Heap Dump
# 方式1:OOM时自动生成(推荐在启动参数中加上)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
# 方式2:手动触发
jmap -dump:format=b,file=/tmp/heapdump.hprof <PID>
# 方式3:jcmd(推荐,对线上影响最小)
jcmd <PID> GC.heap_dump /tmp/heapdump.hprof
③ 第三步:用MAT分析Heap Dump
Eclipse MAT(Memory Analyzer Tool)是最强大的堆分析工具:
MAT分析结果:
Dominator Tree:
Problem Suspect 1:
com.heima.dianping.controller.BlogController.cache
Retained Heap: 450MB (85% of total)
Shallow Heap: 128 bytes
问题代码:
private static List<Blog> cache = new ArrayList<>();
public List<Blog> getBlogs() {
List<Blog> blogs = blogMapper.selectAll();
cache.addAll(blogs); // 每次查询都往cache里加,从不清理!
return blogs;
}
④ 第四步:修复
// 修复前(内存泄漏)
private static List<Blog> cache = new ArrayList<>();
// 修复后(使用Redis缓存)
@Autowired
private StringRedisTemplate redisTemplate;
public List<Blog> getBlogs() {
// 先查Redis缓存
String cacheKey = "blog:list";
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseArray(cached, Blog.class);
}
// 缓存未命中,查数据库
List<Blog> blogs = blogMapper.selectAll();
// 写入Redis,设置30分钟过期
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(blogs), 30, TimeUnit.MINUTES);
return blogs;
}
7.3 常用JVM诊断工具
表7:JVM诊断工具对比
| jps | 命令行 | 查看Java进程 | 找到目标PID |
| jstat | 命令行 | GC统计信息 | 监控GC频率、堆使用率 |
| jmap | 命令行 | 堆dump、内存映射 | 获取heap dump |
| jstack | 命令行 | 线程dump | 排查死锁、CPU飙高 |
| jcmd | 命令行 | 综合诊断 | 替代jps+jmap+jstack |
| MAT | GUI | 堆分析 | OOM根因分析 |
| VisualVM | GUI | 可视化监控 | 实时监控+采样 |
| JProfiler | GUI | 性能分析 | 方法级耗时分析 |
| Arthas | 命令行 | 在线诊断 | 线上不重启排查 |
🔧 第八幕:JVM调优实战 — 把餐厅效率拉满

8.1 什么时候需要调优?
不是所有应用都需要JVM调优。以下情况才需要:
- GC停顿时间过长,影响用户体验(P99延迟超标)
- GC频率过高,CPU被GC占用过多
- 应用频繁OOM
- 堆内存使用率持续很高
8.2 调优的基本原则
8.3 黑马点评生产环境JVM参数
# 基础参数
-server # 服务器模式
-Xms4g -Xmx4g # 堆大小(初始=最大,避免动态扩展)
-Xmn2g # 年轻代2G(堆的50%)
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m # 元空间
# GC参数
-XX:+UseG1GC # 使用G1收集器
-XX:MaxGCPauseMillis=200 # 目标最大停顿200ms
-XX:G1HeapRegionSize=4m # Region大小
-XX:InitiatingHeapOccupancyPercent=45 # 老年代45%触发并发标记
# 监控与诊断
-XX:+HeapDumpOnOutOfMemoryError # OOM时自动dump
-XX:HeapDumpPath=/data/logs/heapdump.hprof
-XX:+PrintGCDetails # 打印GC详情
-XX:+PrintGCDateStamps # GC时间戳
-Xloggc:/data/logs/gc.log # GC日志路径
-XX:+UseGCLogFileRotation # GC日志轮转
-XX:NumberOfGCLogFiles=5 # 保留5个GC日志文件
-XX:GCLogFileSize=20M # 每个GC日志20MB
# 性能优化
-XX:+UseCompressedOops # 压缩指针(堆<32G时)
-XX:+UseCompressedClassPointers # 压缩类指针
-XX:+TieredCompilation # 分层编译
-XX:+OptimizeStringConcat # 优化字符串拼接
8.4 GC日志分析
GC日志是调优的重要依据。关键指标:
[GC pause (G1 Evacuation Pause) (young), 0.0154321 secs]
[Parallel Time: 12.3 ms, GC Workers: 8]
[Eden: 1024.0M(1024.0M)->0.0B(1024.0M)] // Eden清空
[Survivor: 128.0M->128.0M] // Survivor保持
[Old: 512.0M->640.0M] // Old增长(对象晋升)
[Heap: 1664.0M(4096.0M)->768.0M(4096.0M)] // 总堆使用下降
需要关注的指标:
| Minor GC频率 | 5-30分钟/次 | <1分钟/次 | 太频繁说明年轻代太小或对象分配太多 |
| Minor GC耗时 | <50ms | >100ms | 与存活对象数量成正比 |
| Full GC频率 | <1次/天 | >1次/小时 | 太频繁说明有内存泄漏或老年代太小 |
| Full GC耗时 | <200ms | >1s | 与堆大小成正比 |
| GC后老年代使用率 | <70% | >80% | 持续增长说明有内存泄漏 |
8.5 常见JVM问题与解决方案
表8:常见JVM问题排查速查表
| Full GC频繁 | 内存泄漏、老年代太小 | MAT分析heap dump | 修复泄漏、增大老年代 |
| Minor GC频繁 | 年轻代太小、对象分配速率高 | jstat -gcutil | 增大年轻代、优化代码 |
| GC停顿时间长 | 堆太大、存活对象多 | GC日志分析 | 换G1/ZGC、减小堆 |
| CPU飙高 | 死循环、频繁GC、锁竞争 | top + jstack + GC日志 | 修复代码 |
| 应用卡顿 | Full GC STW、锁等待 | GC日志 + jstack | 换低延迟GC、优化锁 |
| OOM: Metaspace | 类加载泄漏、CGLIB代理过多 | -verbose:class | 增大Metaspace、修复泄漏 |
| OOM: heap space | 内存泄漏、堆太小 | MAT分析dump | 修复泄漏、增大堆 |
8.6 不同场景的JVM配置建议
| 开发环境 | 256m-512m | 默认Parallel | -XX:+PrintGCDetails |
| 测试环境 | 512m-2g | G1 | -XX:MaxGCPauseMillis=200 |
| 生产(Web应用) | 4g-8g | G1 | 全套生产参数 |
| 生产(低延迟) | 8g+ | ZGC | -XX:+UseZGC |
| 生产(批处理) | 8g+ | Parallel | -XX:+UseParallelGC -XX:GCTimeRatio=99 |
| 微服务(容器) | 根据容器 | G1 | -XX:MaxRAMPercentage=75.0 |
🌟 结语
从一份外卖订单出发,我们走过了JVM的每一个核心区域:
- 虚拟机栈是"制作工序单",记录每道菜的制作过程
- 堆内存是"食材仓库",新食材在年轻代,常备食材在老年代
- 垃圾回收是"保洁阿姨",定期清理用完的食材
- 类加载是"菜谱管理",确保厨房里有正确的菜谱
- 执行引擎是"厨师团队",解释器是新手(现学现做),JIT是老手(烂熟于心)
- 字符串常量池是"标准化食材库",避免重复采购
JVM调优不是银弹,代码质量才是根本。 90%的性能问题,根源在于代码(内存泄漏、N+1查询、不合理的缓存策略),而不是JVM参数。把代码写好,JVM的默认参数往往就够了。
但当你的应用真的需要调优时,希望这篇文章能给你一把"手术刀",让你在JVM的世界里游刃有余。
“理解JVM,不是为了炫技,而是为了在凌晨三点收到告警时,能从容地打开GC日志,找到问题,修复它,然后安心地回去睡觉。”
📚 参考文献
*📝 本文由AI助手撰写 | JVM知识来源于《深入理解Java虚拟机》及JVM官方文档


