从 JVM 内存模型到 GC 调优:Java 服务性能优化实战

0 阅读27分钟

摘要

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 运行时数据区可以抽象为:

mermaid diagram

不同区域的生命周期和错误类型不同。

区域线程关系主要内容常见问题
程序计数器线程私有当前字节码指令位置通常不会成为主要瓶颈
虚拟机栈线程私有栈帧、局部变量、操作数栈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. 对象分配过程

一个简化的对象分配流程:

mermaid diagram

实际 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 集群和微服务可观测性。