首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >从应用层面细说map

从应用层面细说map

作者头像
数据小冰
发布2022-08-15 14:43:01
发布2022-08-15 14:43:01
47600
代码可运行
举报
文章被收录于专栏:数据小冰数据小冰
运行总次数:0
代码可运行

2万字图解map文章主要讲述了Go中map的是如何实现的。本文将从应用的角度来总结map使用过程中容易出现的问题,如何保证map并发读写,以及并发读写的优化。

map的基本操作

map的申明

map申明的语法格式为var m map[K]V,这样就得到一个map对象m。需要注意的是,不是任何类型都是可以作为key,通过2万字图解map文章,我们知道map是通过key来定位存储数据在哪个位置的。key必须是可以比较的类型,即可以通过==和!=进行运算。但value类型就没有限制了,可以是任何类型,甚至是nil. 在Go语言中,可以比较的类型有布尔型、整数、浮点数、复数、字符串、通道channel、指针和接口,元素可比较的struct和数组也是可以比较的。不可比较的类型有:slice、map和函数值。下面申明的3个map都是还未初始化的,也就是他们现在都是nil值,直接向nil的map中添加数据会引发panic, 对nil的map查询key、删除key、遍历不会出现panic.

代码语言:javascript
代码运行次数:0
运行
复制
func main(){
 var m1 map[int]int
 var m2 map[string]struct{}
 var m3 map[interface{}]int
}
map的创建(初始化)

map的初始化有两种方式:一种是通过make,另一种是{}.如下代码所示,m和m2和m3都是通过make初始化的,m4是直接在类型后面跟上{}初始化的。通过make初始化map可以指定map的大小也可以不指定大小。下面的m2指定初始化存储2个元素的map.一般在实际使用中,建议指定大小,减少扩容带来的性能影响。

代码语言:javascript
代码运行次数:0
运行
复制
func main() {
 var m = make(map[int]int)
 m2 := make(map[int]int, 2)
 
 var m3 map[int]int
 m3 = make(map[int]int)

 m4 := map[int]int{}
}
map元素添加

map通过m[K]=V形式赋值,下面的操作向m中添加了两个元素,第三次赋值其实是更新操作,对于map中已有的key向里面添加数据是更新value.

代码语言:javascript
代码运行次数:0
运行
复制
func main() {
 m := map[int]int{}
 m[1] = 1
 m[2] = 2
 m[1] = 3
}
map元素查询

map中查询元素调用有两种形式,一种是只有一个返回值,另一种是有两个返回值,第一个返回值表示key对应的value,第二个返回值表示key是否在map中存在。当key不在map中时,返回的value不是nil而是对应value类型的零值,例如下面的m,取它的key为1的数据时,得到的v值为0,是int型的零值,所以不好区分返回的0是真正存储的1对应的0呢还是1不存在返回的零值。所以就有第二种返回两个参数的调用,第二返回值是一个bool类型,如果返回true表示key在map中,否则key不在map. 注意不能直接对map的value取地址,例如直接操作 &m[1]是不被允许的,编译器直接报错。因为map分配value空间可能因扩容失效,所以设计成不能取地址操作。

代码语言:javascript
代码运行次数:0
运行
复制
func main() {
 m := map[int]int{}

 v := m[1]
 v2, ok := m[2]

 _ = v
 _ = v2
 _ = ok
}
map遍历

map通过for range进行遍历,具体的遍历形式也有两种,一种是同时遍历key和value,另一种是只遍历key.通过for range得到的key和value值是map中key和value的副本,修改下面k和v的值,并影响m.

代码语言:javascript
代码运行次数:0
运行
复制
func main() {
 m := map[int]int{1: 1, 2: 2, 3: 3}

 // 遍历所有的key-value值
 for k, v := range m {
  fmt.Println(k, v)
 }

 // 只遍历key值
 for k := range m {
  fmt.Println(k)
 }
}

在单个goroutine中即没有并发冲突下,对map进行遍历的过程中进行删除操作是安全的,不会报错误。边遍历边删除操作,删除的key不会被遍历出来。例如下面的例子中,在遍历的过程中删除key,执行完遍历之后,m中的所有数据也删除完了,没有问题。

代码语言:javascript
代码运行次数:0
运行
复制
// 边遍历边删除是可以的,没有问题
func main() {
 m := map[int]int{1: 1, 2: 2, 3: 3}

 for k := range m {
  delete(m, k)
 }
}

下面对map边遍历边添加也是可以的,不过添加的4和5可能不被打印出来,也可能被打印出来,多次运行该程序,也可能输出的结果不同。具体原因,可以看2万字图解map文章,理解map的迭代位置是随机之后很容易理解这里。

