【Go语言精进之路】构建高效Go程序:了解map实现原理并高效使用
文章目录
- 引言
- 一、什么是`map`
- 1.1 `map`的基本概念与特性
- 1.2 map的初始化与零值问题
- 1.3 `map`作为引用类型的行为
- 二、`map`的基本操作
- 2.1 插入数据
- 2.2 获取数据个数
- 2.3 查找和数据读取
- 2.4 删除数据
- 2.5 遍历数据
- 三、map的内部实现
- 3.1 初始状态
- 3.2 `map`扩容
- 3.3 `map`并发
- 四、尽量使用cap参数创建map
- 五、总结
引言
在Go语言中,
map
是一种无序的键值对集合,它以其高效的查找、插入和删除操作而闻名。了解map
的基本概念、特性和内部实现机制,对于编写高效且稳定的Go代码至关重要。本文将深入探讨map
的各个方面,包括其初始化、基本操作、内部实现细节,并讨论为何在创建map
时应尽量使用带有容量提示参数的做法。
一、什么是map
1.1 map
的基本概念与特性
map
是Go语言中的一种内建引用类型,它表示一组无序的键值对集合。每个键值对用冒号“:”分隔,其中键(key)是唯一的,用于标识对应的值(value)。map
允许我们根据特定的键快速检索、更新或删除对应的值。
在Go语言中,map
对值(value)的数据类型没有特定限制,它可以是任意类型,包括基本类型、结构体、自定义类型等。但是,键(key)的类型有严格要求:key的类型必须可以通过“==”和“!=”操作符进行比较,这意味着键的类型需要是可比较的。因此,像函数、map和切片这样不可比较的类型不能作为map的键。
1.2 map的初始化与零值问题
需要注意的是,map
类型不支持“零值可用”,也就是说,未显式初始化的map变量其默认值为nil
。尝试对nil
的map
变量进行操作将会导致运行时错误(panic)。例如:
var m map[string]int // 此时m的值为nil
// 下面的操作将会导致运行时panic,因为m未被初始化
m["key"] = 1 // panic: assignment to entry in nil map
为了避免这种情况,我们需要在使用map
之前对其进行初始化。可以通过以下两种方式之一来初始化map
:
- 使用
make
函数初始化:
m := make(map[string]int)
m["key"] = 1 // 现在这是安全的,因为m已经被初始化
- 使用字面量初始化:
m := map[string]int{"key": 1}
// 或者
m := map[string]int{}
m["key"] = 1 // 同样是安全的,因为m已经被初始化
初始化后的map
可以被安全地用于存储和检索键值对,而不会导致运行时错误。在Go程序中,map
是非常有用的数据结构,特别适用于需要根据键快速查找、添加或删除相应值的场景。
1.3 map
作为引用类型的行为
和切片一样,map
也是引用类型。这意味着,当你将一个map
类型的变量传递给函数时,实际上传递的是指向底层数据结构的指针,而不是整个数据结构的拷贝。因此,将map
类型变量作为函数参数传入不会有很大的性能消耗。
此外,由于在函数内部和外部引用的是同一个底层数据结构,所以在函数内部对map
变量的修改(如添加、删除键值对或更新值)在函数外部也是可见的。这种特性使得map
在需要在多个函数或方法间共享和修改数据时非常有用。
以下是一个示例,展示了在函数内部修改map
,并在函数外部观察到这些修改:
package mainimport "fmt"func modifyMap(m map[string]int) {// 在函数内部修改mapm["apple"] = 5m["banana"] = 10
}func main() {// 初始化一个mapfruitMap := make(map[string]int)// 调用函数,传入map作为参数modifyMap(fruitMap)// 打印修改后的map,可以看到在modifyMap函数中所做的修改fmt.Println(fruitMap) // 输出: map[apple:5 banana:10]
}
在这个例子中,modifyMap
函数接收一个map
作为参数,并在函数内部添加了两个键值对。当函数执行完毕后,main
函数中的fruitMap
已经被修改,反映了modifyMap
函数中所做的更改。这是因为map
是引用类型,modifyMap
接收的是fruitMap
的引用,因此对它的任何修改都会反映在原始map
上。
二、map
的基本操作
2.1 插入数据
当面对一个非nil
的map
类型变量时,我们可以向其中插入符合map
类型定义的任意键值对。值得注意的是,如果试图插入的键(key)已经存在于map
中,那么新的值将会覆盖旧的值。Go运行时会管理map
内部的内存,因此,除非系统内存耗尽,否则我们不必担心向map
中插入大量数据。
m := make(map[string]int)
m["apple"] = 5 // 插入键值对 "apple": 5
m["apple"] = 7 // 更新键 "apple" 的值为 7,旧值5被覆盖
m["banana"] = 10 // 插入键值对 "banana": 10
在上述代码中,我们首先创建了一个从string
类型到int
类型的map
。然后,我们插入了键值对"apple": 5
。紧接着,我们尝试再次插入键"apple"
,但这次赋予它一个新的值7
。由于这个键已经存在于map
中,因此旧的值5
会被新的值7
覆盖。最后,我们插入了一个新的键值对"banana": 10
。
这种覆盖行为是map
的一个重要特性,它允许我们根据需要更新存储在map
中的值。在实际编程中,这一特性非常有用,比如当我们需要根据某些条件动态改变值时。
2.2 获取数据个数
要获取map
中数据的个数,可以使用内置的len()
函数。
count := len(m)
fmt.Println("Number of items in map:", count) // 输出map中的元素个数
len(m)
返回m
中当前存储的键值对数量。
2.3 查找和数据读取
可以根据键来查找和读取map
中的数据。如果键不存在,则返回该类型的零值。
value, exists := m["apple"] // 查找键为"apple"的值,并检查键是否存在
if exists {fmt.Println("The value of 'apple' is:", value)
} else {fmt.Println("'apple' does not exist in the map.")
}
使用value, exists := m[key]
的格式可以同时获取键对应的值和该键是否存在。如果键存在,exists
为true
,并且value
为该键对应的值;如果键不存在,exists
为false
,value
为该类型的零值。
2.4 删除数据
要从map
中删除一个键值对,可以使用delete()
函数。
delete(m, "banana") // 删除键为"banana"的键值对
delete(m, key)
函数会从m
中删除与key
关联的键值对。如果key
不存在,则delete
什么也不做。
2.5 遍历数据
可以使用range
关键字来遍历map
中的所有键值对。
package mainimport "fmt"func main() {m := map[int]int{1: 11, 2: 12, 3: 13,}fmt.Printf("{ ")for key, value := range m {fmt.Printf("key: %d, value: %d ", key, value)}fmt.Printf(" }\n")
}
range m
会迭代m
中的所有键值对,每次迭代都会返回当前的键和值。在上面的循环中,key
和value
分别被赋值为当前迭代的键和值,然后打印出来。
上面的输出结果非常理想,给我们的表象是迭代器按照map
中的元素插入次序逐一遍历。那让我们再多遍历几次这个map
:
package mainimport "fmt"func doIteration(m map[int]int) {fmt.Printf("{ ")for key, value := range m {fmt.Printf("key: %d, value: %d ", key, value)}fmt.Printf(" }\n")
}func main() {m := map[int]int{1: 11,2: 12,3: 13,}for i := 0; i < 3; i++ {doIteration(m)}
}
我们看见对同一map
进行多次遍历,遍历的元素次序并不相同。这是因为Go运行时在初始化map迭代器时对起始位置做了随机处理。因此千万不要依赖遍历map
所得到的元素次序。
三、map的内部实现
和切片相比,map
类型的内部实现要复杂得多。Go运行时使用一张哈希表来实现抽象的map
类型,运行时实现了map
操作的所有功能,包括查找、插入、删除、遍历等。本文这里只做一些简单的介绍。
3.1 初始状态
在Go语言中,当一个map
被初始化时,它会分配一个较小的内存空间来存储键值对数据。这个初始的内存空间包含一定数量的桶(buckets),每个桶能够存储一个或多个键值对。初始状态下,这些桶都是空的。
map
的初始化可以通过字面量、make
函数或者直接使用map
类型进行。例如:
// 使用字面量初始化
m1 := map[string]int{"apple": 5, "banana": 10}// 使用make函数初始化
m2 := make(map[string]int)// 直接声明map类型变量(需要后续进行初始化)
var m3 map[string]int
m3 = make(map[string]int)
在初始化时,map
会预留一定的空间以准备存储键值对,但这个初始空间相对较小。
3.2 map
扩容
当map
中的元素数量增加,负载因子(已存储的键值对数量与桶的数量的比例)也会随之增加。当负载因子超过某个预定的阈值时,map
会进行扩容以保证性能。
扩容过程中,map
会创建一个更大的桶数组,并且重新计算所有现有键值对的哈希值,将它们重新分布到新的桶数组中。这个重新哈希和分布的过程是为了确保键值对能够更均匀地分散在新的桶中,从而减少哈希冲突并提高查找效率。
扩容是一个相对昂贵的操作,因为它涉及到内存分配和大量数据的迁移。因此,在实际使用中,如果可能的话,最好提前预估map
的大小并一次性分配足够的空间。
3.3 map
并发
Go语言的map
类型并不是并发安全的。这意味着如果多个goroutine同时对一个map
进行读写操作,就可能导致数据竞争(data race)和不可预知的行为。
为了在并发环境中安全地使用map
,有几种常见的解决方案:
-
使用互斥锁(Mutex):通过使用
sync.Mutex
或sync.RWMutex
来同步对map
的访问。在每次读写map
之前,先获取锁,操作完成后再释放锁。 -
使用
sync.Map
:Go语言标准库提供了一个并发安全的map
实现,即sync.Map
。它内部使用了分段锁和其他优化技术来提供高效的并发访问。 -
通道(Channel):另一种方法是使用Go的通道来序列化对
map
的访问。通过将所有对map
的操作都通过一个或多个通道来进行,可以确保在同一时间只有一个goroutine能够访问map
。
在实际应用中,选择哪种并发控制方法取决于具体的使用场景和性能要求。对于简单的用例,使用互斥锁可能就足够了;而在需要高并发性能的场景中,sync.Map
可能更为合适。
四、尽量使用cap参数创建map
由于扩容是一个相对昂贵的操作,因为它涉及到内存分配和大量数据的迁移,因此,如果可以的话我们最好对map
使用规模做出粗略的估算,并使用cap
参数对map
实例进行初始化。
当你创建一个 map
而不指定容量时,Go 会自动为你分配一个初始的、未指定的容量。这个容量足以满足初始需求,并且随着 map
中元素的增加,Go 的运行时会自动管理其内部结构的大小调整,以容纳更多的元素。这是最常见也是最简单的初始化方式。
m := make(map[string]int)
如果你在创建 map
时明确指定了 cap
参数,你是在给 Go 提供一个关于你期望 map
最终可能包含多少个键值对的提示。这有助于减少 map
在增长过程中需要重新分配内存的次数,从而提高效率,尤其是在你知道 map
大致会有多大时。但请注意,指定的 cap
是一个提示而不是严格的限制,map
的实际容量可能会略高于指定的值,且 map
仍然可以在达到这个预设容量后继续增长。
m := make(map[string]int, 100)
优缺点分析:
- 不使用 cap:简化初始化过程,让Go自动管理容量,适用于大多数情况,特别是当你不确定
map
最终大小时。 - 使用 cap:通过预先估计
map
的大小,可以略微优化性能,减少动态扩容的次数,适合于明确知道或能估算map
容量的场景。
选择是否使用 cap
主要取决于你对map
最终规模的了解程度和对性能的特定需求。在不需要精确控制初始容量的情况下,省略 cap
是一个简洁且有效的方法。然而,如果你正处理大量数据且关心性能优化,明智地设定初始容量可以带来益处。
下面对两种初始化方式的性能进行对比:
package mainimport "testing"const mapSize = 10000func BenchmarkMapInitWithoutCap(b *testing.B) {for i := 0; i < b.N; i++ {m := make(map[int]int)for i := 0; i < mapSize; i++ {m[i] = i}}
}
func BenchmarkMapInitWithCap(b *testing.B) {for i := 0; i < b.N; i++ {m := make(map[int]int, mapSize)for i := 0; i < mapSize; i++ {m[i] = i}}
}
BenchmarkMapInitWithoutCap
函数执行以下操作:
-
它使用一个循环,该循环将运行
b.N
次,其中b.N
是testing.B
提供的,表示基准测试应该运行的次数。这是为了确保我们获得足够的数据点来平均性能测试结果,从而获得更准确的数据。 -
在每次循环中,它创建一个新的map,没有指定初始容量(
make(map[int]int)
)。 -
然后,它向这个map中插入
mapSize
(即10000)个键值对,其中键和值都是循环变量i
。
这个基准测试的目的是测量在不指定初始容量的情况下,初始化并填充一个map的性能。
执行结果如下:
BenchmarkMapInitWithCap
函数与BenchmarkMapInitWithoutCap
非常相似,但有一个关键区别:
- 在创建map时,它使用
make(map[int]int, mapSize)
来指定一个初始容量提示,这个容量提示等于将要插入的键值对的数量(即10000)。
这个基准测试的目的是测量在指定了与将要插入的键值对数量相等的初始容量提示的情况下,初始化并填充一个map的性能。
下面是执行结果:
可以看出,使用cap
参数的map
实例的平均写性能是不使用cap
参数的2倍。
五、总结
本文通过详细阐述了Go语言中 map
的基本概念、特性及其作为引用类型的行为,介绍了 map
的基本操作如插入、获取数据个数、查找、删除和遍历数据等。同时,深入剖析了 map
的内部实现,包括其初始状态、扩容机制以及并发问题。最后,本文强调了在使用 map
时,为了提高性能和减少内存重新分配的次数,应尽量在创建时提供合理的容量提示参数。通过全面理解 map
的工作原理和最佳实践,开发者可以更加有效地利用这一强大的数据结构来优化程序性能。
相关文章:

