摘要
Java 服务运行一段时间后出现响应变慢、频繁 Full GC、内存持续上涨甚至 OOM,并不一定是“堆内存太小”。问题可能来自对象分配过快、缓存没有上限、线程栈过多、元空间增长、直接内存耗尽、锁竞争或垃圾回收器参数不匹配。
要解决 JVM 性能问题,首先需要建立正确的内存模型。本文会区分 JVM 运行时数据区和 Java 内存模型,介绍堆、虚拟机栈、程序计数器、元空间和直接内存的作用,然后解释年轻代、老年代、对象晋升、垃圾回收算法以及常用收集器的工作方式。
在此基础上,文章通过 Spring Boot 服务示例、JVM 启动参数、GC 日志和监控指标,展示如何定位内存问题、分析 GC 停顿、设置合理的堆大小,并给出一套从现象观察、数据采集到参数调整的性能优化流程。
读完本文后,你应该能够:
- 区分 JVM 内存区域与 Java 内存模型;
- 理解对象创建、存活、晋升和回收过程;
- 判断堆、栈、元空间和直接内存问题;
- 读懂常见 GC 日志和关键指标;
- 了解 G1、ZGC 等收集器的适用方向;
- 设计合理的 JVM 启动参数;
- 使用 jmap、jcmd、jstat 和 JFR 进行排查;
- 避免只靠增加堆内存掩盖真实问题。
一、背景与问题
1. Java 服务为什么会越跑越慢
一个 Java 服务刚启动时通常运行正常,但经过一段时间后可能出现:
- 接口 P99 延迟逐渐升高;
- Young GC 频率越来越高;
- Full GC 偶尔出现长时间停顿;
- 堆内存回收后仍然保持高位;
- 老年代不断增长;
- CPU 使用率突然升高;
- 线程数量持续增加;
- 最终出现 OutOfMemoryError。
这些现象可能互相关联:
请求量增加
-> 短命对象创建速度变快
-> Young GC 频率提高
-> 部分对象存活并晋升老年代
-> 老年代回收压力增加
-> GC 停顿和 CPU 消耗上升
-> 请求处理变慢
-> 请求堆积并创建更多对象
-> 内存压力继续扩大
但也可能是完全不同的问题:
堆使用正常
元空间持续增长
-> 动态生成类过多
-> 元空间耗尽
或者:
Java 堆正常
直接内存持续增长
-> NIO Buffer 未及时释放
-> 进程内存超过容器限制
因此,JVM 调优不能只盯着堆使用率。
2. JVM 内存模型容易被混淆
“JVM 内存模型”在实际讨论中经常有两种含义:
JVM 运行时数据区
它描述 JVM 进程在运行 Java 程序时有哪些内存区域:
- 堆;
- 虚拟机栈;
- 本地方法栈;
- 程序计数器;
- 方法区或元空间;
- 运行时常量池。
Java Memory Model
Java 内存模型,也就是 JMM,主要描述多线程环境下:
- 线程如何读写共享变量;
- 可见性;
- 原子性;
- 有序性;
- happens-before 关系;
- volatile、synchronized 和 final 的内存语义。
两者不是同一个概念。本文重点讨论 JVM 运行时数据区和垃圾回收,但也会用一节说明 JMM 与性能问题的关系。
3. 性能问题必须先测量
JVM 调优最常见的错误是直接修改参数:
内存不够
-> 把 -Xmx 调大
GC 很频繁
-> 把年轻代调大
接口变慢
-> 把线程数调大
这种做法有时能够暂时缓解问题,但可能导致:
- 容器内存超限;
- GC 停顿更长;
- 问题被延后;
- 真实内存泄漏被掩盖;
- 系统在高峰期更难恢复。
更可靠的流程是:
现象
-> 指标
-> 日志
-> 线程和堆分析
-> 找到主要瓶颈
-> 小幅调整
-> 压测和对比
-> 灰度发布
4. 性能优化的目标
JVM 调优不是让某个数字越小越好,而是要在以下目标之间找到平衡:
- 吞吐量;
- 响应时间;
- 最大停顿时间;
- 内存使用;
- CPU 使用;
- 稳定性;
- 启动速度;
- 运维复杂度。
例如,批处理服务更关注吞吐量和总执行时间,在线交易服务更关注 P99 延迟和暂停时间,数据分析服务可能更愿意使用更大的堆换取更高吞吐。
二、核心概念
1. JVM 运行时数据区
JVM 运行时数据区可以抽象为:
不同区域的生命周期和错误类型不同。
| 区域 | 线程关系 | 主要内容 | 常见问题 |
|---|---|---|---|
| 程序计数器 | 线程私有 | 当前字节码指令位置 | 通常不会成为主要瓶颈 |
| 虚拟机栈 | 线程私有 | 栈帧、局部变量、操作数栈 | StackOverflowError、线程过多 |
| 本地方法栈 | 线程私有 | Native 方法调用状态 | 本地调用异常 |
| 堆 | 线程共享 | Java 对象实例和数组 | GC 压力、堆 OOM |
| 元空间 | 进程共享 | 类元数据、常量、方法信息 | Metaspace OOM |
| 直接内存 | 进程级资源 | 堆外缓冲区 | Direct buffer memory、容器 OOM |
2. 堆内存
堆是大多数 Java 对象分配的主要区域。对象通过 new 创建后,通常会在堆中分配:
Order order = new Order();
List<OrderItem> items = new ArrayList<>();
byte[] payload = new byte[1024 * 1024];
堆的特点:
- 由多个线程共享;
- 受垃圾回收器管理;
- 可以配置初始和最大大小;
- 包含对象和数组;
- 是最常见的内存问题来源。
堆大小由以下参数控制:
-Xms:初始堆大小
-Xmx:最大堆大小
例如:
java -Xms2g -Xmx2g -jar app.jar
将 Xms 和 Xmx 设置为相同值,可以减少运行期间堆扩缩容带来的波动,但不代表一定更优。容器环境仍然需要为非堆内存、线程栈、直接内存和本地库保留空间。
3. 新生代和老年代
传统分代垃圾回收器通常把堆划分为年轻代和老年代。
年轻代中又常见:
- Eden;
- Survivor From;
- Survivor To。
对象分配过程可以简化为:
新对象
-> Eden
-> Young GC
-> Survivor 区复制
-> 达到年龄阈值或 Survivor 放不下
-> 老年代
为什么要分代?因为许多对象具有“朝生夕死”的特点:
public Response handle(Request request) {
Map<String, Object> temporary =
buildTemporaryData(request);
return createResponse(temporary);
}
temporary 只在一次请求中使用,很快就没有引用。把这类对象集中在年轻代,可以通过较低成本的复制和清理回收。
老年代通常保存存活时间更长的对象,例如:
- 长期缓存;
- 会话对象;
- Spring 单例;
- 连接池;
- 配置对象;
- 长生命周期集合。
4. 对象可达性
垃圾回收器不是简单地判断对象是否“被使用过”,而是从 GC Roots 出发判断对象是否仍然可达。
常见 GC Roots 包括:
- 活跃线程栈中的引用;
- 静态字段;
- JNI 引用;
- 已加载类相关引用;
- 同步锁持有的对象。
对象引用关系可以表示为:
GC Root
-> ApplicationContext
-> CacheManager
-> HashMap
-> UserSession
如果缓存 Map 一直被全局对象引用,Map 中的对象就可能一直存活,即使业务已经不再需要它们。
内存泄漏在 Java 中通常不是“忘记 free”,而是“对象仍然被某个不该持有它的引用链持有”。
5. 虚拟机栈和栈帧
每个 Java 线程都有自己的虚拟机栈。方法调用时,会创建一个栈帧,包含:
- 局部变量表;
- 操作数栈;
- 动态链接;
- 方法返回信息。
例如:
public int calculate(int a, int b) {
int result = a + b;
return result;
}
方法执行期间,a、b 和 result 等局部变量位于当前栈帧中。对象本身通常在堆中,局部变量保存的是对象引用。
递归过深可能导致:
java.lang.StackOverflowError
线程数量过多也会消耗大量栈内存。栈大小可以通过:
-Xss512k
进行调整,但不能简单地把 Xss 设置得非常小。栈太小可能导致正常调用链无法执行,尤其是框架层级深、递归或复杂代理调用场景。
6. 元空间
JDK 8 之后,类元数据主要存放在元空间,替代了永久代。
元空间可能包含:
- 类结构;
- 方法元数据;
- 字段信息;
- 注解信息;
- 常量信息;
- JIT 编译相关数据的一部分。
常见参数:
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
元空间增长的常见原因:
- 动态生成大量代理类;
- 类加载器没有释放;
- 热部署或脚本引擎反复加载类;
- 插件系统类加载器泄漏;
- 大量不同的 Lambda 或字节码增强;
- 依赖库生成类过多。
元空间问题不能通过增加 Java 堆来解决。
7. 直接内存
直接内存位于 Java 堆之外,常见于 NIO、Netty、压缩库和部分序列化组件。
例如:
ByteBuffer buffer =
ByteBuffer.allocateDirect(1024 * 1024);
直接内存的优势是减少某些 IO 场景中的数据复制,但它由进程整体内存承担,不等同于 -Xmx。
可以设置上限:
-XX:MaxDirectMemorySize=512m
容器环境下,进程总内存大致包括:
进程总内存
= Java 堆
+ 元空间
+ 线程栈
+ 直接内存
+ JIT Code Cache
+ GC 辅助结构
+ 本地库和其他 Native 内存
因此,如果容器内存限制为 2 GB,不能把 Xmx 也设置成 2 GB。
8. Java 内存模型 JMM
JMM 关注的是多线程之间的内存可见性和指令重排序,不是 JVM 堆的空间划分。
例如:
class FlagHolder {
private boolean ready;
private int value;
public void write() {
value = 42;
ready = true;
}
public void read() {
if (ready) {
System.out.println(value);
}
}
}
没有同步措施时,一个线程可能看不到另一个线程对 ready 或 value 的更新,也可能观察到重排序带来的非预期结果。
使用 volatile:
private volatile boolean ready;
可以保证对 ready 的写入对其他线程可见,并建立相关的 happens-before 关系。
使用 synchronized、Lock 或并发容器,也可以建立内存可见性和互斥关系。
JMM 问题通常表现为:
- 数据不一致;
- 状态更新延迟;
- 线程偶发读到旧值;
- 自旋不退出;
- 并发条件下结果错误。
这类问题不能通过调整 GC 参数解决。
三、工作原理
1. 对象分配过程
一个简化的对象分配流程:
实际 JVM 会使用线程本地分配缓冲区 TLAB,减少多个线程同时分配对象时的锁竞争。
可以通过日志观察对象分配和 GC 行为,但不建议一开始就修改 TLAB 参数。大多数应用首先应该解决对象创建过多、临时集合过大或缓存无界等问题。
2. Young GC
Young GC 主要回收年轻代对象。典型过程:
Eden 满
-> 标记存活对象
-> 将存活对象复制到 Survivor
-> 清空 Eden
-> Survivor 区交换角色
-> 对象年龄增加
如果 Survivor 空间不足,部分对象可能提前晋升到老年代。过早晋升可能加重老年代压力。
Young GC 通常停顿时间较短,但频率过高会消耗 CPU 和影响尾延迟。
3. 老年代回收
老年代回收通常成本更高,因为存活对象比例可能较高。传统收集器可能需要:
- 标记存活对象;
- 清理不可达对象;
- 压缩内存;
- 修复对象引用;
- 暂停应用线程。
当老年代空间不足或并发回收跟不上分配速度时,可能触发 Full GC 或类似的长停顿过程。
4. 垃圾回收算法
标记-清除
先标记存活对象,再清除垃圾对象。
优点是实现简单,缺点是容易产生内存碎片。
复制算法
把存活对象复制到另一块区域,然后整体清空原区域。
优点是回收后空间连续,缺点是需要额外的复制空间,适合存活率较低的年轻代。
标记-整理
标记存活对象后,将对象移动到一端,清理边界之外的空间。
优点是减少碎片,缺点是对象移动成本较高。
分代回收
根据对象生命周期差异,把年轻对象和老对象分开处理。现实中的收集器通常会组合多种算法。
5. Stop-The-World
垃圾回收过程中,某些阶段需要暂停应用线程,这就是 Stop-The-World,简称 STW。
STW 并不一定意味着整个 GC 都是单线程执行,而是应用线程暂时停止,GC 线程进行必要工作。
停顿时间受以下因素影响:
- 堆大小;
- 存活对象数量;
- 根扫描范围;
- 卡表和记忆集;
- 并发标记速度;
- CPU 资源;
- 对象晋升速度;
- GC 算法和收集器。
在线服务通常更关注 P99 延迟,因此要关注最大停顿和尾延迟,而不只是平均 GC 时间。
6. G1 收集器
G1 将堆划分为多个 Region,不再严格按连续的年轻代和老年代物理区域组织,而是根据对象年龄和回收策略动态选择 Region。
G1 的基本流程包括:
年轻代回收
-> 并发标记
-> 识别垃圾较多的 Region
-> 优先回收收益较高的 Region
-> 在停顿目标内完成回收
G1 的特点:
- 面向大堆;
- 支持可预测的停顿目标;
- 通过 Region 管理堆;
- 具备并发标记阶段;
- 需要维护记忆集;
- 仍可能出现 Full GC 或退化情况。
常见参数示例:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
MaxGCPauseMillis 是目标,不是严格保证。设置得过于激进,可能导致收集器更频繁工作,影响吞吐量。
7. ZGC 收集器
ZGC 面向低停顿目标,通过并发处理和着色指针等机制,将大部分工作与应用线程并发执行。
它的特点:
- 停顿时间目标较低;
- 适合较大堆和低延迟场景;
- 对 JDK 版本和运行环境有要求;
- 仍然需要足够 CPU 并发;
- 低停顿不代表没有吞吐量成本。
选择 ZGC 时需要验证:
- 当前 JDK 版本支持情况;
- 应用是否适合并发 GC;
- CPU 是否有余量;
- 堆外内存是否受控;
- 压测下 P99 是否改善;
- GC 日志和监控是否完善。
8. GC 日志
现代 JVM 可以使用统一日志参数:
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=100M
它可以记录:
- GC 类型;
- GC 开始和结束时间;
- 暂停时长;
- 回收前后堆使用;
- 老年代变化;
- Safepoint;
- 并发阶段;
- 失败或退化信息。
GC 日志需要和业务指标结合起来看:
接口 P99 升高
-> 对比 GC pause 时间
-> 对比线程池队列
-> 对比数据库响应时间
-> 判断是否为 GC 主因
不能看到一次长 GC 就断定所有慢请求都由 GC 导致。
四、实战示例
1. Spring Boot 服务的基础启动参数
一个在线服务可以从如下参数开始:
java \
-Xms2g \
-Xmx2g \
-Xss512k \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:MaxMetaspaceSize=512m \
-XX:MaxDirectMemorySize=512m \
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=100M \
-jar app.jar
这些参数不是通用最佳值,需要根据:
- 容器内存;
- CPU 核数;
- 请求量;
- 对象分配率;
- 延迟目标;
- 数据库和下游资源;
- 压测结果;
进行调整。
2. 容器内存的估算
假设容器内存限制为 4 GB,可以预留:
Java 堆:2.5 GB
元空间:256 MB
直接内存:512 MB
线程栈:300 MB
JIT、GC 和 Native:400 MB
系统余量:约 100 MB
实际配置可以是:
-Xmx2560m
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=512m
-Xss512k
线程栈预算与线程数相关:
线程栈总量
≈ 线程数量 × Xss
如果服务有 500 个线程,Xss 设置为 1 MB,仅栈空间理论上就可能需要约 500 MB,还没有计入线程对象和 Native 开销。
3. 使用 jcmd 查看 JVM 信息
查看 Java 进程:
jcmd
查看进程参数:
jcmd <pid> VM.command_line
jcmd <pid> VM.flags
查看堆信息:
jcmd <pid> GC.heap_info
查看类统计:
jcmd <pid> GC.class_histogram
查看线程:
jcmd <pid> Thread.print
在生产环境执行诊断命令需要考虑:
- 命令耗时;
- 输出大小;
- 对应用的暂停影响;
- 权限;
- 容器 PID;
- 是否需要在低峰期执行。
4. 使用 jstat 观察 GC
查看 GC 统计:
jstat -gcutil <pid> 1000 20
常见字段包括:
- S0:Survivor 区使用率;
- S1:Survivor 区使用率;
- E:Eden 使用率;
- O:老年代使用率;
- M:元空间使用率;
- CCS:压缩类空间使用率;
- YGC:Young GC 次数;
- YGCT:Young GC 总耗时;
- FGC:Full GC 次数;
- FGCT:Full GC 总耗时;
- GCT:GC 总耗时。
例如:
S0 S1 E O M YGC YGCT FGC FGCT GCT
0.00 72.40 85.00 68.20 84.30 120 2.1 1 0.8 2.9
如果 E 快速增长、YGC 频繁但 O 稳定,可能是短命对象分配较多;如果 O 持续增长且 Full GC 后也不明显下降,需要重点检查长生命周期引用和缓存。
5. 使用堆转储分析内存泄漏
生成堆转储:
jcmd <pid> GC.heap_dump /tmp/app-heap.hprof
或者:
jmap -dump:live,format=b,file=/tmp/app-heap.hprof <pid>
堆转储可能很大,生成过程也可能影响服务。分析时重点关注:
- 实例数量最多的类;
- 占用浅堆和深堆最大的对象;
- 大集合;
- Map 中的 Key 和 Value;
- ThreadLocal;
- 静态集合;
- 类加载器;
- 缓存对象;
- 请求上下文和会话对象。
常见泄漏链:
静态 Map
-> 用户 ID
-> Session
-> 大量业务对象
或者:
ThreadLocal
-> 请求上下文
-> 大对象
-> 线程长期存活
ThreadLocal 使用完后要及时清理:
private static final ThreadLocal<RequestContext>
CONTEXT = new ThreadLocal<>();
public void handle(Request request) {
try {
CONTEXT.set(buildContext(request));
process(request);
} finally {
CONTEXT.remove();
}
}
6. 排查无界缓存
错误示例:
private final Map<String, UserProfile> cache =
new ConcurrentHashMap<>();
public void put(String userId, UserProfile profile) {
cache.put(userId, profile);
}
如果用户数量不断增长,cache 也会不断增长。应使用有容量和过期策略的缓存:
Cache<String, UserProfile> cache =
Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterAccess(Duration.ofMinutes(30))
.recordStats()
.build();
缓存设计还要考虑:
- 单个对象大小;
- Key 数量;
- 过期时间;
- 淘汰策略;
- 热点数据;
- 重建并发;
- 失效风暴;
- 监控命中率。
7. 排查大对象分配
大对象会快速占用 Eden,甚至直接进入老年代,常见于:
- 大 JSON;
- 文件内容;
- 批量查询结果;
- 大数组;
- 图片和压缩包;
- 全量导出;
- 一次性读取整张表。
错误方式:
List<Order> allOrders =
orderRepository.findAllByDate(date);
String json = objectMapper.writeValueAsString(
allOrders
);
改进方向:
- 分页查询;
- 流式读取;
- 分批序列化;
- 限制返回数量;
- 使用异步导出;
- 通过文件或对象存储中转;
- 避免在内存中同时保留多份副本。
8. 通过 GC 日志判断问题
假设日志表现为:
Young GC 每秒多次
每次暂停几十毫秒
老年代长期保持 20% 到 30%
接口 P99 逐渐升高
可能说明对象分配速率过高,需要检查:
- JSON 序列化;
- 临时集合;
- 字符串拼接;
- 日志格式化;
- ORM 查询对象;
- 大量短生命周期 DTO;
- 缓存反序列化。
另一种表现:
Young GC 正常
老年代持续上涨
Full GC 后下降很少
可能说明:
- 有内存泄漏;
- 缓存没有过期;
- 静态集合持有对象;
- ThreadLocal 未清理;
- 监听器或回调没有注销;
- 类加载器无法回收。
还有一种表现:
GC 次数不多
进程 RSS 持续增长
堆使用正常
需要重点排查:
- 直接内存;
- Native 内存;
- 线程数量;
- 元空间;
- JNI 或第三方本地库;
- 内存映射文件。
9. 线程和内存联合分析
查看线程数量:
jcmd <pid> Thread.print | findstr /C:"tid="
或者在 Linux 中:
ps -eLf | grep java
需要关注:
- 线程总数;
- 线程池线程数;
- BLOCKED 状态;
- WAITING 状态;
- 线程栈是否重复;
- 是否存在大量定时任务;
- 是否有线程泄漏。
线程数量增长可能引起:
线程栈增加
-> Native 内存增加
-> CPU 上下文切换增加
-> GC 线程与业务线程竞争
-> 响应时间上升
10. JFR 采样
Java Flight Recorder 可以用于采集:
- CPU 热点;
- 方法执行;
- 锁竞争;
- IO;
- GC;
- 分配;
- 线程;
- 类加载;
- 网络事件。
启动一个短时间记录:
jcmd <pid> JFR.start \
name=profile \
settings=profile \
duration=60s \
filename=/tmp/app-profile.jfr
记录完成后,可以使用 JDK Mission Control 分析。JFR 的价值在于同时观察 CPU、内存、锁、线程和 GC,而不是只看单一指标。
五、常见问题与实践建议
1. 把 Xmx 设置得越大越好吗
不是。堆太小会频繁 GC,堆太大则可能:
- 单次回收停顿变长;
- 垃圾更多;
- 容器更容易超限;
- 内存泄漏暴露更晚;
- GC 线程消耗更多 CPU;
- 应用启动和预热更慢。
合理大小应该根据:
- 存活对象集合;
- 对象分配速率;
- 延迟目标;
- GC 收集器;
- 容器内存;
- 峰值流量;
- 压测结果;
确定。
2. Full GC 一定是严重问题吗
偶发 Full GC 不一定代表故障,需要结合:
- 发生频率;
- 停顿时间;
- 回收前后老年代;
- 是否影响 P99;
- 是否伴随分配失败;
- 是否持续增长。
如果 Full GC 偶发且很快、业务无感,可能不需要立即调整。但如果频繁发生、回收后占用仍然很高,就必须定位根因。
3. GC 频繁是不是年轻代太小
可能是,但不能直接下结论。还可能是:
- 请求对象分配速率过高;
- 大量临时对象;
- 批量任务;
- 堆总量太小;
- 业务流量突然增加;
- 对象晋升速度过快;
- 收集器并发线程不足。
调整年轻代前,应先查看:
- 每秒分配速率;
- Young GC 频率;
- 每次回收前后使用量;
- Survivor 使用情况;
- 老年代晋升速率;
- GC 停顿比例。
4. 为什么堆回收后使用率仍然很高
可能有三种情况:
正常存活对象较多
缓存、单例、连接池和业务状态本来就应该长期存活。
内存泄漏
对象不再需要,但仍然被引用链持有。
回收器尚未完成整理
并发收集器的某些阶段不会立即将所有空间压缩到最低。
需要通过堆转储、类直方图和引用链确认,不能只看一张内存曲线。
5. 内存泄漏和内存溢出的区别
内存溢出是结果,内存泄漏是可能的原因。
内存泄漏:
对象已经不需要
但仍然被引用
无法被 GC 回收
占用持续增长
内存溢出:
申请内存失败
触发 OutOfMemoryError
内存溢出也可能是正常高峰、堆配置过小、大对象请求、直接内存限制或线程数量过多造成的,不一定是泄漏。
6. 为什么不要随意关闭显式 GC
某些应用会调用 System.gc(),可能触发不必要的 Full GC。可以考虑:
-XX:+DisableExplicitGC
但是否关闭要看第三方库和业务行为。某些场景确实依赖显式 GC 释放直接内存或进行特定资源回收,不能仅凭经验统一关闭。
应先通过代码搜索、JFR 或日志确认是否存在大量显式 GC 调用。
7. 显式调用 System.gc() 能解决内存问题吗
通常不能。它只是在请求 JVM 执行回收,不能解决:
- 无界缓存;
- 静态集合泄漏;
- ThreadLocal 泄漏;
- 类加载器泄漏;
- 直接内存泄漏;
- 大对象设计;
- 线程过多。
如果对象仍然可达,调用 GC 也不会回收。
8. 为什么不能只根据平均响应时间调优
平均响应时间会掩盖长尾:
99 个请求:20ms
1 个请求:5000ms
平均值:约 70ms
对于在线系统,这 1% 的慢请求可能正是用户投诉和级联故障的来源。因此应重点观察:
- P95;
- P99;
- P999;
- 最大暂停;
- GC 暂停与慢请求时间对齐;
- 下游超时;
- 线程池排队时间。
9. Spring Boot 服务需要关注哪些内存指标
建议监控:
jvm_memory_used_bytes
jvm_memory_max_bytes
jvm_gc_pause_seconds
jvm_gc_memory_allocated_bytes_total
jvm_gc_memory_promoted_bytes_total
jvm_threads_live
jvm_threads_peak
process_resident_memory_bytes
process_cpu_usage
同时补充:
- 堆使用率;
- Eden 使用率;
- 老年代使用率;
- 元空间;
- 直接内存;
- GC 次数;
- GC 总耗时;
- Full GC 次数;
- 线程池队列;
- 请求分配率。
10. 如何设置告警
示例告警:
堆使用率连续 5 分钟超过 80%
老年代回收后使用率超过 70%
Full GC 每分钟超过 3 次
GC 暂停 P99 超过 300ms
元空间使用率超过 85%
线程数超过基线两倍
进程 RSS 接近容器限制
OOM 次数大于 0
告警阈值需要结合历史基线,不能只套用固定数字。重点是观察趋势、持续时间和业务影响。
11. 不要在生产环境随意生成大堆转储
堆转储可能:
- 占用大量磁盘;
- 引起明显暂停;
- 影响应用响应;
- 泄露敏感数据;
- 在容器中写满临时目录。
执行前需要确认:
- 磁盘空间;
- 文件保存位置;
- 数据脱敏和访问权限;
- 业务低峰期;
- 是否有替代的类直方图;
- 是否允许短暂停顿。
12. 参数调整要小步进行
不要一次修改十几个参数,否则无法知道哪个参数真正产生了影响。建议:
确定基线
-> 每次只调整一组相关参数
-> 运行相同压测
-> 对比吞吐、P99、GC 和 RSS
-> 保留结果
-> 再决定下一步
参数必须和 JDK 版本、收集器及部署方式一起记录。
六、进阶思考
1. GC 调优的本质是控制对象生命周期
很多 GC 问题的根因不是收集器参数,而是对象生命周期设计不合理:
请求范围对象
-> 被缓存长期持有
-> 生命周期被意外延长
-> 老年代不断增长
优化方向包括:
- 缩短对象引用链;
- 限制缓存容量;
- 避免把大对象放入会话;
- 及时清理 ThreadLocal;
- 控制批量查询大小;
- 使用流式处理;
- 减少不必要的对象复制;
- 避免重复序列化和反序列化。
2. 分配速率比堆大小更重要
假设两个服务都使用 4 GB 堆:
服务 A:每秒分配 100 MB,存活 10%
服务 B:每秒分配 1 GB,存活 1%
服务 B 可能产生更多 Young GC,因为垃圾产生速度更快,即使最终存活对象很少。
因此应关注:
Allocation Rate:单位时间内新分配对象的大小
Promotion Rate:单位时间内晋升老年代的大小
Live Set:长期存活对象集合大小
如果分配速率很高,优先检查代码和业务负载,而不是只增加堆。
3. 存活对象集合决定基础内存
堆中必须容纳长期存活对象。可以把内存需求粗略表示为:
最小堆需求
>= 存活对象集合
+ GC 工作空间
+ 短期对象缓冲
+ 收集器辅助结构
如果存活集合已经接近 Xmx,GC 即使高频运行也没有足够空间应对短期对象。
通过堆转储可以估算主要存活对象类型,通过多次采样可以区分正常缓存和异常增长。
4. JIT 编译与 Code Cache
Java 服务运行一段时间后,热点方法会被 JIT 编译为机器码。编译后的代码存放在 Code Cache 中。
相关信息可以通过:
jcmd <pid> Compiler.codecache
查看。Code Cache 过小可能导致:
- 编译代码被回收;
- 热点方法无法继续优化;
- 性能波动;
- 日志中出现编译相关警告。
这类问题通常不是 Java 堆问题,应单独观察。
5. 锁竞争也会表现为性能下降
如果线程大量处于 BLOCKED 或 WAITING,接口变慢可能与 GC 无关。
可以使用:
jcmd <pid> Thread.print -l
排查:
- 哪些锁被大量等待;
- 哪个线程持有锁;
- 是否存在大粒度 synchronized;
- 是否有数据库连接池等待;
- 是否有线程池队列阻塞;
- 是否有 Future.get 长时间等待。
JFR 可以帮助分析锁竞争和线程阻塞时间。
6. 性能优化需要结合业务压测
只压测一个简单接口,不能代表整个服务。压测场景至少要包含:
- 真实请求比例;
- 缓存命中和未命中;
- 正常和异常流量;
- 大小不同的请求;
- 慢下游;
- 数据库热点;
- 并发用户数;
- 持续时间;
- 流量峰值。
同时记录压测期间:
QPS
P50/P95/P99
GC 次数和停顿
堆使用
老年代晋升
CPU
线程
数据库连接
下游延迟
容器 RSS
7. JVM 参数需要版本化
建议把 JVM 参数和应用配置一起纳入版本管理:
java:
version: "17"
options:
- "-Xms2g"
- "-Xmx2g"
- "-XX:+UseG1GC"
- "-XX:MaxGCPauseMillis=200"
- "-XX:MaxDirectMemorySize=512m"
这样可以追踪:
- 哪次发布修改了参数;
- 哪个环境使用了不同配置;
- 性能变化是否与参数相关;
- 回滚时如何恢复。
8. JDK 升级也属于性能优化
新版本 JDK 可能带来:
- GC 改进;
- JIT 改进;
- 类加载优化;
- 容器感知改进;
- 诊断工具增强;
- 并发库优化;
- 默认参数变化。
但升级不能只看基准测试,需要验证:
- 依赖兼容性;
- 启动参数;
- 监控指标;
- GC 行为;
- 序列化和网络组件;
- 线上流量模型;
- 回滚方案。
9. 建立 JVM 性能基线
每个服务都应该有一份性能基线:
正常 QPS
平均和 P99 延迟
CPU 使用率
堆使用率
老年代增长速度
Young GC 频率
Full GC 频率
GC 暂停 P99
线程数
进程 RSS
数据库连接池
出现问题时,先与基线对比,判断是流量变化、代码变化、依赖变化还是 JVM 参数变化。
结论
JVM 性能优化的第一步不是修改 GC 参数,而是建立正确的内存和执行模型。
需要区分:
- 堆与非堆;
- JVM 运行时数据区与 Java 内存模型;
- Java 堆与直接内存;
- 正常对象存活与内存泄漏;
- GC 停顿与锁竞争;
- 平均延迟与长尾延迟。
GC 调优的基本方法是:
观察现象
-> 建立基线
-> 采集 GC、线程和内存数据
-> 判断主要瓶颈
-> 优先优化对象生命周期和业务代码
-> 小步调整 JVM 参数
-> 压测验证
-> 灰度发布
-> 持续监控
在实践中,应重点关注对象分配速率、老年代晋升、存活对象集合、堆外内存、线程数量和 GC 停顿,而不是只看 Xmx 或一次 Full GC。
当代码、缓存、线程池、数据库和 JVM 参数共同被纳入容量模型后,Java 服务的性能问题才真正具备可诊断、可验证和可持续优化的基础。
至此,系列一“Java 后端工程进阶之路”的 10 篇基础与进阶主题已经完成。后续可以围绕具体项目继续扩展,例如 JVM 源码分析、线上故障排查、MySQL 深度优化、Redis 集群和微服务可观测性。