欢迎光临
我们一直在努力

万字详解 | Go 进阶(三)内存分配器:一次 make 背后 9 次跳转,它和 GC 怎么配合不拖垮延迟

你好,我是利威尔xu。

这是「保姆级万字详解」系列 Go 进阶的第三篇。第一篇我们拆了 GMP 调度器,知道 goroutine 是被 P 带着跑的;第二篇拆了 GC 的三色标记,知道 STW 是怎么从 10ms 压到 40μs 的。今天补上中间那块一直被忽略的拼图——内存分配器。前面两篇里反复出现的 mcache、span、辅助标记,这篇把它讲明白。

点名一下受众:能背出"Go 内存分配分 mcache/mcentral/mheap 三层",但被问"span 和 GC 标记位什么关系"就卡壳的后端同学;以及服务上了生产,P99 抖动只会 GOGC=off 硬扛的工程师。这篇帮你把分配器和 GC 的关系焊死在脑子里。

建议先收藏——文里有两张能直接进排查手册的表:「size class 档位速查表」和「分配慢/GC 频繁排查速查表」,下次线上出问题,翻出来照着查,比现翻源码快得多。(新读者可以先看置顶的学习路线再决定从哪篇入坑。全文结论以 Go 1.25 源码口径为准,跨版本差异文中会标注。)

文章目录

    • 一、开场:P99 抖动,别只怪 GC
      • 先建一个直觉比喻:快递驿站
    • 二、三层地图:mcache / mcentral / mheap
    • 三、一次 make([]byte, 512) 的 9 站旅程
    • 四、size class 与 span:按档位发内存的仓库学
    • 五、mcache:每个 P 的私有抽屉
    • 六、mcentral 与 mheap:抽屉空了之后
    • 七、和 GC 的配合:span 上的双位图
    • 八、逃逸分析:编译器替你关哪扇门
    • 九、实战:benchmark、一次真实排查、一张速查表
      • 9.1 先量:分配到底多贵
      • 9.2 再查:一次真实排查复盘
      • 9.3 常备:分配慢 / GC 频繁排查速查表
    • 💡 本章面试要点
    • 写在最后

一、开场:P99 抖动,别只怪 GC

先讲一个我身边真实发生过的故障(细节做了脱敏)。一个 Go 写的网关服务,白天一切正常,晚上高峰 P99 从 3ms 一路飙到 40ms。值班同学第一反应是 GC:打 gctrace 一看,GC 占比确实涨了,从 0.3% 到 2.1%。于是把 GOGC 从 100 调到 400,GC 次数降下来了——但 P99 只从 40ms 降到 35ms,大头根本没解决。

后来用 pprof 抓 alloc_space,真相才浮出来:高峰期某个接口的响应体从平均 2KB 涨到 60KB(下游加了一批富文本字段),单请求分配字节数涨了 30 倍,分配速率直接把 GC 频率顶高了 5 倍,同时大量对象活不过一轮 GC,纯属"制造垃圾"。真正该做的是裁字段、改分页,而不是动 GC 参数。

这个案例说明一件事:Go 的延迟问题,一半藏在分配里。GC 是收拾分配的摊子,分配才是摊子本身。你不搞懂分配器,GC 调优就是隔靴挠痒。

先建一个直觉比喻:快递驿站

后面全是术语,先用一个比喻把地图立起来。把 Go 的内存管理想象成你小区的快递体系:

  • 你家门口的快递柜 = goroutine 栈。最私密、最快,快递员(编译器)直接把小包裹放进去,不用过任何系统;
  • 菜鸟驿站的货架 = mcache。驿站站长(P)私有,伸手就拿到,不用跟别人抢;
  • 城市总仓 = mheap。所有驿站补货的源头,管着整座城市的空地(page)。

包裹从总仓到货架、从货架到快递柜,每一跳都有成本。今天要讲的,就是这每一次跳跃的路线图、以及它和"城市清洁队"(GC)怎么配合——清洁队要在包裹不停流动的情况下把垃圾清走,两边不打架,靠的是一套精心设计的双位图机制。

面试追问 Q:mcache 为什么能做到无锁? A:因为 mcache 挂在 P 上,一个 P 同一时刻只跑一个 goroutine(M 抢到 P 才能跑 G),天然串行。GOMAXPROCS 是几,就有几个 mcache,彼此老死不相往来。代价是每个 mcache 都占一份内存,P 越多冗余越多——这是典型的"用空间换无锁"。