【Go语言精进之路】构建高效Go程序:了解map实现原理并高效使用
🔥 个人主页:空白诗 🔥 热门专栏:【Go语言精进之路】 文章目录 引言一、什么是map1.1 map的基本概念与特性1.2 map的初始化与零值问题1.3 map作为引用类型的行为 二、map的基本操作2.1 插入数据2.2 获取数据个数2.3 查找和数据读取…...

【机器人和人工智能——自主巡航赛项】进阶篇
文章目录 案例要求创建地图rviz仿真 保存地图坐标点定位识别训练主逻辑理解语音播报模块匹配二维码识别多点导航讲解视频其余篇章 案例要求 创建地图 ./1-gmapping.sh 把多个launch文件融合在sh文件里面 rviz仿真 rviz是rose集成的可视化界面,查看机器人的各项数…...
[大师C语言(第二十五篇)]C语言字符串探秘
引言 字符串是编程中不可或缺的基本数据类型之一,它用于表示和操作文本数据。在C语言中,字符串以一种独特的方式实现,与许多其他编程语言的处理方式不同。本文将深入探讨C语言字符串背后的技术,帮助你更好地理解和应用字符串。 …...

xLua(一) 环境安装笔记
为了方便查阅记录一下xLua的安装地址及方法 1.登录地址下载: https://github.com/Tencent/xLua 2.解压文件 将文件中的这些内容拷贝到项目中的Asset文件夹中 注意 : 工程项目路径不得含有中文 3.将Tools复制到Asset同级目录下 4.导入后会发现有Bug,需要导入工程 5.还有另…...

