文档目录

7.7 配套代码:GC 对比实验与参数确认

对应小节:7.7 JVM 调优 三件事:① 对比不同 GC;② 确认 JVM 实际参数;③ 堆大小与停顿的关系实验。

一、GC 对比实验脚本

#!/usr/bin/env bash
# tools/compare-gc.sh <EXP_ID> [DURATION]
#
# 用同一份代码、同一负载,对比不同 GC 的延迟与吞吐。
#
# ⚠️ 关键:必须同时看延迟(P99)、吞吐(QPS)、CPU——只看延迟会被误导。
set -uo pipefail

EXP_ID="${1:?usage: compare-gc.sh <exp_id> [duration]}"
DURATION="${2:-3m}"
DIR="docs/experiments/${EXP_ID}/results"
mkdir -p "$DIR"

# 要对比的 GC 配置
declare -a CONFIGS=(
  "G1:-XX:+UseG1GC -Xms2g -Xmx2g"
  "ZGC:-XX:+UseZGC -Xms2g -Xmx2g"
  "Parallel:-XX:+UseParallelGC -Xms2g -Xmx2g"
)

echo "═══ GC 对比实验 ═══"
echo "  实验编号: $EXP_ID"
echo "  每配置时长: $DURATION"
echo

tools/collect-env.sh "$EXP_ID" > /dev/null 2>&1 || true
tools/check-environment.sh "$EXP_ID" > /dev/null 2>&1 || true

for ENTRY in "${CONFIGS[@]}"; do
  GC="${ENTRY%%:*}"
  ARGS="${ENTRY#*:}"

  echo "───────── 配置:$GC ─────────"
  echo "  JVM 参数: $ARGS"

  # 启动服务(带 GC 日志)
  JVM_ARGS="$ARGS -Xlog:gc*:file=$DIR/gc-$GC.log:time,uptime,level,tags" \
    scripts/restart-app.sh "$DIR/gc-$GC-console.log" &
  sleep 8

  # 预热(不计入统计)
  BASE_URL=http://127.0.0.1:8080 k6 run --quiet --vus 20 --duration 60s \
    loadtest/profile-constant.js > /dev/null 2>&1

  # 正式测量
  BASE_URL=http://127.0.0.1:8080 RATE=500 DURATION="$DURATION" \
  k6 run --summary-export="$DIR/k6-$GC.json" \
         loadtest/profile-constant.js > /dev/null 2>&1

  # 记录 JVM 实际参数
  PID=$(jcmd 2>/dev/null | grep app.jar | awk '{print $1}')
  [ -n "$PID" ] && jcmd "$PID" VM.flags > "$DIR/flags-$GC.txt" 2>/dev/null || true

  # 停止服务
  scripts/stop-app.sh 2>/dev/null || pkill -f app.jar 2>/dev/null || true
  sleep 3

  echo "  ✅ 完成"
  echo
done

echo "═══ 对比结果 ═══"
echo
python3 - "$DIR" <<'PY'
import json, pathlib, re, sys

d = pathlib.Path(sys.argv[1])
configs = ["G1", "ZGC", "Parallel"]

print(f"{'GC':<12}{'QPS':>10}{'P50':>10}{'P95':>10}{'P99':>10}{'错误率':>10}{'GC停顿均值':>12}{'GC次数':>10}")
print("-" * 88)

results = {}
for gc in configs:
    k6 = d / f"k6-{gc}.json"
    gclog = d / f"gc-{gc}.log"
    if not k6.exists():
        print(f"{gc:<12}{'(无数据)':>10}")
        continue

    m = json.loads(k6.read_text())["metrics"]
    dur = m["http_req_duration"]

    # 解析 GC 日志
    pause_avg, pause_count = 0.0, 0
    if gclog.exists():
        pauses = re.findall(r"([\d.]+)ms\s*$", gclog.read_text(errors="ignore"), re.MULTILINE)
        if pauses:
            vals = [float(p) for p in pauses]
            pause_avg = sum(vals) / len(vals)
            pause_count = len(vals)

    results[gc] = {
        "qps": m["http_reqs"]["rate"], "p50": dur["med"], "p95": dur["p(95)"],
        "p99": dur["p(99)"], "err": m["http_req_failed"]["rate"],
        "pause_avg": pause_avg, "pause_count": pause_count,
    }

    print(f"{gc:<12}{m['http_reqs']['rate']:>10.1f}{dur['med']:>10.1f}{dur['p(95)']:>10.1f}"
          f"{dur['p(99)']:>10.1f}{m['http_req_failed']['rate']:>9.2%}"
          f"{pause_avg:>11.2f}ms{pause_count:>10}")

