最近一直在重构优化老系统,所以性能优化相关的文章会比较多。

这次的是有关循环处理map时的性能优化。预分配内存之类的大家都知道的就不多说了,今天来讲点大伙不知道的。

要讲的一共有三点,而且都和循环处理map有关。

不要用for-range循环清空map

这里要讨论的“清空”是指删除map中所有键值对,但保留map里已分配的内存供下次复用。

如果只是想释放map并且不再需要复用,那么map1 = nil或者map2 = map[T]U{}就足够了。

内置函数delete可以帮我们实现删除键值对但保留它们在map中的内存空间,通常我们会这么写:

for key := range Map1 {
delete(Map1, key)
}

这种模式化的代码太常见,以至于go编译器专门对其做了优化,只要形式上符合上述代码片段的,编译器都会把循环优化成runtime.mapclear(Map1),使用runtime内置的map清理函数将map清空,这比靠循环遍历删除要快很多倍。

看到这里你可能会说这不是挺好吗,为什么不让用了呢?

因为现在有更好的替代方案了——内置函数clear

clear应用在map上时本身就会调用runtime.mapclear(...),在性能上和循环方法大致一样而且只快不慢。因为两者最终生成代码差不多,性能测试也就没多大意义了,所以这里不做性能测试。

clear还有另一个好处,它更容易让包含它的函数被内联。

这是什么意思呢?go的编译器实际上在编译时要分很多个步骤,粗略地讲go代码在真正开始生成机器码之前,得先经过内联 -> 逃逸分析 -> 语法树改写这样几个阶段。上文说的for循环删除优化在语法树改写这个阶段完成。

这就带来了一个问题,相比一个简单的clear函数调用,编译器认为for循环这种操作更“重量级”,一个函数拥有的“重”操作越多,那么这个函数被内联优化的可能性就越小。

我们看个例子:

func RangeClearMap() {
for k := range bigMap {
delete(bigMap, k)
}
for k := range bigPtrMap {
delete(bigPtrMap, k)
}
for k := range smallMap {
delete(smallMap, k)
}
for k := range smallPtrMap {
delete(smallPtrMap, k)
}
for k := range bigMapIntKey {
delete(bigMapIntKey, k)
}
for k := range bigPtrMapIntKey {
delete(bigPtrMapIntKey, k)
}
for k := range smallMapIntKey {
delete(smallMapIntKey, k)
}
for k := range smallPtrMapIntKey {
delete(smallPtrMapIntKey, k)
}
} func BuiltinClearMap() {
clear(bigMap)
clear(bigPtrMap)
clear(smallMap)
clear(smallPtrMap)
clear(bigMapIntKey)
clear(bigPtrMapIntKey)
clear(smallMapIntKey)
clear(smallPtrMapIntKey)
}

同样是清空8个map,一个用循环,一个用内置函数。我们看下内联分析的结果:

其中cost就是衡量一个函数里的操作有多“重”的数值标准,超过一定的cost,函数就无法内联。可以看到,使用循环会比使用clear内置函数重整整四倍。虽然最后因为两个函数都很简单所以被内联展开,但碰上更复制一点的函数,显然使用clear能有更多的冗余。

尽管编译器最终会把两者优化成一样的对runtime的map清理函数的调用,但对for循环的优化在内联处理之后,因此for不仅让代码更长,也更容易错失内联优化的机会,而失去内联优化进而会影响逃逸分析从而损失更多性能,你可以在我以前的博客里看到内联和逃逸分析对内联的影响。

clear()是go1.21添加的,因此只要你在用的go版本大于等于1.21,我推荐你尽量使用clear而不是for-range循环来清空map。

遍历访问map时的陷阱

遍历处理map中的元素也是常见操作,不过不像循环删除,编译器在这种代码上并没有什么优化。

最常见的写法是这样的:

for key, value := range Map1 {
func1(key, value)
func2(key, value)
}

这时候,一部分开发者会觉得每次循环都得复制一次value,尤其是1.22开始循环变量每轮都是新变量,这种操作是不是会很慢也很占用内存?毕竟在遍历slice的时候确实有这些问题,那么能不能采用优化slice遍历的相同方法来优化map遍历呢:

for key := range Map1 {
func1(key, Map1[key])
func2(key, Map1[key])
}

现在不用复制value了,性能应该获得提升了吧?真的是这样吗?