面试追问 Q:那 mcentral 为什么要加锁? A:mcache 是 P 私有的,但总仓的货(span)是所有 P 共享的。多个 P 同时来补货,不加锁就乱了。mcentral 的锁竞争是分配路径上第一个可能出现的争用点,高并发下这里是性能分水岭。

二、三层地图:mcache / mcentral / mheap

抛开比喻,上真结构。Go 的堆内存管理分三层,先看总表:

层级归属锁命中延迟量级典型场景
mcache 每个 P 一个 无锁 几十纳秒 小对象分配(≤32KB 的绝大多数)
mcentral 每个 size class 一个 有锁 几百纳秒~微秒(有争用时劣化) mcache 的 span 用完,来补货
mheap 全局唯一 有锁(大对象还要过 page allocator) 微秒级 mcentral 也没货;>32KB 大对象直通

两个关键设计先说清楚,不然后面全悬空:

第一,size class(尺寸档位)。 Go 不给对象精确按需分配,而是把请求向上取整到固定的 66 个档位(8B 到 32KB),每个档位对应一种 span。为什么要分档?假设不分档,一个 9B 的对象和一个 8B 的对象挨着放,中间 7B 的缝再也用不上——这叫外部碎片,分档就是用可控的内部碎片(每个对象平均浪费不到 12.5%)换零外部碎片。档位表第三节细拆。

第二,span。 span 是分配器的基本管理单位,由若干个 8KB 的 page 组成,一个 span 只服务一个 size class,里面切成等大的一格格对象。span 自己有个"账本"(位图)记录哪格空了、哪格占了——这个账本同时被 GC 用,是第八节的重点。

链路串起来就是:make 一个对象 → 算 size class → 找当前 P 的 mcache → mcache 里对应档位的 span 账本上找个空格 → 记账返回。全程可能一次锁都不碰。

面试追问 Q:为什么小对象阈值是 32KB,不是别的数? A:32KB 是"值得单独管理"和"放进 span 切格子"的分界。小于它,走 mcache 切格子最划算;大于它,切格子反而浪费(一个 span 最多放不了几个,还得处理页对齐),直接从 mheap 整块拿更合理。这个数是工程权衡的结果,不是数学最优。

面试追问 Q:GOMAXPROCS 调大,分配一定更快吗? A:不一定。GOMAXPROCS 变大,mcache 总数变多,每个 P 拿到"热 span"的概率上升,锁竞争下降;但 mcentral/mheap 的争用会上升,内存冗余也上升。分配密集型服务的 GOMAXPROCS 调优必须实测,没有普适答案。

三、一次 make([]byte, 512) 的 9 站旅程

光看结构不过瘾,我们把一次 make([]byte, 512) 从编译到返回的完整路径拆成 9 站,逐站看它在哪、发生了什么、可能在哪一脚刹车。这是全文的主线,后面每一节都是把某一站放大。

buf := make([]byte, 512) // 今天的主角

第 1 站 · 编译器入口。 make([]byte, 512) 在编译期就确定了长度,走的是 runtime.makeslice(不是 makechan/makemap 那些专用函数)。编译器知道元素类型是 byte(无指针),这个信息后面几站都要用。

第 2 站 · mallocgc 统一入口。 makeslice 内部转手 runtime.mallocgc(size=512, typ, needzero=true)。整个分配器只有这一个总入口,栈上分配除外——那个是逃逸分析的决策,第九节讲。

第 3 站 · 算 size class。 512B 请求向上取整,命中 class 表里的 512 档(正好整除)。如果请求是 520B,会被归到 640 档,多出的 120B 就是内部碎片。档位表见下一节。

第 4 站 · tiny 判定。 mallocgc 先问一句:小于 16B 且不含指针吗?是的话走 tiny 分配器(多个小对象合并塞进一个 16B 块)。我们的 512B 不够格,继续大路。

第 5 站 · 找当前 P 的 mcache。 通过 getg().m.p.mcache 一步拿到私有货架,取出 512 档位的 span 指针。到这里依然零锁——只要 span 没空。

第 6 站 · 翻 span 的账本。 span 有个 freeindex 字段指向"第一个可能空闲的格子",分配器从这开始扫位图 allocBits,找到第一个 0 位。512 档的 span 一个能放 16 个对象(8KB/512B),扫起来极快。

第 7 站 · 记账返回。 把那一位置 1,freeindex 前移,返回指针。如果这一格需要清零(needzero),顺手清零。到此一次分配就结束了——mcache 命中的路径,全程无锁、无系统调用、几十纳秒。

