文档目录

2.5 内存与 GC:分配速率才是关键

上一节:2.4 预热、稳态与冷启动 | 下一节:2.6 并发原语成本与伪共享 配套代码:02-jvm-performance-basics/05-memory-and-gc


一句话结论

决定 GC 压力的是「每秒产生多少垃圾」,不是「堆有多大」。 所以 JVM 上的优化目标不是「零分配」,而是降低分配速率和减少对象存活时间。这两件事的效果,比调堆大小大得多。


二、用办公室垃圾桶理解 GC

假设一个办公室,每个人都在往垃圾桶里扔废纸:

概念 类比 技术含义
扔废纸 分配对象 new / 创建对象
垃圾桶容量 堆大小 -Xmx
扔废纸的速度 分配速率 MB/s——最关键
保洁员来倒垃圾 GC 回收不可达对象
倒垃圾时全员停手 GC 停顿 Stop-The-World
有些文件不能扔,要归档 对象晋升 从年轻代到老年代
每人一个自己的小垃圾桶 TLAB 线程本地分配缓冲,避免排队领纸
一个巨大的纸箱 Humongous 对象 塞不进普通桶,触发特殊处理

从这个类比能推出三个关键结论:

  1. 垃圾桶越大,倒的次数越少,但每次倒的时间越长。 → 堆不是越大越好。
  2. 如果大家扔得飞快,保洁员就要频繁来倒,全员频繁停手。 → 分配速率是核心指标。
  3. 每人有自己的小垃圾桶(TLAB),所以「扔纸」这个动作本身几乎不花时间。 → 分配本身很便宜,代价在 GC。

第 3 条是最反直觉的:从 C++ 过来的人会本能地认为「分配很贵,要尽量少分配」。在 JVM 上,分配一个普通对象就是「在一个线程私有区域里移动一下指针」(TLAB 里的指针碰撞),比一次内存访问还快。真正贵的是这些对象累积起来导致的 GC。


三、TLAB:为什么分配这么快

TLAB(Thread Local Allocation Buffer,线程本地分配缓冲):每个线程在 Eden 区里预先划走一小块(默认约占 Eden 的 1%),分配对象时直接在自己这块区域里移动指针,不需要任何同步。

传统理解:分配对象 → 找一块空闲内存 → 加锁 → 分配 → 解锁
实际情况:分配对象 → 在「我自己的」这块区域的指针上 +N → 完成

推论:

  • 多线程分配不会互相竞争(各自在自己的 TLAB 里)。
  • 分配速率极高(每秒几亿字节是常见的)。
  • 只有当 TLAB 用完需要换一块时,才会有极小的同步开销。

所以「对象池」在 JVM 上通常是负优化:分配这么便宜,你费劲维护一个池(同步、生命周期、残留状态),往往比直接分配更慢。


四、堆大小 vs 分配速率:一个具体对比

场景 堆大小 分配速率 结果
A 4 GB 10 MB/s 每 400 秒填满一次 Eden,GC 频率低,停顿可接受
B 4 GB 500 MB/s 每 8 秒就要回收一次,GC 频率高,停顿频繁
C 16 GB 500 MB/s Eden 变大,频率降低,但单次停顿变长

结论:

  • A → B 的差别是分配速率造成的,加大堆只能缓解频率,不能解决根本。
  • C 说明「加大堆」的代价:单次 GC 停顿变长(因为要扫描/复制的区域更大),而且可能触发更长时间的 Full GC。

正确的优化顺序:先降低分配速率(找分配热点)→ 再考虑堆大小与 GC 选型。


五、GC 选型的取舍(不要照抄别人的参数)

收集器 特点 适合
G1(现代 JDK 默认) 吞吐与停顿折中,可设停顿目标 大多数场景的默认选择
Parallel 吞吐最高,停顿较长 批处理、离线任务、对延迟不敏感
ZGC 停顿极低(亚毫秒级),牺牲部分吞吐 延迟敏感、堆很大(几十 GB 以上)
Shenandoah 低停顿,与 ZGC 定位类似 视 JDK 发行版而定

三条纪律:

  1. 必须用自己的负载压测决定,不能照抄网上参数。
  2. 一次只改一个参数,用 GC 日志 + 延迟分布验证。
  3. 注意取舍:ZGC 换低停顿通常要牺牲吞吐。如果你的瓶颈是吞吐而不是尾延迟,换 ZGC 可能让整体变差。

六、Humongous 对象:一个隐形杀手

在 G1 中,如果一个对象的大小超过 region 的一半(region 大小通常 1–32 MB),它被称为 Humongous 对象,会被特殊处理:直接在老年代分配、可能连续占用多个 region、并且可能提前触发 GC。

常见的 Humongous 对象:

  • 大数组(ByteArray(10_000_000))
  • 一次性读入整个文件/整个响应体
  • 大字符串(比如拼接出来的 JSON)
  • 大图片/大缓存的序列化结果