我说过很多次,性能优化不能靠想象,要靠benchmark来说话,所以我们来做个性能测试。

我们测试大value和小value在两种循环下的表现,另外还会额外测试一下map里存放指针的情况,测试用的value类型主要是下面两种:

// 128字节
type BigObject struct {
n1, n2, n3, n4, n5, n6, n7, n8, n9, n10 int64
s1, s2, s3 string
} // 32字节
type SmallObject struct {
n1, n2 int64
s1 string
}

我们一共分8种case进行测试:

var (
bigMap = map[string]BigObject{}
bigPtrMap = map[string]*BigObject{}
smallMap = map[string]SmallObject{}
smallPtrMap = map[string]*SmallObject{}
) func init() {
for i := range int64(100) {
strKey := fmt.Sprintf("Key:%03d", i)
bigMap[strKey] = NewBigObject()
bigPtrMap[strKey] = NewBigObjectPtr()
smallMap[strKey] = NewSmallObject()
smallPtrMap[strKey] = NewSmallObjectPtr()
}
}

每个map里都填充100个元素,元素的值随机生成,不过我限制了字符串都是等长的,这是为了结果的准确性。

测试也比较简单:

func BenchmarkBigObjectLoopCopy(b *testing.B) {
for b.Loop() {
for _, v := range bigMap {
if v.n1 == 0 || v.n2 == 0 {
panic("error")
}
}
}
} func BenchmarkBigObjectPtrLoopCopy(b *testing.B) {
for b.Loop() {
for _, v := range bigPtrMap {
if v.n1 == 0 || v.n2 == 0 {
panic("error")
}
}
}
} func BenchmarkBigObjectLoopKey(b *testing.B) {
for b.Loop() {
for k := range bigMap {
if bigMap[k].n1 == 0 || bigMap[k].n2 == 0 {
panic("error")
}
}
}
} func BenchmarkBigObjectPtrLoopKey(b *testing.B) {
for b.Loop() {
for k := range bigPtrMap {
if bigPtrMap[k].n1 == 0 || bigPtrMap[k].n2 == 0 {
panic("error")
}
}
}
} func BenchmarkSmallObjectLoopCopy(b *testing.B) {
for b.Loop() {
for _, v := range smallMap {
if v.n1 == 0 || v.n2 == 0 {
panic("error")
}
}
}
} func BenchmarkSmallObjectPtrLoopCopy(b *testing.B) {
for b.Loop() {
for _, v := range smallPtrMap {
if v.n1 == 0 || v.n2 == 0 {
panic("error")
}
}
}
} func BenchmarkSmallObjectLoopKey(b *testing.B) {
for b.Loop() {
for k := range smallMap {
if smallMap[k].n1 == 0 || smallMap[k].n2 == 0 {
panic("error")
}
}
}
} func BenchmarkSmallObjectPtrLoopKey(b *testing.B) {
for b.Loop() {
for k := range smallPtrMap {
if smallPtrMap[k].n1 == 0 || smallPtrMap[k].n2 == 0 {
panic("error")
}
}
}
}

8种case就是大value的map和小value的分别用k,v := range map遍历和只用key遍历,看看性能差异。

结果很是让人诧异,自认为的不需要复制value所以访问更快的结论是错的,而且错的离谱:复制value的做法性能是只用key遍历的3倍!

为啥会这样呢?因为你掉进hashmap的陷阱里了,我就直接说原因了:

  1. for k,v := range m遍历map时,键值对是被顺序访问的,这对缓存命中和cpu的模式预测更友好,性能会比用key进行hash的随机访问要好;
  2. 使用m[k]访问时需要计算key的hash,这一步是比较耗费计算资源的,哪怕新版本换了swissmap之后也一样,而for-range循环不需要计算hash值。hash计算对性能的影响可以看我这篇博客讲解。

这两点就是hashmap的陷阱,本来在少量多次或者随机查找等模式下算不上太大的问题,但在循环遍历的情形下影响就会放大,最后导致出现3倍以上的性能差异。

所以当你要遍历处理每一个value的时候,最好用for k,v := range m或者for _,v := range m

不过也有例外:如果你的value比较大,你需要遍历key,符合过滤条件的key的value才需要处理,这种时候那些无用的复制就会成为性能绊脚石了。不过还是老话,先做benchmark再谈优化。

复制map时的性能陷阱

最后一个性能陷阱埋在复制map时。

