文档目录

2.5 配套代码:分配速率、GC 日志与 Humongous 对象

对应小节:2.5 内存与 GC 三件事:① 亲眼看到「分配速率决定 GC 频率」② 读懂 GC 日志 ③ 复现 Humongous 对象的陷阱。

一、实验:分配速率与 GC 频率的关系

// src/main/kotlin/lesson02/AllocationRate.kt
package lesson02

import java.lang.management.ManagementFactory
import kotlin.random.Random

private val threadBean = ManagementFactory.getThreadMXBean() as com.sun.management.ThreadMXBean

fun allocatedMb(): Double =
    threadBean.getThreadAllocatedBytes(Thread.currentThread().threadId()) / 1024.0 / 1024.0

fun gcCount(): Long =
    ManagementFactory.getGarbageCollectorMXBeans().sumOf { it.collectionCount }

fun gcTimeMs(): Long =
    ManagementFactory.getGarbageCollectorMXBeans().sumOf { it.collectionTime }

/**
 * 分配指定大小的"垃圾",观察 GC 频率。
 * @param bytesPerIteration 每次迭代分配的字节数
 */
fun allocateGarbage(bytesPerIteration: Int, iterations: Int) {
    val rnd = Random(42)
    for (i in 0 until iterations) {
        val arr = ByteArray(bytesPerIteration)
        arr[0] = rnd.nextInt().toByte()          // 防止整个分配被消除
        if (arr[0] == Byte.MAX_VALUE) println("never")   // 让 arr 有逃逸可能
    }
}

fun measureScenario(label: String, bytesPerIteration: Int, iterations: Int) {
    val gcBefore = gcCount()
    val gcTimeBefore = gcTimeMs()
    val allocBefore = allocatedMb()

    val t0 = System.nanoTime()
    allocateGarbage(bytesPerIteration, iterations)
    val elapsedMs = (System.nanoTime() - t0) / 1_000_000.0

    val allocMb = allocatedMb() - allocBefore
    val gcDelta = gcCount() - gcBefore
    val gcTimeDelta = gcTimeMs() - gcTimeBefore

    println("%-26s %10.1f MB  分配速率 %8.1f MB/s  GC次数 %3d  GC总停顿 %5d ms"
        .format(label, allocMb, allocMb / (elapsedMs / 1000), gcDelta, gcTimeDelta))
}

fun main() {
    println("=== 分配速率 → GC 频率 的关系 ===")
    println("(堆默认大小;如果想看得更明显,用 -Xmx256m 运行)\n")
    println("%-26s %13s  %21s  %14s  %18s".format("场景", "总分配量", "分配速率", "GC次数", "GC停顿"))

    // 同样的总分配量,但速率不同
    measureScenario("小对象慢速分配", 1_024, 200_000)          // 200 MB,分 20 万次
    measureScenario("小对象快速分配", 1_024, 2_000_000)        // 2 GB
    measureScenario("大对象分配", 1_048_576, 2_000)            // 2 GB,每次 1 MB

    println()
    println("预期观察:")
    println("  1. 分配速率越高,GC 次数越多(这就是「分配速率决定 GC 频率」)")
    println("  2. 大对象场景的 GC 行为不同——1MB 的数组在 G1 下可能触发 Humongous 分配")
    println()
    println("⚠️ 这个实验会占用较多内存和 CPU,建议用 -Xmx256m 限制堆,并在空闲机器上跑。")
}

运行建议:

# 堆限制小一点,GC 现象更明显
java -Xmx256m -Xlog:gc*:file=gc-alloc.log:time,uptime,level,tags \
     -cp build/classes/kotlin/main lesson02.AllocationRateKt

二、预期输出形态

场景                              总分配量              分配速率        GC次数            GC停顿
小对象慢速分配                    200.0 MB         850.3 MB/s          12              45 ms
小对象快速分配                   2000.0 MB        3200.7 MB/s         118             620 ms
大对象分配                       2000.0 MB        2800.1 MB/s         203            1890 ms

三个值得注意的点:

观察 含义
分配速率 ×3.8,GC 次数 ×9.8 GC 频率对分配速率是非线性敏感的
大对象的 GC 次数更多、停顿更长 Humongous 分配会提前触发 GC
总分配量相同,但 GC 行为完全不同 「分配了多少」不如「多快分配」重要

三、读 GC 日志

# 打开 GC 日志(生产环境必开)
java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m -jar app.jar

# 只看关键行
grep -E "Pause Young|Pause Full|Humongous" gc.log | head -30

# 看 Humongous 分配(G1 特有)
java -Xlog:gc+humongous=info -jar app.jar

日志行解读:

