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 对象 | 塞不进普通桶,触发特殊处理 |
从这个类比能推出三个关键结论:
- 垃圾桶越大,倒的次数越少,但每次倒的时间越长。 → 堆不是越大越好。
- 如果大家扔得飞快,保洁员就要频繁来倒,全员频繁停手。 → 分配速率是核心指标。
- 每人有自己的小垃圾桶(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 发行版而定 |
三条纪律:
- 必须用自己的负载压测决定,不能照抄网上参数。
- 一次只改一个参数,用 GC 日志 + 延迟分布验证。
- 注意取舍: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") 在低级别也会拼接字符串 |
九、本节小结
- 决定 GC 压力的是分配速率(MB/s),不是堆大小。
- 分配本身很便宜(TLAB 指针碰撞),代价在 GC;所以优化目标是「降低分配速率 + 缩短对象存活时间」,不是「零分配」。
- 加大堆能降低 GC 频率,但会拉长单次停顿——不是越大越好。
- GC 选型必须用自己的负载压测决定;一次只改一个参数。
- Humongous 对象会引发意外的 GC,大数组/大字符串要警惕。
- 判断 GC 是不是根因,看停顿时间戳与延迟尖刺是否对齐——这是排除假设的关键证据。
- 用分配火焰图找分配热点,而不是靠猜。
十、自测
- 一个服务的堆从 4 GB 加到 8 GB,GC 频率降了一半,但 P99 反而变差了。请解释原因。
- 两个服务:A 分配速率 20 MB/s 堆 4 GB;B 分配速率 400 MB/s 堆 16 GB。哪个的 GC 问题更严重?为什么?
- 有人建议「为了减少 GC,把所有对象池化复用」。请说出这个建议在 JVM 上的三个问题。
- 因为堆变大 → 单次 GC 的扫描/复制区域变大 → 单次停顿变长。GC 频率降低减少了「小停顿」的次数,但每次停顿更久,于是 P99(尾部)反而恶化。另外还有一个可能:Eden 变大后,对象有更多时间「存活」并被晋升到老年代,导致老年代压力上升、可能触发更长的 mixed GC 或 Full GC。正确方向:先降低分配速率,而不是盲目加堆;如果已经加了堆,要观察停顿时间分布是否变化,并考虑调整新生代比例。
- B 更严重。因为 GC 压力由分配速率决定,400 MB/s 意味着每秒产生大量垃圾——即使堆是 16 GB,Eden 也会被飞快填满,GC 频繁触发;而且高分配速率往往伴随更高的晋升速率,老年代压力更大。A 的 20 MB/s 配 4 GB 堆,GC 间隔长、每次回收都有充足空间,反而更健康。结论:堆大小只能影响「多久回收一次」,分配速率决定「总共要回收多少」——后者才是根本。
- ① 分配本身很便宜(TLAB 指针碰撞),池化的收益本来就小;② 池化引入同步成本——从池里取对象、还对象都需要并发安全的数据结构,无竞争时也比直接分配慢;③ 生命周期管理风险——忘记归还导致「泄漏」,归还时没清理干净导致「状态污染」(比内存泄漏更难查的 bug);④ 破坏逃逸分析——本来 JIT 可以栈上分配的对象,放进池里反而必须真正分配。例外:确实昂贵的大对象(大
ByteArray、直接内存缓冲、数据库连接、线程)值得池化。