这里说的“复制”是浅复制,把键值对复制到一个新map里去,里面的指针或者slice都是浅拷贝的。

还是先上常见写法,实际上在1.21之前你也只能这么写:

m2 := make(map[T]U, len(m1))
for k, v := range m1 {
m2[k] = v
}

编译器同样也不会对这种代码有特殊优化,循环会老老实实地执行。

到了1.21,我们可以用maps.Clone做一样的事情,而且这个标准库函数也会在底层调用runtime里的专用map复制函数,性能杠杠的。

我们准备一下性能测试,map样本沿用上一节里的:

func BenchmarkMapClone(b *testing.B) {
b.Run("range-smallMap", func(b *testing.B) {
for b.Loop() {
m := make(map[string]SmallObject, len(smallMap))
for k, v := range smallMap {
m[k] = v
}
}
})
b.Run("range-smallPtrMap", func(b *testing.B) {
for b.Loop() {
m := make(map[string]*SmallObject, len(smallPtrMap))
for k, v := range smallPtrMap {
m[k] = v
}
}
})
b.Run("range-bigMap", func(b *testing.B) {
for b.Loop() {
m := make(map[string]BigObject, len(bigMap))
for k, v := range bigMap {
m[k] = v
}
}
})
b.Run("range-bigPtrMap", func(b *testing.B) {
for b.Loop() {
m := make(map[string]*BigObject, len(bigPtrMap))
for k, v := range bigPtrMap {
m[k] = v
}
}
})
b.Run("clone-smallMap", func(b *testing.B) {
for b.Loop() {
maps.Clone(smallMap)
}
})
b.Run("clone-smallPtrMap", func(b *testing.B) {
for b.Loop() {
maps.Clone(smallPtrMap)
}
})
b.Run("clone-bigMap", func(b *testing.B) {
for b.Loop() {
maps.Clone(bigMap)
}
})
b.Run("clone-bigPtrMap", func(b *testing.B) {
for b.Loop() {
maps.Clone(bigPtrMap)
}
})
}

在这里如果你用的go版本是1.24,那么你会踩到第一个陷阱,对没错,我说了有三种陷阱,没说只有三个哦。

1.24的maps.clone实现有问题,会有严重的性能回退,所以你可以看到它和循环复制性能没有差距,甚至有时候还更慢一点:

具体是什么样的问题我就不深入讲解了,因为是偷懒导致的很无聊的问题。好在这个问题会在1.25修复,修复代码已经在主分支上了,因此我们可以用go version go1.25-devel_3fd729b2a1 Sat May 24 08:48:53 2025 -0700 windows/amd64来测试:

修复后结果就和1.22以及1.23一样了。总得来说maps.Clone虽然多浪费了一点内存,但速度是循环复制的1.5~3倍。

所以要复制map的时候,尽量去用maps.Clone,这样就能避开循环复制慢这第二个陷阱。

总结

golang果然还是那个golang,大道至简的外皮下往往暗藏杀机。

真要说什么原则的话,那就是如果有对应的标准库函数/内置函数,那就用,尽量少在map上直接用循环。

还有我说过无数次的,性能优化要靠benchmark,切记不要依赖经验去预判,陷阱二就是我用benchmark找出来的“预判”失误。

