删除
如果想要删除哈希中的元素,就需要使用Go语言中的delete
关键字,这个关键字的唯一作用就是将某一个键对应的元素从哈希表中删除,无论该键对应的值是否存在,这个内建的函数不会返回任何结果。
在编译期间,delete
关键字会被转换成操作为ODELETE
的节点,而ODELETE
会被cmd/compile/internal/gc.walkexpr
转换成mapdelete
函数簇中的一个,包括mapdelete
、mapdelete_faststr
、mapdelete_fast32
和mapdelete_fast64
:
func walkexpr(n *Node, init *Nodes) *Node {
switch n.Op {
// 处理哈希表删除操作的节点
case ODELETE:
// 将当前节点的初始化代码加入init列表中
init.AppendNodes(&n.Ninit)
// 获取要操作的哈希表节点
map_ := n.List.First()
// 获取要删除的键节点
key := n.List.Second()
// 进一步遍历和转换哈希表节点和键节点,这可能在init中添加更多的初始化语句
map_ = walkexpr(map_, init)
key = walkexpr(key, init)
// 获取哈希表类型
t := map_.Type
// 通过mapfast函数确定哈希的删除方式
fast := mapfast(t)
// 如果是慢速删除
if fast == mapslow {
// 将键节点包装成取地址的节点
key = nod(OADDR, key, nil)
}
// 构建用于删除操作的函数调用节点,将返回的函数调用节点替换n(ODELETE节点)
// mapfndel根据删除类型选择删除函数
n = mkcall1(mapfndel(mapdelete[fast], t), nil, init, typename(t), map_, key)
}
}
这些函数的实现其实差不多,我们来分析其中的runtime.mapdelete
函数,哈希表的删除逻辑与写入逻辑非常相似,只是触发哈希的删除需要使用关键字,如果在删除期间遇到了哈希表的扩容,就会对即将操作的桶进行分流,分流结束之后会找到桶中的目标元素完成键值对的删除工作。
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
// ...
// 如果哈希表正在扩容
if h.growing() {
// 处理增量扩容工作,确保在删除开始前,处理完必要的扩容工作
growWork(t, h, bucket)
}
// ...
search:
// 遍历目标桶b,以及它的所有溢出桶
for ; b != nil; b = b.overflow(t) {
// 遍历桶中的每个槽位
for i := uintptr(0); i < bucketCnt; i++ {
// 如果tophash不匹配
if b.tophash[i] != top {
// 如果后面已经没有槽位了
if b.tophash[i] == emptyRest {
// 停止搜索
break search
}
// 搜索下一个槽位
continue
}
// 此处tophash已经匹配了,获取完整的key的地址
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
k2 = k
// 如果完整的key不相等
if !alg.equal(key, k2) {
// 处理下一个槽位
continue
}
// 删除key
*(*unsafe.Pointer)(k) = nil
// 获取指向v的指针
v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
// 删除value
*(*unsafe.Pointer)(v) = nil
// 将槽位设为emptyOne,表示此槽位曾经被占用,但现在为空
b.tophash[i] = emptyOne
// ...
}
}
}
我们其实只需要知道delete
关键在在编译期间经过类型检查和中间代码生成阶段被转换成runtime.mapdelete
函数簇中的一员就可以,用于处理删除逻辑的函数与哈希表的runtime.mapassign
几乎完全相同,不太需要刻意关注。
3.3.5 小结
Go语言使用拉链法来解决哈希碰撞问题实现了哈希表,它的访问、写入和删除等操作都在编译期间转换成了运行时的函数或方法。
哈希在每一个桶中存储键对应哈希的前8位,当对哈希进行操作时,这些tophash
就成为了一级缓存帮助哈希快速遍历桶中元素,每一个桶都只能存储8个键值对,一旦当前哈希的某个桶超出8个,新的键值对就会被存储到哈希的溢出桶中。
随着键值对数量的增加,溢出桶的数量和哈希的装载因子也会逐渐升高,超过一定范围就会触发扩容,扩容会将桶的数量翻倍,元素再分配的过程也是在调用写操作时增量进行的,不会造成性能的瞬时巨大抖动。
3.4 字符串
字符串是Go语言中最常用的基础数据类型之一,虽然字符串往往被看做一个整体,但是实际上字符串是一片连续的内存空间,我们也可以将它理解成一个由字符组成的数组,在这一节中就会详细介绍字符串的实现原理、相关转换过程以及常见操作的实现。
字符串虽然在Go语言中是基本类型string
,但是它实际上是由字符组成的数组,C语言中的字符串就使用字符数组char[]
表示,作为数组会占用一片连续的内存空间,这片内存空间存储了的字节共同组成了字符串,Go语言中的字符串其实是一个只读的字节数组,下图展示了"hello"
字符串在内存中的存储方式:
如果是代码中存在的字符串,会在编译期间被标记成只读数据SRODATA
符号(Static Read Only DATA),假设我们有以下的一段代码,其中包含了一个字符串,当我们将这段代码编译成汇编语言时,就能够看到hello
字符串有一个SRODATA
的标记:
$ cat main.go
package main
func main() {
str := "hello"
println([]byte(str))
}
# -S表示将生成的汇编代码输出到控制台
$ GOOS=linux GOARCH=amd64 go tool compile -S main.go
...
# dupok表示如果有多个相同的只读数据段,可以重复使用
go.string."hello" SRODATA dupok size=5
# hello的ASCII码表示
0x0000 68 65 6c 6c 6f hello
...
只读意味着字符串会分配到只读的内存空间并且这块内存不会被修改,但是在运行时我们其实还是可以将这段内存拷贝到堆或者栈上,将变量的类型转换成[]byte
后就可以进行,修改后通过类型转换就可以变回string
,Go语言只是不支持直接修改string
类型变量的内存空间。
3.4.1 数据结构
字符串在Go语言中的接口其实非常简单,每一个字符串在运行时都会使用如下的StringHeader
结构体表示,在运行时包的内部其实有一个私有的结构stringHeader
,它有着完全相同的结构只是用于存储数据的Data
字段使用了unsafe.Pointer
类型:
type StringHeader struct {
Data uintptr
Len int
}
我们会经常说字符串是一个只读的切片类型,这是因为切片在Go语言的运行时表示与字符串高度相似:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
与切片的结构体相比,字符串少了一个表示容量的Cap
字段,因为字符串作为只读类型,我们并不会直接向字符串直接追加元素改变其本身的内存空间,所有在字符串上执行的写入操作实际都是通过拷贝实现的。
3.4.2 解析过程
字符串的解析一定是解析器在词法分析时就完成的,词法分析阶段会处理源文件中的字符串,将原有无意义的字符流转换成Token序列,在Go语言中,有两种字面量方式可以声明一个字符串,一种是使用双引号,另一种是使用反引号:
str1 := "this is a string"
str2 := `this is another
string`
使用双引号声明的字符串和其他语言中的字符串没有太多的区别,它只能用于单行字符串的初始化,如果字符串内部出现双引号,需要使用\
符号避免编译器的解析错误,而反引号声明的字符串就可以摆脱单行的限制,因为双引号不再负责标记字符串的开始和结束,我们可以在字符串内部直接使用"
,在遇到需要手写JSON或者其他复杂数据格式的场景下非常方便。
json := `{"author": "draven", "tags": ["golang"]}`
两种不同的声明方式也意味着Go语言的编译器需要在解析阶段能够区分并且正确解析这两种不同的字符串格式,解析字符串使用的scanner
扫描器,它的功能就是将输入的字符流转换成Token流,cmd/compile/internal/syntax.scanner.stdString
方法用来解析使用双引号包裹的标准字符串:
func (s *scanner) stdString() {
// 记录字符串字面量的开始位置,从这个位置开始,后面是字符串字面量
s.startLit()
for {
// 获取下一个字符
r := s.getr()
// 遇到结束引号时,结束字符串扫描
if r == '"' {
break
}
// 如果遇到反斜杠,处理转义字符
if r == '\\' {
s.escape('"')
continue
}
// 遇到换行符,将换行符放回流中,然后报告错误,之后结束字符串扫描
if r == '\n' {
s.ungetr()
s.error("newline in string")
break
}
// 如果遇到文件结束符或读取出错,报告错误并结束字符串扫描
if r < 0 {
// s.errh相比s.error,可以报告出错位置
s.errh(s.line, s.col, "string not terminated")
break
}
}
// 标记字符串字面量后面可以是分号
s.nlsemi = true
// 保存字符串字面量内容
s.lit = string(s.stopLit())
// 将kind字段设为字符串字面量
s.kind = StringLit
// 将tok字段设为字面量类型
s.tok = _Literal
}
从这个方法的实现我们能分析出Go语言处理标准字符串的逻辑:
1.标准字符串使用双引号表示开头和结尾;
2.标准字符串中需要使用反斜杠\
来escape
双引号;
3.标准字符串中不能出现如下所示的隐式换行符\n'
;
str := "start
end"
使用反引号声明的原始字符串的解析规则就非常简单了,cmd/compile/internal/syntax.scanner.rawString
会将非反引号的所有字符都划分到当前字符串的范围中,所以我们可以使用它来支持复杂的多行字符串:
func (s *scanner) rawString() {
// 记录字符串字面量的开始位置,从这个位置开始,后面是字符串字面量
s.startLit()
for {
// 获取下一个字符
r := s.getr()
// 如果是反引号,表示字符串字面量结束,结束字符串扫描
if r == '`' {
break
}
// 如果遇到文件结束符或读取出错,报告错误并结束字符串扫描
if r < 0 {
s.errh(s.line, s.col, "string not terminated")
break
}
}
// 与处理普通字符串字面量时相同
s.nlsemi = true
s.lit = string(s.stopLit())
s.kind = StringLit
s.tok = _Literal
}
无论是标准字符串还是原始字符串最终都会被标记成StringLit
类型的Token并传递到编译的下一个阶段——语法分析,在语法分析阶段,与字符串相关的表达式都会使用如下的方法cmd/compile/internal/gc.noder.basicLit
处理:
// basicLit处理基本字面量
func (p *noder) basicLit(lit *syntax.BasicLit) Val {
// 将lit.Value赋值给s,然后根据lit.Kind选择case
switch s := lit.Value; lit.Kind {
// 如果是字符串字面量
case syntax.StringLit:
// 如果字符串字面量非空且是原始字符串字面量
if len(s) > 0 && s[0] == '`' {
// 将s中的所有"\r"替换为"",这是为了处理Windows换行符\r\n
s = strings.Replace(s, "\r", "", -1)
}
// 去除字符串字面量的引号,将其转换为字符串
u, _ := strconv.Unquote(s)
// 将解析后的字符串封装到Val结构中并返回
return Val{U: u}
}
}
无论是import
语句中包的路径、结构体中的字段标签还是表达式中的字符串都会使用这个方法将原生字符串中最后的换行符删除并对字符串Token进行Unquote,也就是去掉字符串两边的引号等无关干扰,还原其本来的面目。
strconv.Unquote
方法处理了很多边界条件导致整个函数非常复杂,不仅包括各种不同引号的处理,还包括UTF-8等编码的相关问题,所以在这里就不展开介绍了。
3.4.5 拼接
Go语言拼接字符串会使用+
符号,编译器会将该符号对应的OADD
节点转换成OADDSTR
类型的节点,随后在cmd/compile/internal/gc.walkexpr
函数中调用cmd/compile/internal/gc.addstr
函数生成用于拼接字符串的代码:
func walkexpr(n *Node, init *Nodes) *Node {
switch n.Op {
// ...
case OADDSTR:
n = addstr(n, init)
}
}
cmd/compile/internal/gc.addstr
函数能帮助我们在编译期间选择合适的函数对字符串进行拼接,如果需要拼接的字符串小于等于5个,那么就会直接调用concatstring{2,3,4,5}
等一系列函数,如果超过5个就会直接选择runtime.concatstrings
传入一个数组切片。
func addstr(n *Node, init *Nodes) *Node {
// 获取节点列表的长度
c := n.List.Len()
// 创建空节点
buf := nodnil()
// 初始化参数列表
args := []*Node{buf}
// 将要拼接的字符串节点转换成字符串类型,然后加入参数列表
for _, n2 := range n.List.Slice() {
args = append(args, conv(n2, types.Types[TSTRING]))
}
var fn string
// 如果要拼接的字符串个数小于等于5
if c <= 5 {
// 选择相应的函数名concatstring{2,3,4,5}
fn = fmt.Sprintf("concatstring%d", c)
// 如果要拼接的字符串个数大于5
} else {
// 使用函数concatstrings
fn = "concatstrings"
// 创建一个新的字符串slice
t := types.NewSlice(types.Types[TSTRING])
// 将slice转换成复合字面值(OCOMPLIT)节点
slice := nod(OCOMPLIT, nil, typenod(t))
// 将节点的元素列表设为要拼接的字符串字面值列表
slice.List.Set(args[1:])
// 重新设置参数列表
args = []*Node{buf, slice}
}
// 查找要使用的拼接函数并创建函数调用节点
cat := syslook(fn)
r := nod(OCALL, cat, nil)
r.List.Set(args)
// ...
return r
}
其实无论使用concatstring{2,3,4,5}
中的哪一个,最终都会调用runtime.concatstrings
,该函数会先对传入的切片参数进行遍历,先过滤空字符串并计算拼接后字符串的长度。
func concatstrings(buf *tmpBuf, a []string) string {
// 记录最后一个非空字符串的索引
idx := 0
// 所有非空字符串的总长度
l := 0
// 计数非空字符串的数量
count := 0
for i, x := range a {
n := len(x)
if n == 0 {
continue
}
l += n
count++
idx = i
}
// 如果所有字符串都为空,返回空字符串
if count == 0 {
return ""
}
// 如果只有一个非空字符串且(有缓冲区可用或字符串数据不在栈上)
if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
// 直接那个唯一的返回非空字符串
return a[idx]
}
// 创建一个新字符串s和字节切片b,长度为l,用于存储拼接结果
// 如果buf不为nil,则使用buf作为临时缓冲区
s, b := rawstringtmp(buf, l)
for _, x := range a {
// 将当前遍历到的字符串x拷贝到字节切片b中
copy(b, x)
// 更新切片b的起始位置
b = b[len(x):]
}
return s
}
如果非空字符串的数量为1并且当前的字符串不在栈上就可以直接返回该字符串,不需要进行额外的任何操作。
3.4.4 类型转换
当我们使用Go语言解析和序列化JSON等数据格式时,经常需要将数据在string
和[]byte
之间来回转换,类型转换的开销并没有想象的那么小,我们经常会看到runtime.slicebytetostring
等函数出现在火焰图中,称为程序的性能热点。
从字节数组到字符串的转换就需要使用runtime.slicebytetostring
函数,例如:string(bytes)
,该函数在函数体中会先处理两种比较常见的情况,也就是字节数组的长度为0或者1,这两个情况处理起来都非常简单:
func slicebytetostring(buf *tmpBuf, b []byte) (str string) {
// 获取字节切片的长度
l := len(b)
// 如果字节切片的长度为0
if l == 0 {
// 返回空字符串
return ""
}
// 如果字节切片的长度为1
if l == 1 {
// 将string的str设为字节切片中第一个字节的地址
stringStructOf(&str).str = unsafe.Pointer(&staticbytes[b[0]])
// 将string的长度设为1
stringStructOf(&str).len = 1
return
}
var p unsafe.Pointer
// 如果调用者提供了缓冲区,且缓冲区能容纳b
if buf != nil && len(b) <= len(buf) {
// 直接使用调用者提供的缓冲区
p = unsafe.Pointer(buf)
} else {
// 否则自己分配一块内存
p = malloc(uintptr(len(b)), nil, false)
}
// 设置string的实际数据所在地址和长度
stringStructOf(&str).str = p
stringStructOf(&str).len = len(b)
// 将字节切片的内容memmove到p指向的内存
memmove(p, (*(*slice)(unsafe.Pointer(&b))).array, uintptr(len(b)))
return
}
处理过后会根据传入的缓冲区大小决定是否需要为新的字符串分配一片内存空间,runtime.stringStructOf
会将传入的字符串指针转换成stringStruct
结构体指针,然后设置结构体持有的字符串指针str
和长度len
,最后通过memmove
将原[]byte
中的字节全部复制到新的内存空间中。
当我们想要将字符串转换成[]byte
类型时,就需要使用runtime.stringtoslicebyte
函数,该函数的实现非常容易理解:
func stringtoslicebyte(buf *tmpBuf, s string) []byte {
var b []byte
// 如果传入了缓冲区,且缓冲区足够放下字符串s
if buf != nil && len(s) <= len(buf) {
// 重置缓冲区,确保它为空
*buf = tmpBuf{}
// 将缓冲区的前len(s)个字节的切片分配给b
b = buf[:len(s)]
} else {
// 否则,分配一块新的长为len(s)的字节slice
b = rawbyteslice(len(s))
}
// 将字符串s的内容复制到b
copy(b, s)
return b
}
如果向该函数传入了缓冲区,那么它会使用传入的缓冲区存储[]byte
,没有传入缓冲区时,运行时会调用runtime.rawbyteslice
创建一个新的字节切片,copy
就会将字符串中的内容拷贝到新的[]byte
中。
字符串和[]byte
中的内容虽然一样,但是字符串的内容是只读的,我们不能通过下标或者其他形式改变其中的数据,而[]byte
中的内容是可以读写的,无论从哪种类型转换到另一种都需要对其中的内容进行拷贝,而内存拷贝的性能损耗会随着字符串和[]byte
长度的增长而增长。
3.4.5 小结
字符串是Go语言中相对来说比较简单的一种数据结构,我们在这一节中详细分析了字符串与[]byte
类型的关系,从词法分析阶段理解字符串是如何被解析的,作为只读的数据类型,我们无法改变其本身的结构,但是在做拼接和类型转换等操作时一定要注意性能的损耗,遇到需要极致性能的场景一定要尽量减少类型转换的次数。