print()
print("判读:")
print("  ⚠️  不要只看延迟!要同时看三个指标:")
print("      ① P99(延迟)② QPS(吞吐)③ GC 停顿(机制验证)")
print()
print("  常见取舍:")
print("      ZGC:停顿最低,但吞吐可能略低、CPU 消耗更高(并发 GC 线程)")
print("      Parallel:吞吐最高,但停顿最长")
print("      G1:折中(默认选择)")
print()
print("  如果某个 GC 的 P99 更好但 QPS 明显下降 → 这个取舍是否值得要看你优化的是哪个指标")
PY

echo
echo "✅ 完成 → $DIR"

二、JVM 参数确认脚本

#!/usr/bin/env bash
# tools/verify-jvm-config.sh [PID]
#
# 确认 JVM 【实际】使用的参数与容器限制——不要相信文档。
set -uo pipefail

PID="${1:-$(jcmd 2>/dev/null | grep app.jar | awk '{print $1}')}"
[ -z "$PID" ] && { echo "❌ 找不到应用进程"; exit 1; }

echo "═══════════════════════════════════════════════════════════════"
echo "JVM 配置确认(PID=$PID)"
echo "═══════════════════════════════════════════════════════════════"
echo

# ── ① 实际 JVM 参数 ────────────────────────────────────────
echo "【① 实际 JVM 参数】"
jcmd "$PID" VM.flags 2>/dev/null | tr ' ' '\n' | grep -E "^(MaxHeapSize|InitialHeapSize|UseG1GC|UseZGC|UseParallelGC|MaxMetaspaceSize|MaxDirectMemorySize|ActiveProcessorCount|HeapDumpOnOutOfMemoryError)" | sed 's/^/  /' || echo "  (无法获取)"
echo

# ── ② 堆大小 vs 容器内存 ────────────────────────────────────
echo "【② 堆大小 vs 容器限制】"
MAX_HEAP_BYTES=$(jcmd "$PID" VM.flags 2>/dev/null | tr ' ' '\n' | grep "^-XX:MaxHeapSize=" | sed 's/.*=//')
if [ -n "$MAX_HEAP_BYTES" ]; then
  MAX_HEAP_MB=$((MAX_HEAP_BYTES / 1048576))
  echo "  JVM 堆上限: ${MAX_HEAP_MB} MB"
fi

if [ -f /sys/fs/cgroup/memory.max ]; then
  MEM_LIMIT=$(cat /sys/fs/cgroup/memory.max)
  if [ "$MEM_LIMIT" != "max" ]; then
    MEM_MB=$((MEM_LIMIT / 1048576))
    echo "  容器内存限制: ${MEM_MB} MB"
    if [ -n "$MAX_HEAP_MB" ]; then
      RATIO=$((MAX_HEAP_MB * 100 / MEM_MB))
      echo "  堆占比: ${RATIO}%"
      if [ "$RATIO" -gt 85 ]; then
        echo "  ❌ 堆占比过高(> 85%)—— 几乎必然 OOMKilled"
        echo "     建议:堆 = 容器限制的 50%~75%(即 $((MEM_MB * 60 / 100)) MB)"
      elif [ "$RATIO" -gt 75 ]; then
        echo "  ⚠️  偏紧 —— 确认堆外内存的实际使用量"
      else
        echo "  ✅ 比例合理"
      fi
    fi
  else
    echo "  容器内存限制: 无限制"
  fi
else
  echo "  (非容器环境)"
