编程 maps.Grow 进了 Go 1.28 草案:给 map 预留容量,少搬几次 bucket

2026-11-09 21:02:59

maps.Grow 进了 Go 1.28 草案:给 map 预留容量,少搬几次 bucket

状态:提案已接受,目标版本 Go 1.28(计划 2027 年 2 月发布)。当前发布版还没有这个函数,落地前以官方文档为准。

  • Go 1.28 发布说明(编写中):https://go.dev/doc/go1.28
  • 提案与讨论:https://github.com/golang/go/issues

现状:map 只有创建时的一个 hint

make(map[K]V, n) 里的 n 只在建 map 的那一刻起作用,而且它说的是「最终大概装多少个」,不是「还要再塞多少个」。之后再往里写,运行时只在插入路径上按负载因子判断要不要扩容,扩容就得把旧 bucket 里的键值对搬到新 bucket(渐进式 rehash)。

slice 有等价物:make([]T, 0, n) 一次分配好底层数组,之后 append 只要不超容量就不搬数据。map 没有对应的东西——已经存在的 map,你没办法告诉运行时「我接下来还要插 5000 个,先把地方腾出来」。

maps.Grow 的语义

func Grow[M ~map[K]V, K comparable, V any](m M, n int)
  • 参数 n 是「还将插入的条目数」,按这个数量预留空间,不是把 map 容量钉死在某个上限。
  • 重复调用是追加语义:Grow(m, 100) 之后再 Grow(m, 100),相当于按再插 200 个来准备。
  • 内部按几何级增长预留,避免一次预留过多,代价是预留量与实际需求之间仍有偏差。
  • 传入负数或 0 是安全的空操作(这一点要等正式文档确认)。

它要解决的是「逐次扩容 + rehash」这个循环,而不是改变 map 的查找复杂度。

典型场景

把一份已有的 map 合并进另一个 map 时,目标 map 往往不是刚建的,或者要接多个来源:

  • 复制 http.Header:来源常常是外部输入(请求头、代理透传),键的数量事先不知道,但 len(src) 是知道的。
  • 合并配置 map:base config 叠加 override,写之前先按 len(override) 预留。
  • JSON 解码后的 map 再加工:map[string]any 解出来之后做字段改写或合并,同样可以先看一眼源 map 的长度。

模式都一样:先 Grow(dst, len(src)),再循环写。

可运行示例

maps.Grow 现在还不能用,所以示例里用了一个占位函数 grow,函数体是空的。等 Go 1.28 落地,把函数体换成 maps.Grow(m, n) 就行,调用点不用动。

package main

import (
	"fmt"
	"net/http"
	"sort"
)

// grow 是 maps.Grow 的临时替身。
// Go 1.28 之后把函数体换成 maps.Grow(m, n),签名保持不变。
func grow[M ~map[K]V, K comparable, V any](m M, n int) {
	_ = m
	_ = n // 当前标准库没有预留手段,只能等插入时自动扩容
}

// MergeHeader 把 src 合并进 dst。
// dst 可能是调用方传进来的、已经有内容的 map,make 的容量参数帮不上忙。
func MergeHeader(dst, src http.Header) {
	grow(dst, len(src))
	for k, vs := range src {
		dst[k] = append(dst[k], vs...)
	}
}

// MergeHeaderPrealloc 是对照组:只有在新建 map 时才能用 len 估一次。
func MergeHeaderPrealloc(src http.Header) http.Header {
	dst := make(http.Header, len(src))
	for k, vs := range src {
		dst[k] = append(dst[k], vs...)
	}
	return dst
}

func main() {
	src := http.Header{
		"Content-Type":  {"application/json"},
		"Cache-Control": {"no-store"},
		"X-Request-Id":  {"abc123"},
	}

	// 场景一:目标 map 已经存在,容量只能靠运行时自己长
	dst := http.Header{"Accept": {"text/html"}}
	MergeHeader(dst, src)

	// 场景二:新建时按 len(src) 估个下界
	fresh := MergeHeaderPrealloc(src)

	for _, m := range []http.Header{dst, fresh} {
		keys := make([]string, 0, len(m))
		for k := range m {
			keys = append(keys, k)
		}
		sort.Strings(keys)
		for _, k := range keys {
			fmt.Printf("%s: %v\n", k, m[k])
		}
		fmt.Println("---")
	}
}

现在能做的只有两件事:建 map 时用 len(src) 当 hint,以及尽量把 map 一次建好再填。前者对「已存在的 map」无效,后者在合并场景里往往做不到。Grow 补的就是这个缺口。实际能省多少,取决于键的数量和分布,需自行实测。

什么时候没用

  • 单次小 map:写完就扔、之后不再扩容的 map,预留没有意义。
  • 写入量在一个 bucket 以内(8 个键值对):本来就不会触发扩容,Grow 只是多一次调用。
  • map 复用:如果你已经在清空后重用同一个 map(clear(m)),bucket 数组还在,容量没丢,再 Grow 是白忙。这种场景下重用比重建更划算。

代价

预留是单向的:map 不会因为删除元素而自动缩小,bucket 数组会一直留在那儿。按 len(src) 预留还算克制;如果按「未来可能的最大值」预留,峰值内存会明显上去,尤其是 map 里 value 比较大的时候。估算宁小勿大,不够就让运行时自己长。

Go 1.28 草案里的其他条目

同一份发布说明(仍在编写)还列了这些:

  • go vet 新增两条检查:未检查的 bufio.Scanner 错误、未检查的 sql.Rows 错误。
  • FlagSet.All 迭代器。
  • url.MustParse。
  • 子进程的 Landlock 支持。

另外,Go 1.27.4 与 1.26.11 计划于 11 月 10 日发布,包含标准库与工具链的安全修复,覆盖 12 个 CVE。

复制全文 生成海报 Go 性能优化 map 版本前瞻

推荐文章

程序员茄子在线接单