golang遍历处理map时的常见性能陷阱的更多相关文章

  1. 警惕 MySql 更新 sql 的 WHERE 从句中的 IN() 子查询时出现的性能陷阱

    警惕 MySql 更新 sql 的 WHERE 从句中的 IN() 子查询时出现的性能陷阱 以下文章来源:https://blog.csdn.net/defonds/article/details/4 ...

  2. golang遍历时修改被遍历对象

    目录 前言 遍历切片 遍历map 总结 前言 很多时候需要将遍历对象中去掉某些元素,或者往遍历对象中添加元素,这时候就需要小心操作了. 对于go语言中的一些注意事项我做了总结和示例,留下点笔记. 遍历 ...

  3. ConcurrentHashMap是如何提高并发时的吞吐性能

    为并发吞吐性能所做的优化 ConcurrentHashMap使用了一些技巧来获取高的并发性能,同时避免了锁.这些技巧包括: 为不同的Hash bucket(所谓hash bucket即不同范围的key ...

  4. 常见性能优化策略的总结 good

    阅读目录 代码 数据库 缓存 异步 NoSQL JVM调优 多线程与分布式 度量系统(监控.报警.服务依赖管理) 案例一:商家与控制区关系的刷新job 案例二:POI缓存设计与实现 案例三:业务运营后 ...

  5. 记一次坑爹的golang 二维map判断问题

    记一次坑爹的golang 二维map判断问题 2018年10月18日 23:16:21 yinnnnnnn 阅读数:32更多 个人分类: golang   版权声明:本文为博主原创文章,未经博主允许不 ...

  6. Unity游戏项目常见性能问题

    Unity技术支持团队经常会对有需求的客户公司项目进行游戏项目性能审查与优化,在我们碰到过的各种项目相关的问题中也有很多比较共同的方面,这里我们罗列了一些常见的问题并进行了归类,开发者朋友们可以参考下 ...

  7. Golang通脉之map

    Go语言中提供的映射关系容器为map,其内部使用散列表(hash)实现. map 是一种无序的键值对的集合.map 最重要的一点是通过 key 来快速检索数据,key 类似于索引,指向数据的值 map ...

  8. 分页查询和分页缓存查询,List<Map<String, Object>>遍历和Map遍历

    分页查询 String sql = "返回所有符合条件记录的待分页SQL语句"; int start = (page - 1) * limit + 1; int end = pag ...

  9. 用<forEach>遍历list集合时,提示我找不到对象的属性

    <c:forEach items="${list}" var="item"> <tr> <td>${item.UserId} ...

  10. JavaScript-//FOR/IN循环。当使用for/in循环遍历关联数组时,就可以清晰地体会到for/in的强大之处。

    <script> //FOR/IN循环.当使用for/in循环遍历关联数组时,就可以清晰地体会到for/in的强大之处. function getvalue(portfolio){ var ...

随机推荐

  1. [Qt 基础内容-05] QDialogButtonBox

    QDialogButtonBox 本文主要根据QT官方帮助文档以及日常使用,简单的介绍一下QDialogButtonBox的功能以及使用 文章目录 QDialogButtonBox 简介 信号和槽 基 ...

  2. The selected directory is not a valid home for Go SDK

    前言 The selected directory is not a valid home for Go SDK 出现这个错误的原因是 idea 的 Go-plugin 插件,和 Go 的sdk版本不 ...

  3. 张高兴的大模型开发实战:(二)使用 LangChain 构建本地知识库应用

    目录 基础概念 什么是 LangChain 什么是 Ollama 环境搭建与配置 安装 Ollama 安装 LangChain 文档加载 加载 JSON 数据 加载文件夹中的文档 文本向量化 实现问答 ...

  4. Effective Java理解笔记系列-第2条-何时考虑用构建器?

    为什么写这系列博客? 在阅读<Effective Java>这本书时,我发现有许多地方需要仔细认真地慢慢阅读并且在必要时查阅相关资料才能彻底搞懂,相信有些读者在阅读此书时也有类似感受:同时 ...

  5. JDK7-日历类--java进阶day07

    1.Calendar类 用于获取或者修改时间,之前学的Date类,获取和修改时间的方法已经过时 2.Calendar对象的创建 Calendar类里面有很多抽象方法,如果创建对象就要全部重写,所以不能 ...

  6. JavaScript 获取鼠标点击位置坐标(转载自https://www.cnblogs.com/dolphinX/archive/2012/10/09/2717119.html )

    在一些DOM操作中我们经常会跟元素的位置打交道,鼠标交互式一个经常用到的方面,令人失望的是不同的浏览器下会有不同的结果甚至是有的浏览器下没结果,这篇文章就上鼠标点击位置坐标获取做一些简单的总结,没特殊 ...

  7. HTB打靶记录-Vintage

    信息收集 nmap -sV -sC -O 10.10.11.45 Nmap scan report for 10.10.11.45 Host is up (2.1s latency). Not sho ...

  8. Unity中检查重复的资源

    目的:检查Unity中资源是否重复,例如有两张贴图,明明是一张,却被复制为两份放在工程中,名字或者所在目录位置不同,这对于资源管理来说是很浪费的.因此需要写一个检查工具来检查项目中是否存在重复的资源. ...

  9. 将本地库上传到 GitHub

    4.4.1 上传本地库 在 GitHub 网站上创建仓库 复制仓库地址 在 Idea 中的模块上右键 设置远程地址别名 点击 Push 推送到 GitHub 仓库 上传成功 4.4.2 正常情况下是合 ...

  10. idea的deployment没有war包

    一.解决方案