第 8 站 · GC 记账。 这一步容易被忽略:分配器在把对象交给你的同时,要把"这块内存归哪个 span、什么类型、要不要扫描指针"登记清楚。GC 将来扫不扫这块内存,靠的就是分配时埋的这些信息。

第 9 站 · 假如 mcache 空了。 第 5 站如果发现 span 用完了,链路变长:mcache 找 mcentral 要个新 span(第一次出现锁)→ mcentral 也没有,找 mheap 切 page(第二次锁,还涉及页分配器)。这就是"9 站"的后半程——绝大多数日子走前 7 站,补货日才走全程。

// 跑一遍,直观感受 mcache 命中的速度(量级参考)
// go test -bench=. -benchmem -run=^$
func BenchmarkMake512(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = make([]byte, 512)
}
}
// 典型输出量级:ns/op 在几十纳秒,B/op 512,allocs/op 1
// (数字随机器波动,这里只给量级;换成 -benchtime=10s 更稳)

面试追问 Q:freeindex 为什么可能指向非空格子? A:因为 freeindex 是"可能空闲"的游标,不保证精确。span 用完后归还、或者 GC 清扫把整段腾出来,freeindex 会尽量前移,但清位图时不一定逐个扫。所以第 6 站必须真扫位图,freeindex 只是起点优化。

面试追问 Q:合并 tiny 分配有什么代价? A:tiny 块里的对象要么一起活、要么一起死(清扫以块为单位),会放大 GC 的保留内存;且 tiny 块只在 GC 时才归还 mcentral,等于把释放周期拉长了。小对象海量场景下,这是"用 GC 压力换分配速度"的交易。

四、size class 与 span:按档位发内存的仓库学

这一节把第 3 站的档位表和第 6 站的 span 账本讲透。

Go 的 size class 共 66 档,从小往上大致是:8、16、24、32、48、64、80、96、112、128、144、160、176、192、208、224、240、256、320、384、416、448、480、512、640、768、896、1024……每往上间隔越大,直到 32KB。规律很好记:小档间隔密(8~16B 一档),大档间隔疏(128B 起跳)——因为对象越大,内部碎片的绝对浪费越可怕,必须让间隔跟上去。

对应关系是:档位 → span 里一格的尺寸;span 由 8KB 的 page 组成,一个 span 的总大小 = 每格尺寸 × 格数,向上取整到 page。给你一张速查表,排查内存问题时拿它换算"一个 span 能装几个对象":

size class(格尺寸)每格对象1 个 span(最小)占用每 page 格数
8B 8B 8KB 1024
16B 16B 8KB 512
32B 32B 8KB 256
128B 128B 8KB 64
512B 512B 8KB 16
1KB 1KB 8KB 8
4KB 4KB 8KB 2
8KB 8KB 8KB 1
16KB 16KB 16KB 1
32KB 32KB 32KB 1

注:表为最小配置;实际 span 也可能由多个 page 组成(格数多时)。>32KB 的大对象不在此列,走 mheap 直通。

span 的账本是一对位图:allocBits 记"这格有没有被分配出去",gcmarkBits 记"这格里的对象是死是活"(GC 用)。两本账分开记,是第八节"分配和 GC 不打架"的关键——分配器只动 allocBits,GC 只动 gcmarkBits,互不干扰,GC 结束再一次性对账。

面试追问 Q:为什么用位图不用链表记录空格? A:三个原因。① 空间:位图每位只占 1/8 字节,链表一个节点要 16B+;② 缓存:位图连续内存,扫起来 cache line 友好;③ GC 友好:清扫时按位图整段判断,能一次处理 64 格。代价是"找第一个 0 位"要扫,但扫的是寄存器里几条 WORD,反而更快。

面试追问 Q:内部碎片最坏多少? A:按 66 档的间隔,最坏情况请求尺寸刚好越过某档(如 8.1KB 被归到 16KB 档),浪费接近 50%;但统计意义上平均浪费在 12.5% 以内,因为档位间隔本身就有上限约束。

五、mcache:每个 P 的私有抽屉

第 5 站的目的地。mcache 是 per-P 的结构,里面按 size class 摆了一排 span 指针(加上 tiny 专用块、栈 span 缓存)。它解决的是小对象分配的锁问题:前 Go 时代全局一把分配锁,多核机器上分配本身就是瓶颈。

设计上三个点值得记:

