Go学习
slice:
slice 的底层数据其实是 数组,slice 是对数组的封装,它描述一个数组的片段。slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。
type slice struct {
array unsafe.Pointer* //元素指针
len int //长度
cap int //容量
}
扩容:
1.17及以前:
如果期望容量大于当前容量的两倍就会使用期望容量;
如果当前切片的长度小于 1024 就会将容量翻倍;
如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;
Go1.18及以后,引入了新的扩容规则:
当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍;
原slice容量超过256,新slice容量newcap = oldcap+(oldcap+3*256)/4;
数组和切片的区别?
数组:数组固定长度。数组长度是数组类型的一部分,数组需要指定大小,不指定也会根据初始化,自动推算出大小, 大小不可改变。数组是通过值传递的。
切片(slice):切片可以改变长度。切片是轻量级的数据结构,三个属性,指针,长度,容量,不需要指定大小。切片是地址传递(引用传递)可以通过数组来初始化,也可以通过内置函数 make()来初始化,初始化的时候 len=cap,然后进行扩容。
从一个切片截取出另一个切片,修改新切片的值会影响原来的切片内容吗?
在截取完之后,如果新切片没有触发扩容,则修改切片元素会影响原切片,如果触发了扩容则不会。
package main
import "fmt"
func main() {
slice := []int{0,1,2,3,4,5,6,7,8,9} // len=10 cap=10
s1 := slice[2:5] // [2 3 4] len=5-2=3, cap=10-2=8
s2 := s1[2:6:7] // [4 5 6 7] len=6-2=4, cap=7-2=5
s2 = append(s2, 100)
s2 = append(s2, 200)
s1[2] = 20
fmt.Println(s1) // [2 3 20]
fmt.Println(s2) // [4 5 6 7 100 200]
fmt.Println(slice) // [0 1 2 3 20 5 6 7 100 9]
}
示例:
slice |0|1|2|3|4|5|6|7|8|9|
s1 |2|3|4| | | | | |
s2 |4|5|6|7| |
第一次 s2 追加100时,s2 容量刚好够,直接追加。不过,这会修改原始数组对应位置的元素。这一改动,slice 和 s1 都可以看得到。
第二次 s2 追加200时,s2 的容量不够用,该扩容了。于是,s2 将原来的元素复制新的位置,扩大自己的容量。并且为了应对未来可能的 append 带来的再一次扩容,s2 会在此次扩容的时候多留一些 buffer,将新的容量将扩大为原始容量的2倍,也就是10了。
最后,修改 s1 索引为2位置的元素,这次只会影响原始数组相应位置的元素。它影响不到 s2 了。
注意:打印 s1 的时候,只会打印出 s1 长度以内的元素。所以,只会打印出3个元素,虽然它的底层数组不止3个元素。
slice作为函数参数传递,会改变原slice吗?
当 slice 作为函数参数时,因为会拷贝一份新的 slice 作为实参,所以原来的 slice 结构并不会被函数中的操作改变,也就是说,slice 其实是一个结构体,包含了三个成员:len, cap, array 并不会变化。
但是需要注意的是,尽管slice结构不会变,但是其底层数组的 数据 如果有修改的话,则会发生变化。若传的是 slice 的指针,则原 slice 结构会变,底层数组的数据也会变。
package main
import "fmt"
func myAdd(s []int) {
// i只是一个副本,不能改变s中元素的值
/*for _, i := range s {
i++
}*/
for i := range s {
s[i] += 1 //会改变s中元素的值
}
}
func myAppend(s []int) []int {
// 这里 s 虽然改变了,但并不会影响外层函数的 s
s = append(s, 100)
return s
}
func myAppendPtr(s *[]int) {
// 会改变外层 s 本身
*s = append(*s, 100)
return
}
func main() {
s := []int{1,1,1}
myAdd(s)
newS := myAppend(s)
fmt.Println(s) // [2 2 2]
fmt.Println(newS) // [2 2 2 100]
s = newS
myAppendPtr(&s)
fmt.Println(s) // [2 2 2 100 100]
}
内存逃逸现象什么?
逃逸是指在函数内部创建的,在函数结束后仍被其他部分引用或持有。是编译器在程序编译时期根据逃逸分析策略,将原本应该分配到栈上的对象分配到堆上的一个过程。
识别不必要的场景,规避不必要的内存逃逸:闭包函数是对内存逃逸合理的利用。
考察內存管理,堆栈的区别:
栈上的内存分配和释放由编译器自动管理,速度快但空间有限。
堆上的内存分配需要运行时的系统的参与,相对较慢但空间较大,由GC回收
影响:
因为堆对象需要 垃圾回收机制 来 释放内存,栈对象会跟随函数结束被编译器回收,所以大量的内存逃逸会给 GC 带来压力,回收的开销增加;指针引用和内存安全;内存泄露风险
导致内存逃逸的原因:
栈空间不足时,无法容纳大变量,会导致内存逃逸;
作用域发生变化,使得函数内部的变量,被外部函数或变量所引用,导致函数执行完后,变量还存在;
编译时无法确定类型或大小。
主要逃逸场景:
返回局部变量指针:函数返回内部变量的地址,变量必须逃逸到堆上
interface{}类型:传递给interface{}参数的具体类型会逃逸,因为需要运行时类型信息
闭包引用外部变量:被闭包捕获的变量会逃逸到堆上
切片/map动态扩容:当容量超出编译期确定范围时会逃逸
大对象:超过栈大小限制的对象直接分配到堆上
func f1() (*int, []int, map[int]int) {
i := 0
list := []int {1,2,3,4}
mp := map[int]int{1:1, 2:2}
return &i, list, mp //返回指针、slice和map,会发生逃逸
}
func f2() {
i := 2
ch := make(chan *int, 2)
ch <- &i //向chan中发送数据的指针或包含指针的值(chan是作为协程兼通讯的通道)
<-ch //函数结束后,ch会被销毁,但ch里面的值可能会被其他协程或方法引用
}
func f3() func() { //返回一个函数
i := 1
return func() {
fmt.Println(i) //非直接的函数调用,比如在闭包中引用包外的值,因为闭包执行的生命周期可能会超过函数周期
}
}
func f4() {
i := 1
list := make([]*int, 10)
list[0] = &i //在slice或map中存储指针或包含指针的值,slice或map都需要动态分配内存来保存数据
}
func f5() {
var a animal = dog{} //没问题
a.run()
var a1 animal //由于接口类型可以持有 任意实现了该接口的类型,编译器在编译时无法确定具体的动态类型
a1 = dog{}
a.run()
}
type animal interface {
run()
}
type dog struct{}
func (a dog) run() {}
运行时注意:moved to heap、escapes to heap
如何知道一个对象是分配在栈上还是堆上?
Go和C++不同,Go局部变量会进行 逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢?
go build -gcflags '-m -m -l' xxx.go.
关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。
golang分配的两个基本原则
指向栈上对象的指针不能被存储到堆中。
指向栈上对象的指针不能超过该栈对象的生命周期。
make 和 new 的区别?
make 和 new 都是用于内存分配的内建函数,但它们的使用场景和功能有所不同。
make:用于初始化并分配内存,只能用于创建 slice、map 和 channel 三种类型;返回的是初始化后的数据结构,而不是指针。
new:用于分配内存,但不初始化,返回的是指向该内存的指针(指向未初始化零值的内存地址);可以用于任何类型的内存分配。
使用for range 的时候,它的地址会发生变化吗?
for index, value := range collection {
//...
}
在Go 1.22之前:对于 for range 循环中的迭代变量,其内存地址是不会发生变化的。
这里的 value 是一个 副本。在每次迭代中,collection 中的当前元素值会被 复制 到 value 这个变量中。Go 编译器通常会为 value 分配一块 固定的内存地址,然后在每次迭代时,将当前元素的值覆盖到这块内存中。所以,当打印 &value 时,会发现它的内存地址在整个循环过程中都是保持不变的。
Go 1.23以后:对于 for range 循环中的迭代变量,其地址是临时的,是变化的,不一样的,不再是共享内存了。
使用 for range 遍历一个集合时,迭代变量的地址会发生变化。这是因为 for range 每次迭代时都会 重新生成 迭代变量(如 value),这些变量在内存中是不同的地址。
Go 语言函数传参是值类型还是引用类型?
在 Go 语言中只存在 值传递,要么是值的副本,要么是指针的副本。无论是值类型的变量还是引用类型的变量亦或是指针类型的变量作为参数传递都会发生 值拷贝,开辟新的内存空间。
另外值传递、引用传递和值类型、引用类型是两个不同的概念,不要混淆了。引用类型作为变量传递可以影响到函数外部是因为发生 值拷贝后 新旧变量 指向了 相同的内存地址。
多返回值是如何实现的?
Go 语言的多返回值 是通过在 函数调用栈帧 上预留空间并进行 值复制 来实现的。在函数调用发生时,Go 编译器会计算出函数 所有返回值 的总大小。在为该函数创建 栈帧 时,就会在调用方(caller)的栈帧上,为这些返回值预留出 连续的内存空间。
当函数执行到 return 语句时,它会将其要返回的各个值 复制 到这些预留好的栈空间中。函数执行完毕后,控制权返回给调用方。此时,调用方可以直接从它自己的栈帧上(即之前为返回值预留的空间)获取这些返回的值。
普通指针和unsafe.Pointer有什么区别?
普通指针比如 *int、 *string,它们有明确的类型信息,编译器会进行 类型检查 和 垃圾回收跟踪。不同类型的指针之间 不能直接转换,这是Go类型安全的体现。
而 unsafe.Pointer 是Go的通用指针类型,可以理解为C语言中的 void*,它绕过了Go的类型系统。unsafe.Pointer 可以与任意类型的指针相互转换,也可以与 uintptr 进行转换来做指针运算。
另外,通指针受GC管理和类型约束,unsafe.Pointer 不受类型约束但仍受GC跟踪。
unsafe.Pointer与uintptr有什么区别和联系
unsafe.Pointer 和 uintptr 可以相互转换,这是Go提供的唯一合法的指针运算方式。典型用法是先将 unsafe.Pointer 转为 uintptr 做算术运算,然后再转回 unsafe.Pointer 使用。
最关键的区别在于 GC跟踪。unsafe.Pointer 会被 垃圾回收器 跟踪,它指向的内存不会被错误回收;而 uintptr 只是一个 普通整数,GC完全不知道它指向什么,如果没有其他引用,对应内存可能随时被回收。
所以记住:unsafe.Pointer 有GC保护,uintptr 没有,这是它们最本质的区别。