Go面试高频考点-数组/切片、字符串、chan、内存逃逸和泄漏

101 阅读6分钟

数组与切片

数组

定义数组需要在定义时指定数组的长度和存放的数据类型

数组初始化成功后,存储类型和长度不能改变

如果要存储更多的元素,需要先创建更长的数组,然后将原数组的数据拷贝到新数组中

切片

切片属于引用类型,go语言中引用类型包括slice,map,channel,函数和指针

引用类型在赋值时拷贝的是指针的值,也就是说拷贝后新变量和旧变量的值(即指向的地址)是相同的,这点和C语言一样

切片的内部实现

切片的底层实现是一个数据结构,可以按照需要动态的自动增长和缩小

切片的动态增长是通过append函数来实现的,动态缩小是通过对切片进行切片来实现

注意切片在内存中也是连续分配的,所以对于切片也能够获取索引和进行迭代

源码定义src/runtime/slice.go,包括了切片的指针,当前的长度和当前的容量

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

字符串

字符的实现

字符串和字符数组不同。

字符串一旦确定之后,其中的元素就不可被更改了,长度当然也是固定的

type stringStruct struct {
    array unsafe.Pointer   // 指向字符串首地址
    length int             // 长度
}

字符串结构体包括两个部分,一个是指向底层的字节数组的指针;一个是字符串长度的变量

字符串不是切片,但是可以通过切片操作

字符串的修改

字符串作为常量不可以直接修改,只能通过先转换成[]byte数组,再转换成string来完成

但是这个过程一定会重新分配内存

func main() {
    s1 := "hello"
    b1 := []byte(s1)
    b1[0] = 'a'
    s2 := string(b1)
    fmt.Println(s2) //aello
}

channel

channel实现

type hchan struct {
    buf      unsafe.Pointer // points to an array of dataqsiz elements
    sendx    uint   // send index
    recvx    uint   // receive index
    recvq    waitq  // list of recv waiters
    sendq    waitq  // list of send waiters
    lock mutex
}
  • buf,循环链表
  • sendx,recvx用于记录buf循环链表中发送者和接受者的index
  • lock, 互斥锁
  • recvq,sendq,双向链表,分别是接收者和发送者goroutine的抽象出来的结构体队列

创建channel

创建channel实际上是创建一个hchan结构体,然后返回指针

函数的调用传递就是传递的这个指针,因此不需要使用channel的指针,直接使用channel即可

未初始化channel写

func main() {
    var c chan int      //只声明,没有初始化
    fmt.Printf("%v", c) //<nil>
    c <- 1              
}
// <nil>fatal error: all goroutines are asleep - deadlock!
//goroutine 1 [chan send (nil chan)]:

未初始化channel读

func main() {
    var c chan int      //只声明,没有初始化
    fmt.Printf("%v", c) //<nil>
    r := <-c
    fmt.Println(r)
}
//<nil>fatal error: all goroutines are asleep - deadlock!
//goroutine 1 [chan receive (nil chan)]:

未初始化channel关闭

func main() {
    var c chan int      //只声明,没有初始化
    fmt.Printf("%v", c) //<nil>
    close(c)
}
//<nil>panic: close of nil channel

对关闭的channel进行读写

  • 对关闭的channel进行写会导致panic
func main() {
    var c chan int = make(chan int, 5)
    close(c)
    c <- 1
}
//panic: send on closed channel
  • 对关闭的channel进行写读分两种情况:管道关闭时还有元素,和没有元素了
func main() {
    var c chan int = make(chan int, 5)
    c <- 1
    c <- 2
    close(c)
    r, ok := <-c
    fmt.Printf("%v, %v\n", r, ok)
    r, ok = <-c
    fmt.Printf("%v, %v\n", r, ok)
    r, ok = <-c
    fmt.Printf("%v, %v\n", r, ok)
}
​
//1, true
//2, true
//0, false

内存逃逸

在函数中申请的临时变量会被存放在栈中,如下

func f(){
    t := make([]int, 5)
}

但是如果将申请的临时变量作为返回值返回,编译器会认为在退出函数之后还有其他地方引用,这是会在堆里进行申请,如下

func f() []int{
    t := make([]int, 5)
    ...
    return t
} 

分配到堆中,栈不会自动清理,并且会引起Go的垃圾回收机制(占用较大的开销)

编译器会根据变量是否被外部引用来决定是否逃逸。如果函数外部没有引用,优先放到栈中;否则必定放到堆中

编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。

注意go是在编译阶段确定的逃逸,而不是在运行时

逃逸的几种情况

  • 情况1,指针逃逸

在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。

func f() []int {
    arr := []int{1, 2}
    return arr
}
func main() {
    r := f()
    fmt.Println(r)
}
​
​
PS E:\go-work> go build -gcflags=-m .\lab3.go
# command-line-arguments
.\lab3.go:5:6: can inline f
.\lab3.go:10:8: inlining call to f
.\lab3.go:11:13: inlining call to fmt.Println
.\lab3.go:6:14: []int{...} escapes to heap
.\lab3.go:10:8: []int{...} escapes to heap
.\lab3.go:11:13: ... argument does not escape
.\lab3.go:11:14: r escapes to heap
  • 情况2,interface{}动态类型逃逸

空接口即 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。

比如fmt.Println函数参数

func Println(a ...interface{}) (n int, err error)
PS E:\go-work> go build -gcflags=-m .\lab3.go
# command-line-arguments
.\lab3.go:5:6: can inline main
.\lab3.go:6:13: inlining call to fmt.Println
.\lab3.go:6:13: ... argument does not escape
.\lab3.go:6:14: "hello,123" escapes to heap
  • 情况3,栈空间不足

对Go编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的Go版本的大小限制可能不一样

  • 情况4,闭包

闭包访问的函数内的外部变量也会被分配(逃逸)到堆上

内存逃逸总结

堆上分配内存比栈上分配内存开销大

不要盲目使用变量的指针做为函数的返回值,虽然会减少复制操作,但是内存的回收会造成更大的开销

内存泄漏

由于疏忽或错误造成程序未能释放已经不再使用的内存

Go内存泄漏有两种情况

  • 僵尸goroutine中分配的内存对象无法被回收
  • 全局数据(生命周期和程序运行周期一样)引用了本应该被释放的对象

goroutine泄漏的场景

  • 情况1,从channel里读数据,但是没有写操作

如下leak函数中的goroutine会被锁死

func leak() {
     ch := make(chan int)
     go func() {
        val := <-ch
        fmt.Println("received value:", val)
    }()
}
  • 情况2,向已满的channel写数据,但是没有读操作
  • 情况3,select操作在所有case上阻塞

比如没有对channel进行close操作

func fibonacci(c, quit chan int)  {
  x, y := 0, 1
  for{
    select {
    case c <- x:
      x, y = y, x+y
    case <-quit:
      fmt.Println("quit")
      return
    }
  }
}
​
func main() {
  c := make(chan int)
  quit := make(chan int)
​
  go fibonacci(c, quit)
​
  for i := 0; i < 10; i++{
    fmt.Println(<- c)
  }
​
  // close(quit)
}
  • 情况5,goroutine死循环
func f(){
    for{
        //...
    }
}