其一,零锁的代价是冗余。 每个 P 都持有全部档位的 span,8 核机器就是 8 套。极端情况下某些 span 长期不用也占着内存——但 Go 的运行时宁愿浪费这点内存,也要换分配路径无锁。

其二,mcache 是 GC 的"缓存债主"。 mcache 里的 span 如果在整个 GC 周期都没被用完,GC 会把它们收回去一部分(通过 flushmcache),缓解"内存卡在 P 里不释放"的问题。这也是为什么 GC 后有时能看到内存曲线掉一口。

其三,tiny 块的特殊性。 <16B 且无指针的对象不单独占格,而是往当前 P 的 tiny 块里拼,16B 一块,拼满换新块。字符串拼接产生的大量小对象、[]struct{}{} 里的元素,很多走了这条路。

面试追问 Q:P 被系统线程抢走又还回来,mcache 会丢吗? A:不会。mcache 挂在 P 上,M 只是执行者;M 释放 P 时 mache 跟着 P 走,下一个 M 抢到这个 P,拿到的还是同一个 mcache。真正会触发 mcache 相关清理的是 GC(flushmcache)和 P 数量变化(procresize)。

面试追问 Q:为什么 tiny 只收无指针的小对象? A:有指针的对象必须让 GC 能逐个精确定位,合并存放会让扫描复杂化;无指针对象 GC 反正整块跳过,合并最安全。

六、mcentral 与 mheap:抽屉空了之后

第 9 站的后半程。mcache 要不到货时,流程是这样:

mcentral 层。 每个 size class 一个 mcentral,内部挂着该档位 span 的双向链表,分两堆:partial(还有空格的)和 full(满的)。mcache 来补货,就从 partial 里摘一个;用满了还回去,挂到 full。这个摘还过程要加 mcentral 的锁——高并发分配下,这里是第一个争用点,表现为 mutator assist 时间之外的额外延迟。所以线上如果发现分配延迟的毛刺和 P 数量相关,先怀疑这里。

mheap 层。 mcentral 也空了,找 mheap 切 page。mheap 管着整片堆区(arenas),核心是一个页分配器:记录每个 8KB page 是空闲还是被哪个 span 占了,切 page 给 mcentral 组装新 span。大对象(>32KB)跳过前两层,直接来找 mheap,拿若干连续 page 组成专属 span。

这里藏着一个反直觉的点:“内存还给 mheap"不等于"还给操作系统”。Go 的堆是向 OS 批租的,页分配器把 page 标空闲后,运行时还会按需 scavenge(在后台或分配压力下真正 madvise 归还)。所以 RSS 降得慢是常态,看内存要看 Go 层指标(HeapReleased/HeapInuse),别只看 RSS。

面试追问 Q:mcentral 的锁能不能优化掉? A:运行时做过尝试(如把 span 按 P 分桶的本地 partial 链表思路),但 span 在 P 之间迁移的复杂度上去了。现状是"mcache 挡掉绝大多数、mcentral 扛峰值",属于够用就行的工程折中。用户态的 sync.Pool 本质上就是应用层再挡一层,第七节实战会用到。

面试追问 Q:大对象为什么跳过 mcache? A:两个原因。① 大对象数量少,给它单独占一个 mcache 档位太浪费;② 大对象往往是页面级分配的(数据库 buffer、大 JSON),直接从 mheap 拿连续 page 更符合 OS 的伙伴。另外大对象不进 mcache,也避免了"一个 64KB 对象卡住 mcache 里本来灵活的 span"。

七、和 GC 的配合:span 上的双位图

现在回答全文最核心的问题:分配器和 GC 怎么配合不打架。答案就在第四节埋的伏笔——span 有两本账。

两本账,两个主人。 allocBits(哪格被分配了)归分配器,任何 make/mallocgc 都动它;gcmarkBits(哪格是活的)归 GC,标记阶段写它。两本账轮流上岗:GC 开始时,标记结果写进 gcmarkBits;清扫阶段,分配器拿"allocBits 里有、gcmarkBits 里没有"的格子开刀,把死对象清掉,然后两本账交换身份(sweepgen 加一,gcmarkBits 变成新的 allocBits 基准)。这样下一轮 GC 又有一本干净的账可用,不用每轮清零整本位图——这个"双缓冲"思路,和 GC 篇里讲的混合写屏障是一套设计哲学:能增量就不全局,能复用就不清零。

GC 期间还能分配吗?能,但有"援助税"。 标记阶段应用线程照常 make 对象,可每分配一些字节,就要替 GC 干点标记活——这就是mutator assist(辅助标记):分配速率越快, assist 工作量越大,直观表现为"高峰期分配猛的服务,GC 延迟也涨"。第一节那个网关案例里,真正把 P99 推高的,一半是 assist——修分配才是治本。