代码语言:javascript
代码运行次数:0
运行
复制
func main() {
 m := map[int]int{1: 1, 2: 2, 3: 3}

 // 对m边遍历遍添加也是可以的,不过添加的4和5会被输出出来,也可能不会输出出来
 // 多次运行输出的结果可能不同,因为map的输出顺序是不固定的,添加的元素可能被
 // 访问到也可能不被访问到
 for k, v := range m {
  fmt.Println(k, v)
  if k == 1 {
   m[4] = 4
  }
  if k == 2 {
   m[5] = 5
  }
 }
}
map元素删除

删除map中的某个key直接使用delete函数。Go中没有提供一次性删除key中所有数据的方法,可以遍历map,对每个key执行delete操作。不过可以将map指向一个新的map或者置为nil,达到间接删除的目的,之前map中的旧数据会被GC回收掉。

代码语言:javascript
代码运行次数:0
运行
复制
func main() {
 m := map[int]int{1: 1, 2: 2, 3: 3}

 // 通过遍历删除map中所有的key
 for k := range m {
  delete(m, k)
 }

 //将m置nil,之前的数据会被GC回收
 m = nil

 //将m指向新的地址,之前的数据会被GC回收
 m = make(map[int]int)
}

map delete操作不会释放内存

对map进行delete操作,底层将key对应的tophash设置为了一个emptyOne标记,对于非直接引用对象,底层内存不会真正释放,可能会导致内存一直增长造成OOM.下面结合一个例子,说明这个现象。

代码语言:javascript
代码运行次数:0
运行
复制
package main

import (
 "fmt"
 "runtime"
)

const (
 count = 10000
)

var m map[int]int

func main() {
 fmt.Println("向map添加数据前:")
 printMemStats()

 addMap()
 runtime.GC()
 fmt.Printf("向map添加%d个数据后:\n", count)
 printMemStats()

 deleteMap()
 runtime.GC()
 fmt.Printf("删除map中%d个数据后:\n", count)
 printMemStats()

 m = nil
 runtime.GC()
 fmt.Println("将map设置为nil后:")
 printMemStats()
}

func addMap() {
 if m == nil {
  m = make(map[int]int, count)
 }
 for i := 0; i < count; i++ {
  m[i] = i
 }
}

func deleteMap() {
 for i := 0; i < count; i++ {
  delete(m, i)
 }
}

func printMemStats() {
 var m runtime.MemStats

 runtime.ReadMemStats(&m)

 fmt.Printf("Alloc = %vKB, TotalAlloc = %vKB, Sys = %vKB, NumGC = %v\n\n", m.Alloc/1024, m.TotalAlloc/1024, m.Sys/1024, m.NumGC)
}

程序输出结果为

这里对上面输出结果的字段含义做一个说明,Alloc是已分配对象的字节数,TotalAlloc是分配的字节数累积之和,对象释放的时候这个值并不会减少,Sys是从操作系统获得的内存总数,NumGC是垃圾回收的次数。

  • 在没有向map添加数据之前,默认用了80KB的内存
  • 向map添加1万个数据之后进行一次GC操作,此时使用内存为383KB
  • 将map中的1万个数据删除之后,也执行一次GC之后,然而占用的内存还是383KB, 内存并没有释放
  • 最后将map设置为nil,内存才释放,使用内存才降低到71KB,恢复到刚开始的情况。

map线程安全问题

map对象不是线程(goroutine)安全的,并发读写的时候在运行时会有检查,如果出现并发读写的问题就会产生panic. 下面的程序同时开启了2个goroutine对m进行操作,一个读一个写,执行下面的程序会报fatal error: concurrent map read and map write

代码语言:javascript
代码运行次数:0
运行
复制
package main

import "sync"

func main() {
 var m = make(map[int]int, 2)
 var wg sync.WaitGroup
 wg.Add(2)
 
 go func() {
  defer wg.Done()
  for {
   m[1] = 1
  }
 }()

 go func() {
  defer wg.Done()
  for {
   _ = m[2]
  }
 }()

 wg.Wait()
}

如何避免map并发读写出现的panic问题呢?通常的做法是对map进行加锁,可以使用读写锁,来提供程序性能。具体来说将map和锁封装到一个结构体里面,对外提供查询、删除、添加和修改map的接口,内部实现加锁处理。可以看下面这个实例。

代码语言:javascript
代码运行次数:0
运行
复制
package main

import (
 "sync"
 "time"
)

type rwMap struct {
 sync.RWMutex
 m map[int]int
}

func NewrwMap() *rwMap {
 return &rwMap{
  m: make(map[int]int),
 }
}

func (m *rwMap) Set(k, v int) {
 m.Lock()
 defer m.Unlock()

 m.m[k] = v
}

func (m *rwMap) Get(k int) (int, bool) {
 m.RLock()
 defer m.RUnlock()

 v, ok := m.m[k]
 return v, ok
}

func (m *rwMap) Delete(k int) {
 m.Lock()
 defer m.Unlock()

 delete(m.m, k)
}

func (m *rwMap) Len() int {
 m.RLock()
 defer m.RUnlock()

 return len(m.m)
}

