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、直接内存、线程数)。