GC 频率是被分配速率顶高的。 GOGC=100 的含义是"堆涨到上次 GC 后存活量的 2 倍就触发下一轮"。存活 100MB 的服务,每多分配 100MB 垃圾就GC一次。所以降低单请求分配字节数 = 直接拉长 GC 周期,这比调 GOGC 安全得多(GOGC 调大只是把账往后拖,堆峰值和 OOM 风险跟着涨)。

清扫也要让路。 对象归还、span 回收走 sweep span,是分批增量做的,每轮 GC 干一部分,避免一次性大扫把 latency 打穿。你在 gctrace 里看到的 sweep 分段耗时,干的就是这个。

面试追问 Q:为什么 GC 期间分配的对象不会被误清? A:因为 GC 期间新分配的内存直接按"黑色"记账(不参与本轮标记),位图里 allocBits 和 gcmarkBits 会同步置位——新对象天生带免死金牌,本轮不清,下轮再说。这也是"GC 期间分配速率直接影响 assist 工作量"的制度根源。

面试追问 Q:sweepgen 翻转会溢出吗? A:会,所以留了特判:到边界时强制做一次全量清扫,把两本账归位。属于实现细节,面试点到"双缓冲交替"即可。

八、逃逸分析:编译器替你关哪扇门

不是所有对象都走上面 9 站。编译器在编译期做逃逸分析,判断对象能不能待在栈上——栈分配不走 mcache/mcentral/mheap,函数返回直接弹栈,零成本。

常见逃逸场景(也是面试高频),一张表说完:

写法逃逸吗原因
p := &x; return p 逃 指针比局部变量活得久
fmt.Println(x) 逃 进 interface{},编译器看不到对岸
闭包捕获局部变量 逃 闭包可能比函数活得久
s := make([]int, 0, n); append 到栈上限内 不一定 编译器能证明不逃就不逃
new(T) 后只在函数内用 不一定 Go 1.20+ 优化了部分场景

反逃逸技巧只有一条主线:缩小对象的作用域可见性。值拷贝代替指针传递(小对象)、预分配 slice 容量(让编译器看到上界)、接口调用换成具体类型(重大热点才做,别过度优化)。验证手段就一条命令:

go build -gcflags='-m' ./... 2>&1 | grep "escapes to heap"

看到谁在输出里排队,就是热点。但记住:逃逸分析是编译器的启发式,版本间会变,同一份代码 Go 1.21 和 Go 1.25 的结论可能不同,别把"某个版本不逃逸"当定律写进注释。

面试追问 Q:栈分配的对象会被 GC 管吗? A:不会。GC 只管堆;栈对象的生死跟着函数调用走,编译期就定死了。这也是为什么"能栈尽栈"是零成本优化。

面试追问 Q:goroutine 栈满了怎么办? A:换一块两倍大的栈,把老的拷贝过去(Go 1.3 起的"栈拷贝",替代了老的分段栈)。初始栈只有几 KB,按需倍增;收缩由 GC 时检查,用得少的栈会缩回去。频繁扩容说明热点函数里的大数组该挪堆或者复用。

九、实战:benchmark、一次真实排查、一张速查表

理论全部落地,上实战。

9.1 先量:分配到底多贵

func BenchmarkBufPerCall(b *testing.B) {
for i := 0; i < b.N; i++ {
buf := make([]byte, 4096)
sink = buf // 防止被优化掉
}
}
func BenchmarkBufPooled(b *testing.B) {
pool := sync.Pool{New: func() any { return make([]byte, 4096) }}
b.ResetTimer()
for i := 0; i < b.N; i++ {
buf := pool.Get().([]byte)
sink = buf
pool.Put(buf)
}
}

结论先行:pool 版通常能把 ns/op 和 allocs/op 双双打下来一到两个量级,但只在"对象活着出不了函数、反复借还"的场景成立。对象如果要跨请求长期存活,pool 反而增加复杂度和 GC 追踪成本。上 pool 前先问自己:这个缓冲区真的用一次就扔吗?

9.2 再查:一次真实排查复盘