func main() {
 m := NewrwMap()

 go func() {
  for {
   m.Set(1, 1)
  }
 }()

 go func() {
  for {
   _, _ = m.Get(2)
  }
 }()

 time.Sleep(time.Second * 5)
}

分片map

上面的例子已说明可以使用读写锁来提供线程(goroutine)安全的map,但是在大量并发读写的时候,锁的竞争非常激烈。锁是性能下降的一个重要原因之一,所以能不使用锁就不要使用。对于map来说,我们可以通过减少锁的粒度和锁的持有时间来降低锁带来的性能影响。分片map就是将map的一把大锁分成几把小锁,每个锁控制一个分片,每个分片是一个map,实际上就是一个map数组。下面是一个分片map的简单实例,具体更详细的学习可以研究github上orcaman/concurrent-map实现。

代码语言:javascript
代码运行次数:0
运行
复制
package main

import (
 "sync"
)

var shardCount = 32

type ConcurrentMap []*concurrentMapShared

type concurrentMapShared struct {
 sync.RWMutex
 items map[string]interface{}
}

func New() ConcurrentMap {
 m := make(ConcurrentMap, shardCount)
 for i := 0; i < shardCount; i++ {
  m[i] = &concurrentMapShared{items: make(map[string]interface{})}
 }
 return m
}

func (m ConcurrentMap) GetShard(key string) *concurrentMapShared {
 return m[uint(fnv32(key))%uint(shardCount)]
}

func fnv32(key string) uint32 {
 hash := uint32(2166136261)
 const prime32 = uint32(16777619)
 keyLength := len(key)
 for i := 0; i < keyLength; i++ {
  hash *= prime32
  hash ^= uint32(key[i])
 }
 return hash
}

func (m ConcurrentMap) Set(key string, value interface{}) {
 shard := m.GetShard(key)
 shard.Lock()
 shard.items[key] = value
 shard.Unlock()
}

func (m ConcurrentMap) Get(key string) (interface{}, bool) {
 shard := m.GetShard(key)
 shard.RLock()
 value, ok := shard.items[key]
 shard.RUnlock()

 return value, ok
}

上面是分片的map的一个简单实现,下面将其与非分片map进行一个对比性能验证,让我们对分片map的性能提升有一个感性认识。注意的是下面只是一个简单验证,得到的性能提升效果并不是非常准确的,在实际的项目中使用的时候要专门的性能验证。

代码语言:javascript
代码运行次数:0
运行
复制
package main

import (
 "strconv"
 "sync"
 "testing"
)

// 验证分片map的性能
func BenchmarkConcurrentMap(b *testing.B) {
 var cm = New()
 for i := 0; i < b.N; i++ {
  cm.Set(strconv.Itoa(i%100), i)
 }
 b.ResetTimer()
 for i := 0; i < b.N; i++ {
  go func(i int) {
   cm.Get(strconv.Itoa(i % 100))
  }(i)

  go func(i int) {
   cm.Set(strconv.Itoa((i+1)%100), i)
  }(i)
 }
}

// 验证非分片map的性能
func BenchmarkMap(b *testing.B) {
 var m = RWMap{m: make(map[int]int)}

 for i := 0; i < b.N; i++ {
  m.Set(i%100, i)
 }

 b.ResetTimer()
 for i := 0; i < b.N; i++ {
  go func(i int) {
   m.Get(i % 100)
  }(i)

  go func(i int) {
   m.Set((i+1)%100, i)
  }(i)
 }
}

type RWMap struct {
 sync.RWMutex
 m map[int]int
}

func (m *RWMap) Get(k int) (int, bool) {
 m.RLock()
 v, ok := m.m[k]
 m.RUnlock()

 return v, ok
}

func (m *RWMap) Set(k, v int) {
 m.Lock()
 m.m[k] = v
 m.Unlock()
}

得到性能验证结果如下,可以看到分片map相比非分片map性能提升近1倍。

总结

本文从应用层面总结了map的基本操作以及使用不当可能引发的问题,概括起来有以下几点

  • map必须初始化之后,才能添加元素
  • map是非线程(goroutine)安全的,是使用时需要通过锁进行保护
  • 在非并发环境中,map可以不用加锁在迭代的过程执行删除元素操作,甚至添加元素操作
  • 通过delete删除元素,底层只是做了删除标记,占用的空间可能没有真正释放
  • 通过分片map可以提升性能,在并发性要求比较高的场景可以采用

Golang map 如何进行删除操作[1]map delete存在的问题[2]

Reference

[1]

Golang map 如何进行删除操作: https://zhuanlan.zhihu.com/p/30662267

[2]

map delete存在的问题: https://github.com/golang/go/issues/20135

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-06-23,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 数据小冰 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • map的基本操作
    • map的申明
    • map的创建(初始化)
    • map元素添加
    • map元素查询
    • map遍历
    • map元素删除
  • map delete操作不会释放内存
  • map线程安全问题
  • 分片map
  • 总结
    • Reference
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档