上一节把「复用」做成了代码(对象池设计
),但「复用到底省了多少 GC 压力」还没有数字。站内《Go 语言运行时原理》
(30 篇)从运行时内部讲了 Go 的 GC 与内存管理(三色标记、写屏障、GOGC/GOMEMLIMIT);本节站在浏览器 JS 引擎外部,不剖析回收器的实现,只做一个分配量级的解析模型:把「每帧分配多少对象」换算成分配总量与 GC 触发次数,给分配密度定一个可比较的标尺。
先把丑话说在前面:本节所有数字都来自一个解析模型,不是 V8 实测,也不是任何真机实测。 真实浏览器里的分代 GC、年轻代尺寸、增量标记、空闲回收时机都不在这个模型里。模型的价值只有一个:在同一套假设下,把「每帧新建」与「对象池零分配」摆在一起,看清它们差在哪个量级。绝对数值不可移植,比值才有意义。
为什么这里必须用解析模型
对象池的收益是分配行为的差异,而不是某个可以测出来的绝对延迟。真机测 GC 停顿有两个绕不开的坑:一是停顿本身很短且抖动大,单次测量噪声往往盖过信号;二是设备不可控,同一段代码在不同机器上的 GC 时机完全不同。与其测一堆测不准的数,不如把可控的部分抽出来建模。
模型只保留三个可控量:每帧分配多少对象(objectsPerFrame)、每个对象多少字节(bytesPerObject)、累计分配多少字节触发一次 GC(gcThresholdBytes)。前两个决定分配速率,第三个是模型假设——它不是一个从 V8 里读出来的常数,而是「我们假设回收器大约每分配 512 KB 就动一次」的工程输入。把假设写在配置里、写清楚,比假装它是实测数字要诚实得多。
D 层:把模型假设写进配置
下面这段是 D 层配置表。它把模型用到的全部整数都摊开:运行时长、分配速率、对象大小、触发阈值、GC 停顿(只用于展示,不参与主计算)、软告警线。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "分配量级的解析模型(不是 V8 实测)。gcThresholdBytes 是「模型假设」的 GC 触发阈值,不是真实引擎的 young-gen 尺寸;它只用来把「分配总量」换算成「GC 触发次数」这一相对量级。全整数,禁浮点。",
"fps": 60,
"runSeconds": 600,
"frameBudgetMs": 16,
"objectsPerFrame": 12,
"bytesPerObject": 96,
"poolCapacity": 64,
"leakPerFrame": 3,
"gcThresholdBytes": 512000,
"gcPauseMs": 6,
"softIntervalFrames": 300
}
fps × runSeconds 给出总帧数 36000(10 分钟),objectsPerFrame 与 bytesPerObject 给出每帧分配字节,gcThresholdBytes 是那个假设的触发阈值。leakPerFrame 是「池子有泄漏」场景的参数——每帧漏 3 个不归还,池子被迫补建,用来展示「有池但不守纪律」的代价。
S 层:三种策略的分配总量与 GC 触发次数
下面这段是 S 层(仿真层)脚本,sim/e09_gc.py。它读配置,对三种策略各算一次分配总量与 GC 触发次数,产出 JSON + CSV + SVG。核心只有一行:gcTriggers = 分配字节 // gcThresholdBytes。
#!/usr/bin/env python3
# file: sim/e09_gc.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e09_gc.py --config config/ --out out/
"""对象池对「分配总量与 GC 触发次数」的影响:一个分配量级的解析模型。
⚠️ 不是 V8 实测。它把「每帧分配多少对象 × 跑多少帧」当分配总量,再用一个**模型假设**的
阈值 gcThresholdBytes 换算成 GC 触发次数。数字的意义是同一套假设下的相对量级,绝对数值不可移植。
对比 naive(每帧新建)/ pooled(稳态零分配)/ leaky(池子每帧漏对象被迫补建)。
产出:out/e09_gc.json + out/e09_gc_scenarios.csv + out/e09_gc.svg
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(cfg_dir):
with open(os.path.join(cfg_dir, "e09_gc.json"), encoding="utf-8") as f:
return json.load(f)
def model(cfg):
frames = cfg["fps"] * cfg["runSeconds"]
bpo = cfg["bytesPerObject"]
thresh = cfg["gcThresholdBytes"]
cap = cfg["poolCapacity"]
leak = cfg["leakPerFrame"]
specs = [
("naive", "每帧新建(无池)", frames * cfg["objectsPerFrame"]),
("pooled", "对象池(稳态零分配)", cap),
("leaky", "对象池有泄漏", cap + frames * leak),
]
rows = [{"id": i, "label": lb, "objects": n, "bytes": n * bpo, "gcTriggers": n * bpo // thresh}
for i, lb, n in specs]
return frames, rows
def draw_svg(path, rows, thresh):
"""纯 Python SVG:每个策略一根横条(分配总量),右侧标字节数与 GC 次数。"""
W, PAD, ROW = 760, 210, 52
H = 84 + ROW * len(rows)
peak = max(r["bytes"] for r in rows) or 1
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "30", "font-size": "15",
"fill": "#111827"}).text = "10 分钟运行:分配总量与 GC 触发次数(解析模型)"
ET.SubElement(svg, "text", {"x": "16", "y": "50", "font-size": "11",
"fill": "#9ca3af"}).text = f"触发阈值 {thresh} 字节(模型假设,非 V8 实测)"
bar_max = W - PAD - 190
for i, r in enumerate(rows):
y = 68 + i * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
"fill": "#374151"}).text = r["label"]
w = max(3, (bar_max * r["bytes"]) // peak)
color = "#dc2626" if r["gcTriggers"] >= 10 else ("#f59e0b" if r["gcTriggers"] > 0 else "#16a34a")
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "24",
"rx": "4", "fill": color})
ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 23), "font-size": "12",
"fill": "#6b7280"}).text = f'{r["bytes"]} 字节 · {r["gcTriggers"]} 次 GC'
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
frames, rows = model(cfg)
by = {r["id"]: r for r in rows}
ppm = (by["pooled"]["bytes"] * 1000000) // by["naive"]["bytes"] if by["naive"]["bytes"] else 0
report = {
"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
"model": "allocation-magnitude analytic model (NOT a V8 measurement)",
"frames": frames, "runSeconds": cfg["runSeconds"], "fps": cfg["fps"],
"objectsPerFrame": cfg["objectsPerFrame"], "bytesPerObject": cfg["bytesPerObject"],
"gcThresholdBytes": cfg["gcThresholdBytes"], "gcPauseMs": cfg["gcPauseMs"],
"poolCapacity": cfg["poolCapacity"], "leakPerFrame": cfg["leakPerFrame"],
"scenarios": rows, "pooledBytesPpmOfNaive": ppm,
"gcAvoidedByPool": by["naive"]["gcTriggers"] - by["pooled"]["gcTriggers"],
}
os.makedirs(args.out, exist_ok=True)
report["svgBytes"] = draw_svg(os.path.join(args.out, "e09_gc.svg"), rows, cfg["gcThresholdBytes"])
with open(os.path.join(args.out, "e09_gc.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "e09_gc_scenarios.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "label", "objects", "bytes", "gcTriggers"])
for r in rows:
w.writerow([r["id"], r["label"], r["objects"], r["bytes"], r["gcTriggers"]])
print(json.dumps({"frames": frames, "naiveGcTriggers": by["naive"]["gcTriggers"],
"pooledGcTriggers": by["pooled"]["gcTriggers"], "pooledBytesPpmOfNaive": ppm,
"gcAvoidedByPool": by["naive"]["gcTriggers"] - by["pooled"]["gcTriggers"],
"leakyGcTriggers": by["leaky"]["gcTriggers"]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它(/opt/homebrew/bin/python3 sim/e09_gc.py --config config/ --out out/),输出是这样的(同机同一套假设下的相对量级):
{"frames": 36000, "naiveGcTriggers": 81, "pooledGcTriggers": 0, "pooledBytesPpmOfNaive": 148, "gcAvoidedByPool": 81, "leakyGcTriggers": 20}
同时落盘 out/e09_gc_scenarios.csv(每策略一行)与 out/e09_gc.svg(分配总量条形图,接近零的那根也被强制留了 3 像素宽,否则它会在图上消失)。CSV 内容是这样:
id,label,objects,bytes,gcTriggers
naive,每帧新建(无池),432000,41472000,81
pooled,对象池(稳态零分配),64,6144,0
leaky,对象池有泄漏,108064,10374144,20
三个数字读出来的结论
第一,池子把分配总量压到几乎为零。 naive 跑 10 分钟分配了 432000 个对象、41.47 MB;pooled 只分配了 64 个对象、6144 字节——就是池子的预热容量。pooledBytesPpmOfNaive = 148 的意思是:池子的分配量是「每帧新建」的百万分之一百四十八(约万分之一点五)。在 GC 触发次数上,naive 是 81 次,pooled 是 0 次,模型里池子把 81 次触发全部消掉了。
第二,有池不等于没问题。 leaky 场景每帧漏 3 个对象不归还,10 分钟就漏了 10 万多个,分配总量 10.37 MB、GC 触发 20 次。它比「每帧新建」好得多(触发从 81 降到 20),但远不如守规矩的池子(0 次)。这条对照说明:对象池的收益取决于 release 的纪律,而不取决于有没有一个叫「池」的类。漏一个对象,就多一次分配;漏一帧的对象,稳态零分配就破了。
第三,数字要连着假设一起读。 gcThresholdBytes = 512000 是模型输入,不是 V8 常数。把阈值改大一倍,所有 GC 触发次数都会腰斩——但 naive 与 pooled 的比值不变(因为两者共用同一个阈值)。这就是为什么模型只对相对量级负责:绝对次数会随假设漂移,比值不会。
B 层:每帧分配数 → GC 触发间隔
S 层回答「池子省了多少」,B 层回答「多密会危险」。下面这段是 B 层(基准)脚本,sim/e09_gc_bench.py。它复用 S 层的模型假设,扫「每帧新建对象数」从 2 到 32,算每个档位的 GC 触发间隔(帧)——即每分配多少帧触发一次 GC。间隔越短,GC 越密,越可能落在帧预算里。
#!/usr/bin/env python3
# file: sim/e09_gc_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/e09_gc_bench.py --config config/ --out out/
"""每帧分配数 → GC 触发间隔的基准(软告警,不硬失败)。
复用 sim/e09_gc.py 的模型假设(gcThresholdBytes / bytesPerObject / fps),
扫「每帧新建对象数」∈ {2,4,8,12,16,24,32},算每个档位的 GC 触发间隔(帧)与每分钟触发次数;
间隔低于软底线 softIntervalFrames 的档位给软告警——只是模型内的提示,不是真机结论。
产出 out/e09_gc_bench.json + out/e09_gc_bench.csv。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from e09_gc import load # noqa: E402
SCAN = (2, 4, 8, 12, 16, 24, 32)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
bpo = cfg["bytesPerObject"]
thresh = cfg["gcThresholdBytes"]
fps = cfg["fps"]
soft = cfg["softIntervalFrames"]
rows, warns = [], []
for op in SCAN:
per_frame_bytes = op * bpo
interval = thresh // per_frame_bytes
gc_per_minute = (fps * 60) // interval
warn = interval < soft
rows.append({"objectsPerFrame": op, "perFrameBytes": per_frame_bytes,
"intervalFrames": interval, "gcPerMinute": gc_per_minute, "warn": warn})
if warn:
warns.append(f"每帧 {op} 个对象 → GC 间隔仅 {interval} 帧(< 软底线 {soft})")
os.makedirs(args.out, exist_ok=True)
report = {"source": "sim/e09_gc_bench.py", "tier": "B",
"model": "allocation-magnitude analytic model (NOT a V8 measurement)",
"bytesPerObject": bpo, "gcThresholdBytes": thresh, "fps": fps,
"softIntervalFrames": soft, "rows": rows, "warnings": warns, "ok": not warns,
"csv": os.path.join(args.out, "e09_gc_bench.csv")}
with open(os.path.join(args.out, "e09_gc_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "e09_gc_bench.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["objectsPerFrame", "perFrameBytes", "intervalFrames", "gcPerMinute", "warn"])
for r in rows:
w.writerow([r["objectsPerFrame"], r["perFrameBytes"], r["intervalFrames"],
r["gcPerMinute"], str(r["warn"]).lower()])
for r in rows:
print(f"obj/frame={r['objectsPerFrame']:>2} interval={r['intervalFrames']:>5} frames "
f"gc/min={r['gcPerMinute']:>3} {'WARN' if r['warn'] else ''}")
for wmsg in warns:
print(f" WARN {wmsg}")
print(f"== GC 间隔基准:{'OK' if report['ok'] else 'WARN'}(软告警不硬失败)")
return 0
if __name__ == "__main__":
sys.exit(main())
在本机跑它,输出是这样的(软告警不硬失败):
obj/frame= 2 interval= 2666 frames gc/min= 1
obj/frame= 4 interval= 1333 frames gc/min= 2
obj/frame= 8 interval= 666 frames gc/min= 5
obj/frame=12 interval= 444 frames gc/min= 8
obj/frame=16 interval= 333 frames gc/min= 10
obj/frame=24 interval= 222 frames gc/min= 16 WARN
obj/frame=32 interval= 166 frames gc/min= 21 WARN
WARN 每帧 24 个对象 → GC 间隔仅 222 帧(< 软底线 300)
WARN 每帧 32 个对象 → GC 间隔仅 166 帧(< 软底线 300)
== GC 间隔基准:WARN(软告警不硬失败)
落盘的 out/e09_gc_bench.csv 记了每个档位的完整数据:
objectsPerFrame,perFrameBytes,intervalFrames,gcPerMinute,warn
2,192,2666,1,false
4,384,1333,2,false
8,768,666,5,false
12,1152,444,8,false
16,1536,333,10,false
24,2304,222,16,true
32,3072,166,21,true
触发间隔怎么用:一条软底线
B 层的输出是一条单调递减的曲线:每帧分配越多,GC 触发间隔越短。2 个对象每帧时,GC 每 2666 帧才动一次(约 44 秒一次),几乎无感;32 个对象每帧时,间隔掉到 166 帧(不到 3 秒一次),模型判定它低于软底线 300 帧、给出告警。软底线的意义不是「超过就一定卡」,而是「超过就该去看一眼」——它是排期与评审用的提示线,不是运行时断言。
这条曲线也解释了对象池为什么值钱。naive 场景每帧 12 个对象,落在 444 帧的间隔上——不危险,但也不是零。把稳态分配压到零之后,这条曲线上的点直接消失,GC 触发的来源只剩「无法池化的一次性分配」(比如加载新关卡)。优化的目标不是让曲线更好看,而是让曲线上的点变少。
这份模型不能当实测用
必须重复一遍:这是分配量级的解析模型,不是 V8 实测,也不是真机实测。真实浏览器里,年轻代回收的触发不只取决于分配量,还取决于存活对象、堆增长速率、空闲时间;停顿也可能被增量标记切碎。所以本节的所有数字只用于同机同一套假设下的横向比较,不能拿去写「本游戏 GC 停顿 X 毫秒」这种结论。真机上的帧率与卡顿,要按 14.2 的帧耗时剖析去做(那是另一套方法)。
小结与下一步
这一节把对象池的收益量化了:在模型假设下,稳态零分配把 10 分钟的分配总量从 41.47 MB 压到 6144 字节、把 GC 触发次数从 81 次压到 0 次;有泄漏的池子退到 20 次,说明纪律比类名重要。B 层又给出了一条「每帧分配数 → GC 触发间隔」的曲线与一条软底线,用来判断分配密度。
但「分配压力」只是弱机问题的一半。另一半是:即使分配为零,弱机的内存和帧耗时本身就不够用,这时要主动降档——降粒子、降分辨率、关动效。下一节就把降级判据做成一个可断言的 TypeScript 内核。
阅读导航:上一节:对象池设计 · 下一节:弱机适配与降级策略 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。