fi
echo

# ── ③ CPU 核数识别 ─────────────────────────────────────────
echo "【③ CPU 核数识别】"
echo "  宿主机可见核数(nproc): $(nproc)"
ACTIVE=$(jcmd "$PID" VM.flags 2>/dev/null | tr ' ' '\n' | grep "ActiveProcessorCount" | sed 's/.*=//')
echo "  JVM 使用的核数(ActiveProcessorCount): ${ACTIVE:-未设置(JVM 自动检测)}"
if [ -f /sys/fs/cgroup/cpu.max ]; then
  read -r QUOTA PERIOD < /sys/fs/cgroup/cpu.max
  if [ "$QUOTA" != "max" ]; then
    echo "  容器 CPU 限制: $(awk -v q="$QUOTA" -v p="$PERIOD" 'BEGIN{printf "%.2f", q/p}') 核"
    echo "  ⚠️  如果容器限制 < 可见核数,JVM 的线程池/GC 线程数可能过大"
    echo "     建议:-XX:ActiveProcessorCount=<容器限制的核数>"
  fi
fi
echo

# ── ④ 排障必备参数 ─────────────────────────────────────────
echo "【④ 排障必备参数检查】"
FLAGS=$(jcmd "$PID" VM.flags 2>/dev/null)
check_flag() {
  local flag="$1" purpose="$2"
  if echo "$FLAGS" | grep -q "$flag"; then
    echo "  ✅ $flag ($purpose)"
  else
    echo "  ❌ 缺少 $flag ($purpose)"
  fi
}
check_flag "HeapDumpOnOutOfMemoryError" "OOM 时自动 dump"
check_flag "ExitOnOutOfMemoryError" "OOM 时退出(可选)"
echo

# 检查 GC 日志(无法从 VM.flags 看,需要看启动命令)
echo "【⑤ GC 日志】"
if ls /var/log/app/gc*.log /tmp/gc*.log 2>/dev/null | head -1 > /dev/null 2>&1; then
  echo "  ✅ 找到 GC 日志文件"
else
  echo "  ⚠️  未找到 GC 日志 —— 确认启动参数里有 -Xlog:gc*"
  echo "     建议:-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m"
fi
echo

echo "═══════════════════════════════════════════════════════════════"
echo "结论:"
echo "  ① 堆占比是否合理(应 < 75%)"
echo "  ② ActiveProcessorCount 是否与容器限制匹配"
echo "  ③ 排障参数是否齐全(HeapDumpOnOutOfMemoryError 必须有)"
echo "  ④ GC 日志是否开启(调优的眼睛)"
echo "═══════════════════════════════════════════════════════════════"

三、堆大小与停顿的关系实验

# tools/heap-pause-analysis.py <gc.log>
"""
从 GC 日志分析「堆大小与停顿时间」的关系。

用法:
  1. 用不同的 -Xmx 跑同一负载,各自生成 gc.log
  2. 分别跑本脚本,对比结果
"""
import re
import statistics as st
import sys


def parse_gc_log(path):
    """解析 GC 日志,提取停顿信息"""
    pauses = []
    heap_after = []

    with open(path, errors="ignore") as f:
        for line in f:
            # 匹配 Pause Young / Pause Full
            if "Pause" not in line:
                continue

            # 停顿时间(末尾的 XX.XXXms)
            pm = re.search(r"([\d.]+)ms\s*$", line.strip())
            if not pm:
                continue
            pause_ms = float(pm.group(1))

            # 回收前后的堆使用(形如 128M->32M(2048M))
            hm = re.search(r"(\d+)M->(\d+)M\((\d+)M\)", line)
            if hm:
                before = int(hm.group(1))
                after = int(hm.group(2))
                heap_after.append(after)
            else:
                before = after = None

            # GC 类型
            gc_type = "Young" if "Pause Young" in line else ("Full" if "Pause Full" in line else "Other")

            pauses.append({"pause_ms": pause_ms, "type": gc_type, "after_mb": after})

    return pauses