排查方法:打开 GC 日志,搜索 Humongous:

java -Xlog:gc+humongous=info -jar app.jar

修法:把大对象拆小、改成流式处理、或复用缓冲区(这是少数「对象池确实有用」的场景——复用大 ByteArray)。


七、GC 日志怎么读

必须开的参数:

java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m -jar app.jar

最该关注的四个信息:

看什么 说明
GC 频率 Young GC 多久一次?如果几秒一次,说明分配速率太高
停顿时间 每次 GC 停了多久?和延迟分布的尖刺是否时间对齐?
回收效果 每次 GC 回收了多少?如果回收得越来越少,可能有内存泄漏或对象在晋升
Full GC 出现 Full GC 通常意味着配置不当或内存泄漏,必须查明

最有价值的一步:把 GC 停顿时间戳和 P99 尖刺时间戳对齐。

  • 如果对齐 → GC 就是尾延迟的原因。
  • 如果不对齐 → GC 不是根因,别在 GC 上浪费时间(这是排除假设的关键证据,第 6 章会用到)。

八、怎么找分配热点

分配火焰图(async-profiler 的 alloc 事件):

asprof -d 60 -e alloc -f alloc.html <pid>

或者用 JFR:

jcmd <pid> JFR.start name=alloc settings=profile duration=60s filename=alloc.jfr
# 用 JDK Mission Control 打开,看 jdk.ObjectAllocationSample 事件

或者看指标:Micrometer 的 jvm_gc_memory_allocated_bytes_total 能直接给你分配速率的趋势。

Kotlin 里常见的分配热点:

写法 问题
data class 的 copy() 每次调用分配新对象,热路径上频繁调用很贵
装箱 List<Int> 会装箱;热路径应用 IntArray
字符串拼接 "$a-$b" 每次分配新 String(小量无所谓,循环里很贵)
lambda 捕获 捕获外部变量的 lambda 每次执行可能分配一个对象
Sequence 链 每个中间操作可能分配
Regex 重复编译 每次 Regex("...") 都要编译(第 6 章的埋雷之一)
日志字符串 log.debug("x=$x") 在低级别也会拼接字符串

九、本节小结

  1. 决定 GC 压力的是分配速率(MB/s),不是堆大小。
  2. 分配本身很便宜(TLAB 指针碰撞),代价在 GC;所以优化目标是「降低分配速率 + 缩短对象存活时间」,不是「零分配」。
  3. 加大堆能降低 GC 频率,但会拉长单次停顿——不是越大越好。
  4. GC 选型必须用自己的负载压测决定;一次只改一个参数。
  5. Humongous 对象会引发意外的 GC,大数组/大字符串要警惕。
  6. 判断 GC 是不是根因,看停顿时间戳与延迟尖刺是否对齐——这是排除假设的关键证据。
  7. 用分配火焰图找分配热点,而不是靠猜。

十、自测

  1. 一个服务的堆从 4 GB 加到 8 GB,GC 频率降了一半,但 P99 反而变差了。请解释原因。
  2. 两个服务:A 分配速率 20 MB/s 堆 4 GB;B 分配速率 400 MB/s 堆 16 GB。哪个的 GC 问题更严重?为什么?
  3. 有人建议「为了减少 GC,把所有对象池化复用」。请说出这个建议在 JVM 上的三个问题。
  1. 因为堆变大 → 单次 GC 的扫描/复制区域变大 → 单次停顿变长。GC 频率降低减少了「小停顿」的次数,但每次停顿更久,于是 P99(尾部)反而恶化。另外还有一个可能:Eden 变大后,对象有更多时间「存活」并被晋升到老年代,导致老年代压力上升、可能触发更长的 mixed GC 或 Full GC。正确方向:先降低分配速率,而不是盲目加堆;如果已经加了堆,要观察停顿时间分布是否变化,并考虑调整新生代比例。
  2. B 更严重。因为 GC 压力由分配速率决定,400 MB/s 意味着每秒产生大量垃圾——即使堆是 16 GB,Eden 也会被飞快填满,GC 频繁触发;而且高分配速率往往伴随更高的晋升速率,老年代压力更大。A 的 20 MB/s 配 4 GB 堆,GC 间隔长、每次回收都有充足空间,反而更健康。结论:堆大小只能影响「多久回收一次」,分配速率决定「总共要回收多少」——后者才是根本。
  3. ① 分配本身很便宜(TLAB 指针碰撞),池化的收益本来就小;② 池化引入同步成本——从池里取对象、还对象都需要并发安全的数据结构,无竞争时也比直接分配慢;③ 生命周期管理风险——忘记归还导致「泄漏」,归还时没清理干净导致「状态污染」(比内存泄漏更难查的 bug);④ 破坏逃逸分析——本来 JIT 可以栈上分配的对象,放进池里反而必须真正分配。例外:确实昂贵的大对象(大 ByteArray、直接内存缓冲、数据库连接、线程)值得池化。