golang 之关于 map 不知道的事

笔者使用 map 作为一个全局的 cache,测试之后发现,即使删除了不需要使用 key,但随着写入数据量的增加,map 占用的内存也开始不断增加。

分析原因,map 是通过 key 和 hash 值来分布和查找对象。map 不会收缩「不再使用」的空间,即使把 map 中的键值对删除,它依然保留内存空间继续使用。

一、正确的使用姿势

预估 map 容量

性能测试: map_test.go

package test                                                                                        
 
import (
    "testing"
)
 
func test(m map[int]int) {
    for i := 0; i < 10000; i++ {
        m[i] = i 
    }   
}
 
func BenchmarkMap(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        m := make(map[int]int)         //不带容量的初始化
        test(m)
    }   
}
 
func BenchmarkMapCap(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        m := make(map[int]int, 10000)   //带容量的初始化
        test(m)
    }   
}

执行go test -v -bench=. -benchmem的结果:

BenchmarkMap-4      	    1000	   1254931 ns/op	  687227 B/op	     276 allocs/op
BenchmarkMapCap-4   	    2000	    567847 ns/op	  322250 B/op	      11 allocs/op

struct VS *struct「值 vs 指针」

性能测试:mapStruct_test.go

package test                                                                                            
 
import (
    "testing"
)  
   
type User struct {
    name string
    age  int
}  
   
func test(m map[int]User) {
    for i := 0; i < 10000; i++ {
        user := User{
            name: "小明",
            age:  i,  
        }
        m[i] = user
    }   
}  
   
func testPointer(m map[int]*User) {
    for i := 0; i < 10000; i++ {
        user := User{
            name: "小明",
            age:  i,  
        }
        m[i] = &user
    }   
}  

func BenchmarkStruct(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        m := make(map[int]User)
        test(m)
    }
}
 
func BenchmarkStructPointer(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        m := make(map[int]*User, 10000)
        testPointer(m)
    }
} 

执行go test -v -bench=. -benchmem的结果:

BenchmarkStruct-4          	    1000	   1678196 ns/op	 1274963 B/op	     212 allocs/op
BenchmarkStructPointer-4   	    1000	   1382258 ns/op	  639520 B/op	   10002 allocs/op

结论

  • 带有容量的 map 初始化,可以有效的减少内存分配的次数,进而减少每次操作的耗时。
  • 内置类型用值,构造的 struct 类型用指针
  • 指针类型会影响 golang. gc 的速度

二、你以为你以为的就是你以为的

清空 map 不等于释放内存

内存分配测试: main.go

package main                                                                                            
       
import (
    "fmt"
    "runtime"
)   
       
var intMap map[int]int
var cnt = 8192
       
func initMap() {
    intMap = make(map[int]int, cnt)
       
    for i := 0; i < cnt; i++ {
        intMap[i] = i 
    }  
}   
       
func printMemStats() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("HeapAlloc = %v HeapIdel= %v HeapSys = %v  HeapReleased = %v\n", m.HeapAlloc/1024, m.HeapIdle/1024, m.HeapSys/1024,  m.HeapReleased/1024)
}

func main() {
    //程序启动占用内存
    printMemStats()
  
    //map 第一次初始化
    initMap()
    runtime.GC()
    printMemStats()
    fmt.Printf("map len's %d\n", len(intMap))
    
    for i := 0; i < cnt; i++ {
        //delete 所有 key
        delete(intMap, i)
    }                                                                                                   
    
    fmt.Printf("map len's %d\n", len(intMap))
    
    runtime.GC()
    printMemStats()
    
    //map 置为nil
    intMap = nil
    runtime.GC()
    printMemStats()
    
    //map 第二次初始化
    initMap()
    runtime.GC()
    printMemStats()
    
}

程序使用 runtime.ReadMemStats() 函数来获取堆的使用信息。它打印四个值:

HeapSys:程序向应用程序申请的内存

HeapAlloc:堆上目前分配的内存

HeapIdle:堆上目前没有使用的内存

HeapReleased:回收到操作系统的内存

程序运行结果分析:

HeapAlloc = 45 HeapIdel= 552 HeapSys = 768  HeapReleased = 0   -> 程序启动占用内存
HeapAlloc = 358 HeapIdel= 192 HeapSys = 736  HeapReleased = 0  -> map 第一次初始化
map len's 8192
map len's 0
HeapAlloc = 358 HeapIdel= 192 HeapSys = 736  HeapReleased = 0 -> delete 所有 key
HeapAlloc = 46 HeapIdel= 512 HeapSys = 736  HeapReleased = 0  -> map 置为 nil
HeapAlloc = 358 HeapIdel= 192 HeapSys = 736  HeapReleased = 0 -> map 第二次初始化

结论

  • 删除 map 中的所有 key,map 占用内存仍处于「使用状态」, map 置为 nil,map 占用的内存处于「空闲状态」。
  • 处于空闲状态内存,一定时间内在下次申请的可重复被使用,不必再向操作系统申请。
  • 笔者使用的是 go 1.9.7 版本

三、sync.Map 了解一下

产生背景

在对标准库做额外的审查和性能分析之后,Go 团队成员发现当使用 sync.RWMutex 的代码被部署在「很多核 」的 CPU 上高并发读的场景下,它的性能远低于理想值。所以使用 sync.RWMutex 封装的数据结构中读取数据的性能会受很大影响。

