先给结论:你大概率撞了两个已知行为叠加——Guava Cache 的 weigher 确实没算到嵌套对象(你的怀疑 2 基本是对的),加上 G1 的 "old gen 不主动回缩" 行为(不是 bug,是设计)。Future 残留引用(你的怀疑 3)在 40 万条场景下确实会撑住几百 MB,但 GC 能回收,问题在于回收后 old gen 的 region 不会归还 OS,所以 jcmd VM.heap_info 看到的"不回落"是正常的。
下面按你问的 3 点逐一给方法。
1. 运行时直接测 Guava Cache 真实驻留
不用 heap dump,两种方法:
(a) Guava 自带 Statistics(推荐,零侵入)
如果建 cache 时加了 .recordStats(),直接读:
CacheStats stats = cache.stats();
// entryCount() = 当前 entry 数
// hitCount() / missCount() = 命中率
// 但 stats 不告诉你内存大小
要算真实内存,写一个 MBean 在运行时遍历(注意不要在请求线程做,放一个低优先级线程):
// 用 WeakHashMap 避免强引用,遍历后立刻释放
long totalWeight = 0;
for (Cache.Entry<Object, Object> e : cache.asMap().entrySet()) {
totalWeight += weigher.apply(e.getKey(), e.getValue());
}
// 但你的 weigher 按 JSON 字节算,嵌套对象没算到
// 所以还要额外用 Instrumentation 算真实 retained:
(b) 用 JFR + jdk.ObjectAllocationInNewTLAB 事件,过滤到 Guava Cache 的分配点
jcmd <pid> JFR.start duration=60s filename=/tmp/guava.jfr \
settings=profile \
jdk.ObjectAllocationInNewTLAB#enabled=true \
jdk.ObjectAllocationInNewTLAB#stackTrace=true
然后用 JMC 打开,过滤 com.google.common.cache.LocalCache 包下的分配事件,看 allocation rate + 对象大小分布。这能直接告诉你 cache 里对象到底多大,比 weigher 算的 JSON 字节数准。
(c) 最粗暴但有效:jcmd <pid> GC.class_histogram | head -30
num #instances #bytes class name
1: 400234 12807488 com.example.Sku
2: 398102 9554448 com.example.SkuSpec
3: 395000 9480000 com.example.AttributeNode
如果 SkuSpec 和 AttributeNode 的数量接近 Sku 的数量,说明嵌套对象确实被 cache 驻留了,weigher 没算到。
2. G1 old gen 不主动回缩——这是设计,不是 bug
G1 的 mixed GC 会回收 old region,但回收后的 region 不会立刻归还 OS,而是标记为 free 留在 heap 里。所以:
jcmd VM.heap_info显示的 "used" 不下降 → 正常- 但 GC 日志里
GC Humongous Allocation或G1 Evacuation Pause的->值如果稳定在 3.8g 不降 → 说明有 1.4g 的 live 对象真的在 old gen 里(你的 Guava Cache + 连接池)
看对象晋升路径:
# JFR 抓 G1 的 promotion 事件
jcmd <pid> JFR.start duration=120s filename=/tmp/promote.jfr \
jdk.G1OldGeneration#enabled=true \
jdk.ObjectAllocationInNewTLAB#enabled=true \
jdk.ObjectAllocationOutsideTLAB#enabled=true
JMC 里看 jdk.ObjectAllocationInNewTLAB 事件,按 objectSize 降序排列,过滤 stackTrace 里包含 LocalCache 或 CompletableFuture 的条目。
关键参数(如果确认是 G1 不回收的问题):
-XX:G1HeapRegionSize=16m # 调大 region,减少碎片
-XX:G1MixedGCLiveThresholdPercent=85 # 降低 mixed GC 触发阈值(默认 85)
-XX:G1ReservePercent=15 # 给 evacuation 留更多余量
但这些是调 GC 行为,不解决"对象真的活着"的问题。你的 1.8g Guava Cache 是 live 的,调 GC 参数不会让它消失。
3. Future 残留引用——JFR 能看分配点,但看不到引用链
直接回答你的问题:JFR 的 jdk.ObjectAllocationInNewTLAB 只能告诉你"谁分配了"(allocation site + stack trace),不能告诉你"谁还引用着"(reference chain)。 要看引用链,只有 heap dump + MAT 的 "Path to GC Roots" 能做。
但 40 万个 CompletableFuture 的残留引用,你可以用这个方式不用 dump 就验证:
# 任务结束后 30 分钟,看 heap 里还有多少 CompletableFuture
jcmd <pid> GC.class_histogram | grep -i "CompletableFuture\|AtomicReference\|trace"
如果 CompletableFuture 的 instance count 还是 ~40 万,说明还有引用链撑着;如果降到 <1 万,说明 GC 已经回收了,剩下的 200-400MB 不是 future 的问题,而是 Guava Cache 的问题。
踩坑清单(我们踩过的):
- Guava Cache 的
weigher如果按toString().length()或JSON 字节数算,嵌套对象(SKU → 规格 → 属性树)的内存完全没算到。我们当时 weigher 算出来 800MB,实际 MAT 看 retained 2.1g,差了 2.6 倍。修法:weigher 里递归算对象图,或者干脆用maximumWeight+ 定期cleanUp()+ 监控stats().entryCount()做兜底。 CompletableFuture的 completion 链上挂的AtomicReference<Exception>如果 task 完成后没显式set(null),整个 completion 链(包括 trace 上下文)都会被 GC root 链撑着。修法:task 完成后用whenComplete回调里显式清理大对象引用。- G1 的
-XX:G1MaxNewSizePercent默认 50%,你的 40 万条 SKU 临时对象如果全晋升到 old gen,young gen 就变小了,young GC 频率自然涨。考虑把-XX:G1MaxNewSizePercent调到 70,给 young gen 多留点空间,减少晋升。
从运维监控角度补一个"不 dump 也能发现阶梯式恶化"的值班技巧:我们给 JVM 进程配了 cgroup 的 memory.current 采集(15s 粒度),配合 GC 日志里的 "G1 Evacuation Pause" 前后 used 值做差值。关键不是看绝对值,而是看"每 4 小时周期结束后,used 的 delta 是否逐轮递增"——如果第 1 轮 +180MB、第 2 轮 +220MB、第 3 轮 +310MB,基本可以断定有对象在逐轮晋升且不被回收,不用等 heap dump。另外你提到的 CompletableFuture 残留引用,我们当时用了一个更粗暴的办法:任务结束 10 分钟后跑 jcmd <pid> GC.run 强制一次 Full GC,然后立刻看 class_histogram 里 CompletableFuture 的数量——如果 Full GC 后还是 40 万,说明有强引用链撑着;如果降到 <1 万,说明只是 mixed GC 还没轮到那批 old region,属于"延迟回收"而非泄漏。这个技巧在凌晨值班时特别好用,30 秒出结论,不用等 JFR 跑 60 秒。
从算法工程师视角补一个"量化判断"的维度:你提到的 weigher 嵌套对象没算到,本质是对象图遍历 vs 序列化字节数的口径差异。我们团队做指标体系时踩过完全同构的坑——把"字段数"当权重,结果嵌套 JSON 的真实内存是字段数的 3~5 倍。建议补一个可量化的判据:用 MAT 的 Retained Heap / weigher 计算值 的比值(你文中提到 2.6 倍),如果 >2 就说明 weigher 严重低估,需要换成 Instrumentation 的 shallowSize + 递归 retained 估算。另外 class_histogram 那个技巧很妙,但注意 GC.class_histogram 本身会触发一次 STW(虽然很短),生产环境如果 p99 敏感,建议放到 JFR 的 jdk.ClassLoading 事件里离线看,避免叠加延迟。