Python基础教程(十一):数据结构汇总梳理
💝💝💝首先,欢迎各位来到我的博客,很高兴能够在这里和您见面!希望您在这里不仅可以有所收获,同时也能感受到一份轻松欢乐的氛围,祝你生活愉快! 💝Ὁ…...
制造型企业图纸泄露问题,如何从根源解决核心文件资料泄露问题?
制造型企业最核心重要的文件当然是图纸,图纸在制造型企业中起到关键主导地位,如果图纸泄露与研发性企业源代码被泄露一样的严重,制造型企业如何保护核心图纸不被外泄是企业在日常经营过程中的重要组成部分,现在有很多防止泄露的产…...

英伟达最新GPU和互联路线图分析
Nvidia在计算、网络和图形领域独树一帜,其显著优势在于雄厚的资金实力及在生成式人工智能市场的领先地位。凭借卓越的架构、工程和供应链,Nvidia能够自由实施创新路线图,引领行业未来。 到 21 世纪,Nvidia 已经是一个非常成功的创…...

Github 2024-06-10 开源项目日报 Top10
根据Github Trendings的统计,今日(2024-06-10统计)共有10个项目上榜。根据开发语言中项目的数量,汇总情况如下: 开发语言项目数量C项目2Go项目2PHP项目1Blade项目1TypeScript项目1Lua项目1Dart项目1Swift项目1Cuda项目1Python项目1MDX项目1Ventoy: 100%开源的可启动USB解决方…...
前后端分离项目中Spring Boot返回的时间与前端相差8个小时
概述 今天在做一个前后端分离项目时,发现从后端获取的时间与从数据库获取的时间相差八个小时,最终排查后发现由于Springboot使用本地时区导致,修改SpringBoot时区后解决 环境 MySQL8SpringBoot 原因排查 发现从后端获取的数据总是比前端快八个小时 …...

