Go随笔 | map泛滥的世界
开发中经常需要用到map数据类型,主流编程语言也都实现了类似的功能,比如叫哈希、散列、map等等,实现数据结构都类似,性能不相上下。网上有很多关于map实现解析的文章。参考:
https://www.jianshu.com/p/0a777dc7f7ae
https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-hashmap
大家可能注意到,map使用过程中可能会根据键值对的增减而发生动态伸缩,而伸缩的过程是重新申请内存重建map的过程,旧的对象就需要进入GC回收。明显频繁使用map会造成性能的下降。究竟影响有多大呢?
map和数组存取比较
map主要是为快速检索对象设计的。在Web开发中几乎都是用map来收集提交的参数;在这种场景下使用map检索值和直接使用字符串比较检索值有差别吗,下面做了个测试。
模拟表单提交
假设某个Web页面请求提交的参数字段是确定的;我们分别用map和数组来实现对Web表单值的存储,最后再循环遍历所有值,尽量模拟业务开发中的场景。
|
|
对上面的两种写法做性能测试:
|
|
基准测试:go test -bench=String$ -benchmem
如果字段少于8个,keys = []string{“user_name”, “user_age”, “created_at”, “updated_at”, “open_time”} 测试结果如下:
cpu: Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz
BenchmarkHashString-8 8109505 148.0 ns/op 0 B/op 0 allocs/op
BenchmarkArrayString-8 9125210 128.3 ns/op 80 B/op 1 allocs/op
如果字段刚好超过8个,9个的时候:keys = []string{“user_name”, “user_age”, “created_at”, “updated_at”, “open_time”, “address”, “toys”, “status”, “mobile”} 测试结果如下:
cpu: Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz
BenchmarkHashString-8 1648086 736.5 ns/op 577 B/op 1 allocs/op
BenchmarkArrayString-8 4992355 223.1 ns/op 144 B/op 1 allocs/op
如果字段不止8个,24个的时候呢:keys = []string{“user_name”, “user_age”, “created_at”, “updated_at”, “open_time”, “address”, “toys”, “status”, “mobile”, “phone”, “age”, “date”, “user_name2”, “user_age2”, “created_at2”, “updated_at2”, “open_time2”, “address2”, “toys2”, “status2”, “mobile2”, “phone2”, “age2”, “date2”} 测试结果如下:
cpu: Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz
BenchmarkHashString-8 535888 2234 ns/op 1944 B/op 2 allocs/op
BenchmarkArrayString-8 1287135 923.6 ns/op 384 B/op 1 allocs/op
分析:
- map单个桶最多装8个值,而局部变量一般分配在执行栈中,因此参数不大于8个的时候,申请的局部变量pms能装下所有参数,内容全部在栈内存中,因此能看到性能测试没有内存分配和回收;map整体性能还不错。即便如此,map生成和检索的性能还不如直接字符串遍历比较检索值来的快。
- map值超过8个时,会出现溢出桶;于是有内存需要动态分配并回收;map性能下降明显。数组模式也因为循环字符串比较变多而出现性能下降。
- map值进一步增加时,意味着更多溢出桶,甚至出现map的自动伸缩,性能下降很多。数组模式因为循环比较性能下降不少。
- 数组模式字符串循环比较其实还是有一定性能优化空间的,比如前缀树快速定位字段。
结论:
- map作为函数局部变量,初始化时因为大小固定能放8组KV,因此编译器优化将对象分配在栈中。
- map除了能随意放入KV,在KV量不是特别大的时候,其哈希检索性能还不如遍历字符串检索值。
- map隐形中带来堆分配,并随着装载量的增减而伸缩,带来GC增多,同时性能严重下降。
sync.Pool提高性能
map对象由于无法重置项目值,只能delete所有key,一般map不方便用sync.Pool缓存并重用。而数组中的值可以遍历重置,可以用sync.Pool提高性能。上面测试代码做如下的小改造:
|
|
slice放入Pool,测试结果如下:
# 输入5个字段
BenchmarkHashString-8 8003494 151.5 ns/op 0 B/op 0 allocs/op
BenchmarkArrayString-8 16711183 70.54 ns/op 0 B/op 0 allocs/op
# 输入9个字段
BenchmarkHashString-8 1702929 697.7 ns/op 577 B/op 1 allocs/op
BenchmarkArrayString-8 7204788 165.8 ns/op 0 B/op 0 allocs/op
# 输入24个字段
BenchmarkHashString-8 547135 2162 ns/op 1944 B/op 2 allocs/op
BenchmarkArrayString-8 1419124 775.0 ns/op 0 B/op 0 allocs/op
结论:
- sync.Pool的确能够有效复用对象,减轻GC压力,提高性能。
高并发测试
在保持数组模式用sync.Pool缓存的情况下,我们给单个核5000个并发压测一下看看效果
|
|
高并发,测试结果如下:
# 输入5个字段
BenchmarkHashStringBF-8 21547699 52.41 ns/op 0 B/op 0 allocs/op
BenchmarkArrayPoolStringBF-8 44316729 23.05 ns/op 0 B/op 0 allocs/op
# 输入9个字段
BenchmarkHashStringBF-8 3531322 408.3 ns/op 578 B/op 1 allocs/op
BenchmarkArrayPoolStringBF-8 15419502 118.7 ns/op 0 B/op 0 allocs/op
# 输入24个字段
BenchmarkHashStringBF-8 1258896 884.2 ns/op 1947 B/op 2 allocs/op
BenchmarkArrayPoolStringBF-8 4403827 270.5 ns/op 1 B/op 0 allocs/op
结论:
- Go性能的确好,高并发场景,能高效利用CPU,提高吞吐效率。
- 高并发场景,进一步体现sync.Pool的性能优势。
map也能用sync.Pool缓存复用
看看下面的试验,我们用sync.Pool缓存map对象,复用的时候每次循环重置所有值(当然这种办法复用对象有不严谨的地方):
|
|
map放入Pool,测试结果如下:
# 输入5个字段
BenchmarkHashString-8 8109230 147.8 ns/op 0 B/op 0 allocs/op
BenchmarkHashPoolString-8 4668223 252.1 ns/op 0 B/op 0 allocs/op
BenchmarkArrayPoolString-8 16450185 71.01 ns/op 0 B/op 0 allocs/op
# 输入9个字段
BenchmarkHashString-8 1684905 706.9 ns/op 577 B/op 1 allocs/op
BenchmarkHashPoolString-8 2490994 456.8 ns/op 0 B/op 0 allocs/op
BenchmarkArrayPoolString-8 7936622 150.1 ns/op 0 B/op 0 allocs/op
# 输入24个字段
BenchmarkHashString-8 524149 2169 ns/op 1944 B/op 2 allocs/op
BenchmarkHashPoolString-8 999249 1238 ns/op 0 B/op 0 allocs/op
BenchmarkArrayPoolString-8 1522460 780.4 ns/op 0 B/op 0 allocs/op
分析:
- map在局部变量,内存分配在栈中。此时用Pool,增加管理的损耗,性能反而下降。
- map存在扩容,伸缩等场景下;Pool能起到缓存对象,复用内存提升性能的作用。
- KV数量不大时,比如三五十个项以内,map的性能没有循环匹配字符串来的快。
总结
在Go项目中,随处可见map的使用;甚至有滥用的嫌疑。不知道大家对map运行细节是否都了然于胸,这个类型使用场景多,方便好用,但稍不留意,很容易写出性能低下的代码。希望大家引起注意。
大结论:通常,保存检索50个以下键值对的场景;相比用map,用数组实现大概率更省内存,性能更好。
我想直接循环遍历比较每个字符串肯定还有优化的空间,待后面再好好想想办法。
(完)
- 原文作者: 闪电侠
- 原文链接:https://chende.ren/2023/03/20203050-008-use-map.html
- 版权声明:本作品采用 开放的「署名 4.0 国际 (CC BY 4.0)」创作共享协议 进行许可