回到第一节的网关。定位路径完整复盘,你可以照抄:

  • GODEBUG=gctrace=1 ./gateway 跑一天,发现 GC 周期从 2 分钟缩到 20 秒,且每次标记阶段 assist 时间占比高;
  • go tool pprof -alloc_space http://host/debug/pprof/allocs 抓分配来源,top 里 json.Marshal 占 60%;
  • 下钻发现某下游接口在响应体里塞了整段富文本 HTML,单请求 60KB;
  • 修复:和下游约定分页 + 字段裁剪,单请求分配回落到 2KB。没动一行 GC 参数,P99 回到 3ms。
  • 注意顺序:先 gctrace 确认 GC 真的忙,再 alloc_space 找分配源,最后才轮到改代码。跳步的常见后果是拿着 GOGC 参数当万能药。

    9.3 常备:分配慢 / GC 频繁排查速查表

    这张表建议收藏,线上出问题按顺序过:

    现象头号嫌疑定位命令/工具处理方向
    P99 抖、gctrace assist 高 分配速率过高 pprof -alloc_space 裁字段/分页/对象复用
    GC 频率高、HeapInuse 低 对象活不过一轮 runtime.ReadMemStats 看 PauseNs+存活 减少临时对象,勿急着调 GOGC
    GC 频率高、HeapInuse 高 真有内存泄漏 pprof -inuse_space 对比两个时间点 查全局 map/goroutine 泄漏
    RSS 不降 页未还给 OS HeapReleased vs HeapInuse 属正常,debug.FreeOSMemory() 应急
    分配延迟随核数抖动 mcentral 锁争用 GOMAXPROCS 对比测试 限流/批量分配/sync.Pool
    栈持续扩容 热点函数大数组 -gcflags=-m 看逃逸 改栈上小数组/堆上复用

    面试追问 Q:sync.Pool 和 GC 的关系? A:sync.Pool 的对象每轮 GC 会被清空(不进入常规存活统计),所以它缓存的是"跨 GC 无所谓"的临时对象。想缓存活过 GC 的长生命周期对象,别用 Pool,用对象池自己管。

    面试追问 Q:GOGC 和 GOMEMLIMIT 一起用,谁兜底? A:GOMEMLIMIT 是软硬兼施的总额度(Go 1.19+):接近上限时 GC 变激进;实在顶不住才 OOM。GOGC 管频率,GOMEMLIMIT 管天花板,容器环境建议两个都显式设置,别让运行时猜。

    💡 本章面试要点

  • 三层结构张口就来:mcache(per-P 无锁)→ mcentral(per-class 有锁)→ mheap(全局+页分配器);>32KB 大对象直通 mheap。
  • size class 66 档、span 由 8KB page 组成、一个 span 一个档位;分档是用内部碎片换零外部碎片。
  • 一次分配的路径:makeslice → mallocgc → 算档位 → tiny 判定 → mcache → 扫 allocBits → 返回;mcache 命中全程无锁。
  • 双位图是分配与 GC 协作的核心:allocBits 归分配器、gcmarkBits 归 GC,sweepgen 翻转交替,双缓冲不清零。
  • GC 期间分配照常,但有 assist 税:分配速率直接推高 GC 延迟——治延迟先治分配。
  • GOGC 控频率、GOMEMLIMIT 控天花板;降分配字节数比调 GOGC 安全。
  • 逃逸分析决定栈/堆;-gcflags=-m 验证;结论随 Go 版本变化,别当定律。
  • 排查顺序铁律:gctrace 确认 → alloc_space 找源 → 才改代码;RSS 不降先看 HeapReleased 是不是正常现象。
  • 写在最后

    留一个具体问题:你的服务单请求分配字节数是多少?如果没有量过,用 pprof -alloc_space 抓一次 top,看看头号分配源是什么——欢迎评论区贴出来,我挑几个有代表性的拆解。

    如果这篇帮你把"make 一次到底发生了什么、GC 怎么和它配合"理顺了,建议收藏——第四节「size class 档位速查表」和第九节「分配慢/GC 频繁排查速查表」是能直接贴进团队 wiki 的,下次线上分配出问题,按表过一遍,比现翻源码快得多。

    下篇预告:「保姆级万字详解」系列 Go 进阶(四)——channel 与 select 的底层:从"两个 goroutine 互发 100 万条消息"讲起,拆环形队列、发送/接收等待队列、select 的随机公平,以及为什么关闭的 channel 会 panic。

    作者:利威尔xu | CSDN 专栏《GO保姆级万字详解》

    赞(0)
    未经允许不得转载:171主机测评 » 万字详解 | Go 进阶(三)内存分配器:一次 make 背后 9 次跳转,它和 GC 怎么配合不拖垮延迟
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址