stm32MP135裸机编程:使用USB/UART烧录程序到SD卡并从SD卡启动点亮一颗LED灯
0 参考资料 轻松使用STM32MP13x - 如MCU般在cortex A核上裸跑应用程序.pdf STM32CubeProgrammer v2.16.0 烧录需要的二进制文件1 烧录到SD卡需要哪些文件 参考《轻松使用STM32MP13x - 如MCU般在cortex A核上裸跑应用程序》,烧录需要的SD卡文件如下: &a…...

【NoSQL数据库】Redis Cluster集群(含redis集群扩容脚本)
Redis Cluster集群 Redis ClusterRedis 分布式扩展之 Redis Cluster 方案功能数据如何进行存储 redis 集群架构集群伸缩向集群中添加一个新的master节点,并向其中存储 num10 .脚本对redis集群扩容缩容,脚本参数为redis集群,固定从6001移动200…...

重邮计算机网络803-(2)物理层
一.物理层 1.介绍 物理层的主要任务描述为确定与传输媒体的接口的一些特性,即: ①机械特性 指明接口所用接线器的形状和尺寸、引线数目和排列、固定和锁定装置等等。 ②电气特性 指明在接口电缆的各条线上出现的电压的范围。 ③功能特性 指明某条线上…...
uniapp使用webview内嵌H5的注意事项
一、描述 uniapp项目中构建app,需要内嵌H5页面,在使用webview时,遇到了以下几个问题: 内嵌H5,默认全屏显示;内嵌页面遮挡住了app的自定义tabbar组件;样式修改无效; 二、解决方案&a…...
现代 C++的高效并发编程模式
现代C提供了许多高效的并发编程模式,以满足日益增长的多核和分布式系统的需求。以下是一些常用的高效并发编程模式: 异步编程:使用std::async来创建异步任务,可以在后台执行任务,将结果返回给调用者。 并行编程&#…...