注意: 在2017 GopherCon有一个闪电演讲:An overview of sync.Map介绍了关于sync.Map诞生的原因以及它的设计目标。如果你在考虑使用这个实现,建议你一定要看下这个视频,视屏中讲解了它可能有的一些性能陷阱。

适用场景

sync.Map 类型是针对两种常见用例进行优化的:

  • 当给定的 key 只写入一次但读多次的时候,比如不断增长的缓存
  • 当多个 goroutines 读取、写入和覆盖 map 不相交的键集合(我理解就是每个 goroutine 只负责部分 key 的读取、写入和覆盖)

笔者翻译水平有限,原文如下:

The Map type is optimized for two common use cases: (1) when the entry for a given key is only ever written once but read many times, as in caches that only grow, or (2) when multiple goroutines read, write, and overwrite entries for disjoint sets of keys. In these two cases, use of a Map may significantly reduce lock contention compared to a Go map paired with a separate Mutex or RWMutex.

map VS sync.Map 压测

笔者模拟后端 map 的使用方式——多个 goroutine 对同一 map 同时执行读取、写入和删除的操作,预计map 的性能要比 sync.Map 的性能要好,但是发现 sync.Map 性能略胜一筹,原因不明,先记录在此处。

性能测试:syncMap_tset.go

package test

import (
	"sync"
	"testing"
)

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

type SMap struct {
	sm sync.Map
}

func (m *Map) Insert(i int, s int, wg *sync.WaitGroup) {
	m.Lock()
	m.m[i] = s
	m.Unlock()
	wg.Done()
}

func (sm *SMap) Insert(i int, s int, wg *sync.WaitGroup) {
	sm.sm.Store(i, s)
	wg.Done()
}

func (m *Map) Get(i int, wg *sync.WaitGroup) (s int) {
	defer wg.Done()
	m.RLock()
	s, ok := m.m[i]
	if ok {
		m.RUnlock()
		return s
	}
	m.RUnlock()
	return 0
}

func (sm *SMap) Get(i int, wg *sync.WaitGroup) (s int) {
	defer wg.Done()
	v, ok := sm.sm.Load(i)
	if ok {
		return v.(int)
	}
	return 0
}

func (m *Map) Delete(i int, wg *sync.WaitGroup) {
	m.Lock()
	delete(m.m, i)
	m.Unlock()
	wg.Done()
}

func (sm *SMap) Delete(i int, wg *sync.WaitGroup) {
	sm.sm.Delete(i)
	wg.Done()
}

func operateMap(m *Map, work int) {
	wg := sync.WaitGroup{}
	wg.Add(work*2 + 3)
	go func() {
		defer wg.Done()
		for i := 0; i < work; i++ {
			go m.Insert(i, i, &wg)
		}
	}()
	go func() {
		defer wg.Done()
		for i := 0; i < work; i++ {
			if i%4 == 0 {
				wg.Add(1)
				go m.Delete(i, &wg)
			}
		}
	}()
	go func() {
		defer wg.Done()
		for i := 0; i < work; i++ {
			go m.Get(i, &wg)
		}
	}()

	wg.Wait()

}

func operateSyncMap(sm *SMap, work int) {
	wg := sync.WaitGroup{}
	wg.Add(work*2 + 3)
	go func() {
		defer wg.Done()
		for i := 0; i < work; i++ {
			go sm.Insert(i, i, &wg)
		}
	}()
	go func() {
		defer wg.Done()
		for i := 0; i < work; i++ {
			if i%4 == 0 {
				wg.Add(1)
				go sm.Delete(i, &wg)
			}
		}
	}()
	go func() {
		defer wg.Done()
		for i := 0; i < work; i++ {
			go sm.Get(i, &wg)
		}
	}()
	wg.Wait()

}
func BenchmarkOperateSyncMap8Work(b *testing.B) {
	sm := SMap{
		sm: sync.Map{},
	}
	b.ResetTimer()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			operateSyncMap(&sm, 8)
		}

	})
}
func BenchmarkOperateMap8Work(b *testing.B) {
	m := Map{
		m: make(map[int]int, 0),
	}
	b.ResetTimer()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			operateMap(&m, 8)
		}

	})
}

func BenchmarkOperateSyncMap256Work(b *testing.B) {
	sm := SMap{
		sm: sync.Map{},
	}
	b.ResetTimer()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			operateSyncMap(&sm, 256)
		}

	})
}
func BenchmarkOperateMap256Work(b *testing.B) {
	m := Map{
		m: make(map[int]int, 0),
	}
	b.ResetTimer()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			operateMap(&m, 256)
		}

	})
}

执行go test -v -bench=. -benchmem的结果:

BenchmarkOperateSyncMap8Work-4     	  500000	      3297 ns/op	     256 B/op	      23 allocs/op
BenchmarkOperateMap8Work-4         	  300000	      7122 ns/op	      16 B/op	       1 allocs/op
BenchmarkOperateSyncMap256Work-4   	   10000	    118631 ns/op	    8227 B/op	     767 allocs/op
BenchmarkOperateMap256Work-4       	    5000	    301040 ns/op	      67 B/op	       1 allocs/op

写在最后,如果你知道为什么我测试的结果是这样的,请一定告诉我,毕竟我是个强迫症晚期患者,不知道原因的感觉很难受!!!

四、参考资料

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值