[2025-01-15T10:23:45.123+0800][12.456s][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 128M->32M(256M) 4.231ms
   │                          │        │      │     │                                    │         │        │
   时间戳                      运行时长  GC序号  类型   子类型                              回收前→后  堆容量   停顿
看什么 判断
Pause Young 的频率 几秒一次 → 分配速率太高
Pause Full 出现 ⚠️ 配置不当或内存泄漏,必须查明
停顿时间(末尾 ms) 与延迟分布的尖刺时间对齐吗?
回收前->后 如果回收后仍居高不下 → 对象在晋升或有泄漏
Humongous 大对象,检查是否有大数组/大字符串

关键动作:把 GC 停顿和 P99 尖刺对齐

# 把 GC 停顿时间戳提取出来
grep "Pause Young" gc.log | awk '{print $1, $NF}' > gc-pauses.txt

# 然后和你的延迟监控(Prometheus 的 P99 尖刺时间点)对照
# 对齐 → GC 是根因;不对齐 → 排除 GC,别在这里浪费时间

这是第 6 章「排除假设」的标准动作。先把 GC 排除掉,再看别的地方,能省下大量时间。

四、复现 Humongous 对象陷阱

// src/main/kotlin/lesson02/HumongousDemo.kt
package lesson02

import kotlin.random.Random

private var sink = 0

/**
 * 在 G1 中,超过 region 大小一半的对象是 Humongous 对象,会:
 *   - 直接在老年代分配
 *   - 可能连续占用多个 region
 *   - 可能【提前触发 GC】
 */
fun main() {
    val rnd = Random(42)
    println("region 大小 = %.1f MB(用 -XX:+PrintFlagsFinal | grep G1HeapRegionSize 查看)"
        .format(1.0))

    // 场景 A:小对象(远小于 region 的一半)
    println("\n场景 A:每次分配 256 KB(非 Humongous)")
    repeat(10_000) {
        val a = ByteArray(256 * 1024)
        a[0] = rnd.nextInt().toByte()
        sink += a[0]
    }
    println("  完成,观察 GC 日志里的 Humongous 计数")

    // 场景 B:大对象(超过 region 的一半 → Humongous)
    println("\n场景 B:每次分配 4 MB(Humongous!)")
    repeat(2_000) {
        val a = ByteArray(4 * 1024 * 1024)
        a[0] = rnd.nextInt().toByte()
        sink += a[0]
    }
    println("  完成,对比 GC 日志")

    println("\nsink = $sink")
    println("\n对比方法:分别运行两次,比较 GC 日志里:")
    println("  - GC 总次数")
    println("  - 是否有 'Humongous' 相关日志")
    println("  - 停顿时间总和")
}
# 场景 A
java -Xmx512m -Xlog:gc+humongous=info:file=gc-small.log -cp build/classes/kotlin/main lesson02.HumongousDemoKt

# 场景 B(把 repeat 里的 256KB 改成 4MB 再跑一次)
# 或者直接用 -XX:G1HeapRegionSize 调整 region 大小来观察阈值变化

修法(对真实项目):

问题 修法
大数组一次性读入 改成流式处理 / 分块读取
大字符串(拼接出的 JSON) 用流式序列化,避免构造完整字符串
大缓存的序列化结果 改成增量处理或复用缓冲区(这是少数对象池确实有效的场景)

五、找分配热点

# 分配火焰图(async-profiler)
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 指标(分配速率的趋势)
curl -s localhost:8080/metrics | grep jvm_gc_memory_allocated_bytes_total

Kotlin 常见分配热点(在第 5 节正文里有完整清单):

// 1. data class copy()
val updated = order.copy(status = "PAID")     // 热路径上频繁调用 → 大量分配

// 2. 装箱
val list: List<Int> = ...                     // 每个 Int 装箱
val array: IntArray = ...                     // 无装箱

// 3. 字符串拼接(循环内)
repeat(1000) { result += "$i," }              // 每次分配新 String
// ✅ 改成 StringBuilder

// 4. Regex 重复编译
Regex("""\d{3}""").findAll(text)              // ❌ 每次编译
private val RE = Regex("""\d{3}""")           // ✅ 提到顶层

六、动手改造

改动 观察什么
用 -Xmx64m 跑分配实验 GC 频率大幅上升、可能 OOM——这就是堆太小的表现
用 -Xmx2g 跑同样的实验 GC 频率下降,但单次停顿变长——「堆越大停顿越长」的验证
用 -XX:+UseZGC 再跑一次 停顿变成亚毫秒级,但总吞吐可能下降(用总耗时对比)
把 ByteArray(4MB) 换成 ByteArray(1MB) 并调小 region 观察 Humongous 阈值随 region 大小变化
在实验里加上 -prof gc(如果用 JMH) 直接得到 gc.alloc.rate,比手工计算更规范

七、这段代码的局限

  • ThreadMXBean.getThreadAllocatedBytes 是 JDK 内部 API,且只统计当前线程(不含其他线程)。用于对比可以,精确测量请用 JMH 的 -prof gc 或 async-profiler。
  • GC 行为强烈依赖 JDK 版本、GC 选型、堆大小、region 大小,你的数字会和示例不同。看趋势,不看绝对值。
  • 这个实验会真实消耗内存与 CPU,不要在重要环境跑。
  • 不要用这个实验的结果去调你的生产 JVM 参数——生产调优必须用真实负载(第 7 章)。