def main(path):
    pauses = parse_gc_log(path)
    if not pauses:
        print(f"❌ 没有解析到 GC 停顿({path})")
        print("   提示:确认 GC 日志格式(-Xlog:gc*:time,uptime)")
        return

    vals = [p["pause_ms"] for p in pauses]
    vals_sorted = sorted(vals)

    print("═" * 70)
    print(f"GC 停顿分析:{path}")
    print("═" * 70)
    print()
    print(f"总停顿次数      : {len(vals)}")
    print(f"停顿总和        : {sum(vals):.1f} ms")
    print(f"平均值          : {st.mean(vals):.2f} ms")
    print(f"中位数          : {st.median(vals):.2f} ms")
    print(f"P95             : {vals_sorted[int(len(vals) * 0.95)]:.2f} ms")
    print(f"P99             : {vals_sorted[int(len(vals) * 0.99)]:.2f} ms")
    print(f"最大值          : {max(vals):.2f} ms")
    print()

    # 按类型统计
    by_type = {}
    for p in pauses:
        by_type.setdefault(p["type"], []).append(p["pause_ms"])
    print("按类型:")
    for t, v in by_type.items():
        print(f"  {t:<8} 次数 {len(v):>5}  平均 {st.mean(v):>7.2f} ms  最大 {max(v):>7.2f} ms")
    print()

    # 老年代基线趋势(泄漏/晋升检查)
    afters = [p["after_mb"] for p in pauses if p["after_mb"]]
    if len(afters) > 10:
        first_q = st.mean(afters[:len(afters) // 4])
        last_q = st.mean(afters[-len(afters) // 4:])
        print("GC 后堆基线趋势:")
        print(f"  前 1/4: {first_q:.0f} MB  →  后 1/4: {last_q:.0f} MB  ({last_q - first_q:+.0f} MB)")
        if last_q > first_q * 1.2:
            print("  ❌ 基线持续抬升 → 可能有内存泄漏或对象晋升过多")
        else:
            print("  ✅ 基线稳定")
        print()

    print("═" * 70)
    print("判读:")
    print("  ① 平均停顿 < 20ms、P99 < 50ms 通常可接受(取决于你的 SLO)")
    print("  ② 出现 Full GC → 必须查明(配置问题或泄漏)")
    print("  ③ 停顿次数太多(几秒一次)→ 分配速率太高(先去降分配,不是调 GC)")
    print("  ④ 对比不同 -Xmx 的同一份日志:")
    print("     堆大 → 停顿次数少但单次更长")
    print("     对延迟敏感的服务,【单次停顿时长】比频率更重要")
    print("═" * 70)


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "gc.log")

四、动手改造

改动 观察什么
用 compare-gc.sh 对比 G1 与 ZGC 同时看 P99、QPS、GC 停顿——体会「只看延迟会被误导」
把 -Xmx 从 2g 改成 8g 再跑 停顿次数下降但单次变长(用 heap-pause-analysis.py 对比)
用 verify-jvm-config.sh 检查你的服务 大概率会发现「堆占比过高」或「缺 HeapDumpOnOutOfMemoryError」
在容器里跑 nproc 并与 cpu.max 对比 会发现「以为有 8 核,实际只有 1 核」的陷阱
故意把 -Xmx 设成容器内存 limit 触发 OOMKilled(用 kubectl describe pod 验证)

五、这段代码的局限

  • compare-gc.sh 需要足够的压测时长:GC 效果要覆盖多个 GC 周期,建议每配置至少 3–5 分钟。
  • GC 日志格式因 JDK 版本而异:本脚本的正则适配了常见的 Pause Young ... XX.XXXms 格式,其他格式可能需要调整。
  • heap-pause-analysis.py 的堆基线趋势只在「同一份配置内」有意义:跨配置对比要看绝对值。
  • JVM 调优的效果需要长期观测:3 分钟的压测可能不够——最终验证应该在真实流量或长时间浸泡下进行。
  • verify-jvm-config.sh 的「堆占比 < 75%」是经验值:具体取决于堆外内存的实际使用(Netty、直接内存、线程数)。