汇编语言作业(五)
目录 一、实验目的 二、实验内容 三、实验步骤以及结果 四、实验结果与分析 五、 实验总结 一、实验目的 1.熟悉掌握汇编语言的程序结构,能正确书写数据段、代码段等 2,利用debug功能,查看寄存器(CS,IP,AX,DS..)及数据段的…...

收音机的原理笔记
1. 收音机原理 有线广播:我们听到的声音是通过空气振动进行传播,因此可以通过麦克风(话筒)将这种机械振动转换为电信号,传到远处,再重新通过扬声器(喇叭)转换为机械振动,…...
排序算法案例
排序算法概述 排序算法是计算机科学中的一个重要主题,用于将一组数据按特定顺序排列。排序算法有很多种,每种算法在不同情况下有不同的性能表现。不同的排序算法适用于不同的场景和数据特征。在选择排序算法时,需要考虑数据规模、数据分布以…...
时间序列评价指标
评价指标 均方误差( M S E MSE MSE) 定义:预测值与实际值之间差异的平方和的平均值。公式: ( M S E 1 n ∑ i 1 n ( y i − y ^ i ) 2 ) (MSE \frac{1}{n}\sum_{i1}^{n}(y_i - \hat{y}_i)^2) (MSEn1∑i1n(yi−y^i)…...

Docker:安装 Orion-Visor 服务器运维的技术指南
请关注微信公众号:拾荒的小海螺 博客地址:http://lsk-ww.cn/ 1、简述 Orion-Visor 是一种用于管理和监控容器的工具。它提供了一个直观的界面,用于查看容器的状态、资源使用情况以及日志等信息。在这篇技术博客中,我们将介绍如何…...

HarmonyOS Next 系列之底部标签栏TabBar实现(三)
系列文章目录 HarmonyOS Next 系列之省市区弹窗选择器实现(一) HarmonyOS Next 系列之验证码输入组件实现(二) HarmonyOS Next 系列之底部标签栏TabBar实现(三) 文章目录 系列文章目录前言一、实现原理二、…...

网络编程(Modbus进阶)
思维导图 Modbus RTU(先学一点理论) 概念 Modbus RTU 是工业自动化领域 最广泛应用的串行通信协议,由 Modicon 公司(现施耐德电气)于 1979 年推出。它以 高效率、强健性、易实现的特点成为工业控制系统的通信标准。 包…...
变量 varablie 声明- Rust 变量 let mut 声明与 C/C++ 变量声明对比分析
一、变量声明设计:let 与 mut 的哲学解析 Rust 采用 let 声明变量并通过 mut 显式标记可变性,这种设计体现了语言的核心哲学。以下是深度解析: 1.1 设计理念剖析 安全优先原则:默认不可变强制开发者明确声明意图 let x 5; …...

label-studio的使用教程(导入本地路径)
文章目录 1. 准备环境2. 脚本启动2.1 Windows2.2 Linux 3. 安装label-studio机器学习后端3.1 pip安装(推荐)3.2 GitHub仓库安装 4. 后端配置4.1 yolo环境4.2 引入后端模型4.3 修改脚本4.4 启动后端 5. 标注工程5.1 创建工程5.2 配置图片路径5.3 配置工程类型标签5.4 配置模型5.…...

Cloudflare 从 Nginx 到 Pingora:性能、效率与安全的全面升级
在互联网的快速发展中,高性能、高效率和高安全性的网络服务成为了各大互联网基础设施提供商的核心追求。Cloudflare 作为全球领先的互联网安全和基础设施公司,近期做出了一个重大技术决策:弃用长期使用的 Nginx,转而采用其内部开发…...

现代密码学 | 椭圆曲线密码学—附py代码
Elliptic Curve Cryptography 椭圆曲线密码学(ECC)是一种基于有限域上椭圆曲线数学特性的公钥加密技术。其核心原理涉及椭圆曲线的代数性质、离散对数问题以及有限域上的运算。 椭圆曲线密码学是多种数字签名算法的基础,例如椭圆曲线数字签…...
今日学习:Spring线程池|并发修改异常|链路丢失|登录续期|VIP过期策略|数值类缓存
文章目录 优雅版线程池ThreadPoolTaskExecutor和ThreadPoolTaskExecutor的装饰器并发修改异常并发修改异常简介实现机制设计原因及意义 使用线程池造成的链路丢失问题线程池导致的链路丢失问题发生原因 常见解决方法更好的解决方法设计精妙之处 登录续期登录续期常见实现方式特…...
React---day11
14.4 react-redux第三方库 提供connect、thunk之类的函数 以获取一个banner数据为例子 store: 我们在使用异步的时候理应是要使用中间件的,但是configureStore 已经自动集成了 redux-thunk,注意action里面要返回函数 import { configureS…...

【从零学习JVM|第三篇】类的生命周期(高频面试题)
前言: 在Java编程中,类的生命周期是指类从被加载到内存中开始,到被卸载出内存为止的整个过程。了解类的生命周期对于理解Java程序的运行机制以及性能优化非常重要。本文会深入探寻类的生命周期,让读者对此有深刻印象。 目录 …...
深入理解Optional:处理空指针异常
1. 使用Optional处理可能为空的集合 在Java开发中,集合判空是一个常见但容易出错的场景。传统方式虽然可行,但存在一些潜在问题: // 传统判空方式 if (!CollectionUtils.isEmpty(userInfoList)) {for (UserInfo userInfo : userInfoList) {…...

通过 Ansible 在 Windows 2022 上安装 IIS Web 服务器
拓扑结构 这是一个用于通过 Ansible 部署 IIS Web 服务器的实验室拓扑。 前提条件: 在被管理的节点上安装WinRm 准备一张自签名的证书 开放防火墙入站tcp 5985 5986端口 准备自签名证书 PS C:\Users\azureuser> $cert New-SelfSignedCertificate -DnsName &…...