一、数组
数组的类型:数组中存储元素类型+数组长度
Go 语言的数组在初始化之后长度就无法改变了,存储元素类型相同但长度不同的数组类型在 Go 语言看来也完全不同。如下,创建数组
1 | // NewArray returns a new fixed-length array Type. |
如上,是编译期间创建数组调用的函数,Array 结构体包括 Elem 元素类型,Bound 数组大小。
1 | arr1 := [3]int{1, 2, 3} |
如上,第一种方式 [3]int
,变量类型在编译进行类型检查阶段就会被提取出来,确定数组类型。第二种方式 [...]int
,编译器会先推导数组的大小,然后再确定数组类型。
数组在内存中是连续的内存空间,我们通过数组开头的指针、元素的数量以及元素的类型占用的空间大小表示数组。一些简单的越界错误会在编译期间发现,例如直接使用整数或者常量访问数组;如果使用变量访问数组,编译器就无法提前发现错误,需要Go语言运行时阻止不合法的访问。
二、切片
切片的定义如:[]interface{}
,创建切片的函数
1 | // NewSlice returns the slice Type with element type elem. |
如上,此函数返回结构体 Slice 中的 Extra 字段是一个只包含切片内元素类型的结构,也就是说,切片内元素的类型是编译期间确定的。编译器确定了类型之后,会将类型存储在 Extra 字段中帮助程序在运行时动态获取。
1 | type SliceHeader struct { |
编译期间的切片的类型是 Slice 结构,但是在运行时是 SliceHeader 结构。其中
- Data 是指向一段连续内存空间的指针
- Len 是当前切片的长度
- Cap 是当前切片的容量,即 Data 数组的大小
切片引入了一个抽象层,提供了对数组中部分连续片段的引用,而作为数组的引用,我们可以在运行时修改他的长度和范围。当切片底层的数组长度不足时就会触发扩容,切片指向的数组可能会发生变化。不过在上层看来切片没有变化。
1 | slice := array[0:3] 或 slice[0:3] // 使用下标的方式获得数组或者切片的一部分 |
1. 切片初始化(使用下标的方式获得数组或者切片的一部分)
1 | func main() { |
底层会调用 SliceMake 操作,会接收4个参数创建新的切片(元素类型、数组指针、切片大小、容量)。注意:使用下标初始化切片不会复制原数组和原切片中的数据,而只会创建一个指向原数组的切片结构体,所以修改新切片的数据也相当于修改了原切片
2. 切片初始化(使用字面量)
当使用字面量创建新切片时,在编译期间会将创建切片的动作展开成如下所示代码片段
1 | slice := []int{1, 2, 3} // 使用字面量初始化新的切片 |
如上,使用字面量初始化新的切片时,他的操作:
- 根据切片中的元素数量推断底层数组的大小并创建一个数组
- 将这些字面量元素存储到初始化的数组中
- 创建一个同样指向
[3]int
类型的数组指针 - 将静态存储区的数组 vstat 赋值给 vauto 指针所在的地址
- 通过
[:]
操作获取一个底层使用 vauto 的切片。其中[:]
就是使用下标创建切片的方法
3. 切片初始化(make)
当使用 make 关键字创建切片时,很多工作需要运行时参与;调用方必须向 make 函数传入切片大小以及可选容量,在类型检查期间会校验入参。
根据不同的条件创建切片:
- 切片大小和容量是否足够小,最终在栈上初始化
- 切片是否发生了逃逸或切片非常大,最终在堆中初始化。
4. 追加和扩容
一般使用 append 关键字向切片中追加元素。当切片容量不足时,会调用 runtime.growslice
函数为切片扩容。扩容是为切片分配新的内存空间并复制原切片中元素到新内存空间的过程。在分配内存之前需要确定新的切片容量,运行时会根据切片的容量选择不同的策略进行扩容:
- 如果期望容量大于当前容量的两倍,就会使用期望容量
- 如果当前切片的长度小于 1024,就会将容量翻倍
- 如果当前切片的长度大于 1024,就会每次增加 25% 的容量,直到新容量大于期望容量
如上策略只是确定切片的大致容量,还需要根据切片中的元素大小对齐内存。当元素所占字节大小为 1、2 或 8 的倍数时,运行时会将待申请的内存向上取整,取整时会使用 runtime.class_to_size
数组,使用该数组中的整数可以提高内存分配并减少碎片。为什么使用该数组在【内存分配】中详解
1 | var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, ... , 27264, 28672, 32768} |
默认情况下,我们会将目标容量和元素大小相乘得到占用的内存,然后根据切片中元素的大小,向上取整内存的大小。如果这个过程中发生了内存溢出或者请求内存超过上限,程序就会直接崩溃退出。
5. 复制切片
当我们使用 copy 对切片进行复制时,无论是编译期间复制还是运行时复制,两种复制方式都会通过 runtime.memmove
将整块内存的内容复制到目标内存区域。
三、字符串
Go 语言中字符串是只读的字节数组。他的运行时结构如下。包括指向字节数组的指针和数组的大小
1 | type StringHeader struct { |
1. 解析过程
在 Go 语言中,使用两种字面量方式声明字符串,即双引号和反引号。
1 | str1 := "this is a string" |
使用双引号声明的字符串只能用于单行字符串的初始化,如果字符串内部有双引号,需要转义,避免解析错误
使用反引号声明的字符串可以摆脱单行的限制,对于需要手写 JSON 或者其他复杂数据格式的场景非常方便。
2. 拼接
Go语言使用 + 符号拼接字符串。正常情况下,运行时会调用 copy 将输入的多个字符串复制到目标字符串所在的内存空间。新字符串是一块新的内存空间,与原来的字符串没有任何关联,一旦需要拼接的字符串非常大,复制造成的性能损失是无法忽略的
3. 类型转换
经常需要将数据在 string
和 []byte
之间来回转换。
- 从切片到字符串的转换需要使用
runtime.slicebytetostring
函数,例如string(bytes)
1 | func slicebytetostring(buf *tmpBuf, ptr *byte, n int) (str string) { |
首先处理 长度为 0 或 1 的切片,然后根据传入的缓冲区大小决定是否需要为新字符串分配一块内存空间。然后设置字符串结构体的指针和长度,最后通过 runtime.memmove
将原切片中的字节全部复制到新的内存空间中
- 从字符串转换成切片类型,需要使用
runtime.stringtoslicebyte
函数
1 | func stringtoslicebyte(buf *tmpBuf, s string) []byte { |
会根据是否传入缓冲区做出不同的处理
- 当传入缓冲区时,它会使用传入的缓冲区存储切片
- 当没有传入缓冲区时,运行时会调用
runtime.rawbyteslice
创建新的切片并将字符串中的内容复制过去
切片和字符串之间的互相转换都需要复制数据,并且内存复制导致的性能损耗会随着字符串和切片长度的增长而增长。在做字符串拼接和类型转换等操作时一定要注意性能损耗,如果需要极致性能的场景,一定要尽量减少类型转换的次数。