开发者

详解Golang中使用map时的注意问题

开发者 https://www.devze.com 2024-08-15 12:10 出处:网络 作者: 刘铸纬
目录1. 将value定义为struct节省内存1. 消除指针引用2. 避免内存碎片化3. 更高的缓存命中率示例:节约内存4. set实现对比结果2. 哈希分桶的结构1. 哈希计算2. 分桶依据3. 桶 (Bucket) 结构4. 插入过程5. 查找过
目录
  • 1. 将value定义为struct节省内存
    • 1. 消除指针引用
    • 2. 避免内存碎片化
    • 3. 更高的缓存命中率
    • 示例:节约内存
    • 4. set实现对比
    • 结果
  • 2. 哈希分桶的结构
    • 1. 哈希计算
    • 2. 分桶依据
    • 3. 桶 (Bucket) 结构
    • 4. 插入过程
    • 5. 查找过程
  • 3. map扩容过程
    • 1. 扩容触发条件
    • 2. 扩容过程的具体步骤
  • 4. recover map的panic
    • panic 和 recover 的工作机制
    • panic 和 recover 的工作机制
    • 在 map 中使用 panic 和 recover
  • 5. map是如何检测到自己处于竞争状态
    • 竞争检测机制
  • 6. sync.Map和map加锁的区别

    1. 将value定义为struct节省内存

    1. 消除指针引用

    当 map 的 value 是 struct 类型时,数据会直接存储在 map 中,而不是通过指针引用。这可以减少内存分配的开销和 GC(垃圾回收)的负担。

    type User struct {
        ID   int
        Name string
    }
    
    m := make(map[string]User)
    m["user1"] = User{ID: 1, Name: "John"}
    
    // Example with pointer to struct
    m2 := make(map[string]*User)
    m2["user1"] = &User{ID: 1, Name: "John"}
    

    在第二个示例中,map 中存储的是指向 User 结构体的指针,这意味着除了存储指针本身外,还需要额外的内存来存储 User 结构体,并且会增加 GC 的负担。

    2. 避免内存碎片化

    存储指针时,由于指针可能指向堆中的不同位置,这会导致内存碎片化,增加了内存使用的不确定性。而存储 struct 使得数据更紧凑,减少了碎片化。

    3. 更高的缓存命中率

    由于 struct 的数据是紧凑存储的,相对于存储指针,struct 的数据更可能在相邻的内存位置。这增加了 CPU 缓存的命中率,从而提高了性能。

    示例:节约内存

    下面是一个示例,展示了如何通过定义 struct 类型来节约内存:

    package main
    
    import (
    	"fmt"
    	"runtime"
    )
    
    type User struct {
    	ID   int
    	Name string
    }
    
    func main() {
    	// 使用 struct 作为 value
    	users := make(map[string]User)
    	for i := 0; i < 1000000; i++ {
    		users[fmt.Sprintf("user%d", i)] = User{ID: i, Name: fmt.Sprintf("Name%d", i)}
    	}
    
    	printMemUsage("With struct values")
    
    	// 使用指针作为 value
    	userPtrs := make(map[string]*User)
    	for i := 0; i < 1000000; i++ {
    		userPtrs[fmt.Sprintf("user%d", i)] = &User{ID: i, Name: fmt.Sprintf("Name%d", i)}
    	}
    
    	printMemUsage("With pointer values")
    }
    
    func printMemUsage(label string) {
    	var m runtime.MemStats
    	runtime.ReadMemStats(&m)
    	fmt.Printf("%s: Alloc = %v MiB\n", label, bToMb(m.Alloc))
    }
    
    func bToMb(b uint64) uint64 {
    	return b / 1024 / 1024
    }
    

    4. set实现对比

    map[int]bool{}

    在这种情况下,map 的 value 类型是 bool。每个键会占用一个 bool 类型的空间(通常是一个字节)。

    set := make(map[int]bool)
    set[1] = true
    set[2] = true
    

    map[int]struct{}{}

    在这种情况下,map 的 value 类型是空的 struct。空的 struct 不占用任何内存,因此每个键只占用键本身的内存。

    set := make(map[int]struct{})
    set[1] = struct{}{}
    set[2] = struct{}{}
    

    内存使用对比

    map[int]bool{} 会比 map[int]struct{}{} 使用更多的内存,因为 bool 类型需要存储一个字节(在实际应用中可能会有额外的内存对齐和管理开销),而 struct{} 是空的,不会增加任何内存开销。

    示例代码对比内存使用

    以下是一个示例代码,比较这两种 map 类型的内存使用情况:

    package main
    
    import (
    	"fmt"
    	"runtime"
    )
    
    func main() {
    	// 使用 bool 作为 value
    	boolMap := make(map[int]bool)
    	for i := 0; i < 1000000; i++ {
    		boolMap[i] = true
    	}
    
    	printMemUsage("With bool values")
    
    	// 使用 struct 作为 value
    	structMap := make(map[int]struct{})
    	for i := 0; i < 1000000; i++ {
    		structMap[i] = struct{}{}
    	}
    
    	printMemUsage("With struct values")
    }
    
    func printMemUsage(label string) {
    	var m runtime.MemStats
    	runtime.ReadMemStats(&m)
    	fmt.Printf("%s: Alloc = %v MiB\n", label, bToMb(m.Alloc))
    }
    
    func bToMb(b uint64) uint64 {
    	return b / 1024 / 1024
    }
    

    结果

    运行上述代码,你会发现使用 structjs 作为 value 的内存使用量明显小于使用指针作为 value 的内存使用量。这是因为:

    • 减少了指针的存储开销
    • 减少了额外的堆内存分配
    • 降低了 GC 的负担,因为 struct 的内存管理更简单,不涉及指针的追踪和回收。

    2. 哈希分桶的结构

    1. 哈希计算

    当我们向map中插入一个键值对,首先对键进行哈希计算。Go内置了哈希函数来计算键的哈希值。哈希值是一个64位的整数。

    2. 分桶依据

    Go 中的 map 是分成多个桶 (bucket) 来存储的。桶的数量通常是 2 的幂次,这样可以方便地通过位运算来定位到具体的桶。哈希值的高八位和低八位分别用于分桶和桶内定位:

    • 高八位 (top 8 bits):用于决定哈希表中的桶位置。
    • 低八位 (low 8 bits):用于桶内查找。

    3. 桶 (Bucket) 结构

    每个桶中可以存储 8 个键值对。当某个桶中的元素超过 8 个时,Go 会使用溢出桶来存储额外的键值对。桶的结构如下:

    type bmap struct {
        tophash [bucketCnt]uint8
        keys    [bucketCnt]keyType
        values  [bucketCnt]valueType
        overflow *bmap
    }
    

    tophash:存储键的哈希值的高八位。

    keys:存储键。

    values:存储对应的值。

    overflow:指向溢出桶的指针。

    4. 插入过程

    当插入一个键值对时,过程如下:

    1. 计算哈希值:对键进行哈希计算得到哈希值 hash
    2. 定位桶:通过 hash >> (64 - B)B 是桶的数量的对数)得到桶的索引 index
    3. 桶内查找:通过 hash & (bucketCnt - 1) 得到桶内索引。然后通过对比 tophash 数组中的值来定位到具体的键值对存储位置。
    4. 存储键值对:将键值对存储到相应的位置,如果当前桶已满,则分配新的溢出桶来存储额外的键值对。

    5. 查找过程

    查找的过程与插入类似:

    查找的过程与插入类似:

    1. 计算哈希值:对键进行哈希计算得到哈希值 hash
    2. 定位桶:通过 hash >> (64 - B) 得到桶的索引 index
    3. 桶内查找:通过 hash & (bucketCnt - 1) 得到桶内索引,然后在相应的 bmap 中查找 tophash 和 keys 数组中匹配的键。如果在当前桶中没有找到,则继续查找溢出桶。

    3. map扩容过程

    1. 扩容触发条件

    扩容通常在以下两种情况下触发:

    扩容通常在以下两种情况下触发:

    1. 装载因子过高:装载因子(load factor)是 map 中元素数量与桶数量的比值。Go 语言中的装载因子阈值通常为 6.5,当装载因子超过这个值时会触发扩容。
    2. 溢出桶过多:当溢出桶的数量过多时,也会触发扩容。

    2. 扩容过程的具体步骤

    1. 初始化新的桶数组: 在需要扩容时,Go 会分配一个新的桶数组,其大小通常是旧桶数组的两倍,并设置相关的元数据以指示 map 正在进行扩容。
    2. 标记迁移状态: 在 map 的内部结构中,会有一个标志位(rehash index)指示当前已经迁移的桶位置。初始值为 0。
    3. 迁移部分数据: 在每次对 map 进行插入或查找操作时,会顺便迁移一部分旧桶中的数据到新桶中。每次迁移一个或多个桶,具体数量取决于操作的复杂度。
    4. 更新 rehash index: 迁移完成后,更新 rehash index,以便下次操作继续迁移下一个桶中的数据。
    5. 完成扩容: 当所有旧桶的数据都迁移到新桶后,更新 map 的元数据,指向新的桶数组,并将扩容状态标志位清除。

    4. recover map的panic

    panic 和 recover 的工作机制

    1. panic
      • panic 用于引发一个恐慌,通常在遇到无法恢复的严重错误时使用。
      • 当 panic 被调用时,程序的正常执行流程会被中断,并开始沿着调用栈向dRdwP上展开,逐层调用函数的 defer 语句,直到遇到 recover 或者程序崩溃。
    2. recover
      • recover 用于恢复程序的正常执行,通常在 defer 函数中调用。
      • 如果在&nbsphpp;defer 语句中调用了 recover,并且当前栈帧处于恐慌状态,那么 recover 会捕获这个恐慌,停止栈的展开,并返回传给 panic 的值。
      • 如果不在恐慌状态下调用 recover,它会返回 nil,不做任何处理。

    在 Go 语言中,panic 和 recover 是用来处理异常情况和错误恢复的两种机制。理解它们的工作原理对于编写健壮的 Go 代码非常重要。以下是对 panic 和 recover 机制的详细解释以及它们在 map 中的应用。

    panic 和 recover 的工作机制

    1. panic
      • panic 用于引发一个恐慌,通常在遇到无法恢复的严重错误时使用。
      • 当 panic 被调用时,程序的正常执行流程会被中断,并开始沿着调用栈向上展开,逐层调用函数的 defer 语句,直到遇到 recover 或者程序崩溃。
    2. recover
      • recover 用于恢复程序的正常执行,通常在 defer 函数中调用。
      • 如果在 defer 语句中调用了 recover,并且当前栈帧处于恐慌状态,那么 recover 会捕获这个恐慌,停止栈的展开,并返回传给 panic 的值。
      • 如果不在恐慌状态下调用 recover,它会返回 nil,不做任何处理。

    在 map 中使用 panic 和 recover

    在 Go 的 map 中,某些操作(如并发读写未加锁的 map)会引发 panic。这些 panic 可以被 recover 捕获和处理,以防止程序崩溃。

    package main
    
    import (
        "fmt"
    )
    
    func main() {
        defer func() {
            if r := recover(); r != nil {
                fmt.Println("Recovered from panic:", r)
            }
        }()
    
        // 创建一个 map
        m := make(map[string]string)
    
        // 引发 panic 的操作
        causePanic(m)
    
        fmt.Println("This line will be executed because panic was recovered.")
    }
    
    func causePanic(m map[string]string) {
        // 这里尝试并发访问 dRdwpmap,可能会引发 panic
        // 模拟并发问题,直接引发 panic
        panic("simulated map Access panic")
    }
    

    5. map是如何检测到自己处于竞争状态

    在 Go 语言中,map 的竞争状态(concurrent access)指的是多个 goroutine 同时读写同一个 map 而没有适当的同步保护。Go 内置的 map 类型在并发读写时会引发 panic,以防止数据竞争和未定义行为。这种检测主要是通过 Go 编译器和运行时的实现来完成的,而不是底层硬件直接支持的功能。

    竞争检测机制

    1. 编译器插桩
      • 在编译时,Go 编译器会在对 map 进行读写操作的代码位置插入特定的检测代码。这些检测代码在运行时检查 map 是否处于并发访问状态。
    2. 运行时检查
      • 运行时的检测代码会追踪 map 的访问。当检测到多个 goroutine 同时对 map 进行读写操作时,会引发 panic。具体来说,Go 运行时会记录每个 map 的访问情况,如果检测到并发访问没有通过同步机制(如 sync.Mutex),就会引发 panic。
    package main
    
    import (
        "fmt"
        "sync"
    )
    
    func main() {
        m := make(map[int]int)
        var wg sync.WaitGroup
        var mu sync.Mutex
    
        // 启动多个 goroutine 并发写 map,未加锁保护会引发 panic
        for i := 0; i < 10; i++ {
            wg.Add(1)
            go func(i int) {
                defer wg.Done()
                // 取消注释以下行,查看未加锁保护的并发写操作
                // m[i] = i
    
                // 使用互斥锁保护并发写操作
                mu.Lock()
                m[i] = i
                mu.Unlock()
            }(i)
        }
    
        wg.Wait()
    
        // 打印 map 内容
        mu.Lock()
        for k, v := range m {
            fmt.Printf("key: %d, value: %d\n", k, v)
        }
        mu.Unlock()
    }
    

    6. sync.Map和map加锁的区别

      • 使用场景
        • sync.Map 适用于读多写少的并发场景,简单且高效。
        • 使用 sync.Mutex 或 sync.RWMutex 保护普通 map 适用于需要复杂并发控制或写操作较多的场景。
      • 性能
        • sync.Map 在读多写少的情况下性能php优越,但在写操作频繁时性能可能不如使用互斥锁保护的普通 map。
        • 使用 sync.Mutex 或 sync.RWMutex 可以在读写操作间提供更好的性能平衡,尤其是在写操作较多时。
      • 复杂性
        • sync.Map 封装了并发控制,使用简单,不需要手动加锁。
        • 使用 sync.Mutex 或 sync.RWMutex 需要手动加锁解锁,代码相对复杂,但更灵活。
      • 方法支持
        • sync.Map 提供了一些特殊的方法(如 LoadOrStoreRange),方便特定场景下的使用。
        • 使用 sync.Mutex 或 sync.RWMutex 保护的普通 map 可以自由定义自己的方法,更灵活,但需要更多的代码。

    以上就是详解golang中使用map时的注意问题的详细内容,更多关于Golang使用map的资料请关注编程客栈(www.devze.com)其它相关文章!

    0

    精彩评论

    暂无评论...
    验证码 换一张
    取 消

    关注公众号