go问题

1. 基础篇

1. 如何高效的拼接字符串

  • Go 语言中,字符串是只读的,也就意味着每次修改操作都会创建一个新的字符串。如果需要拼接多次,应使用 strings.Builder,最小化内存拷贝次数。

    var str strings.Builder
    for i := 0; i < 1000; i++ {
        str.WriteString("a")
    }
    fmt.Println(str.String())

2. Go 支持默认参数或可选参数吗

  • 都不支持

3. defer 的执行顺序

  • 栈型,先进后出

4. Go 语言 tag 的用处

  • tag 可以理解为 struct 字段的注解,可以用来定义字段的一个或多个属性。框架/工具可以通过反射获取到某个字段定义的属性,采取相应的处理方式。tag 丰富了代码的语义,增强了灵活性。

  • 下面这个例子使用 tag 定义了结构体字段与 json 字段的转换关系,Name -> stu_name, ID -> stu_id,忽略 Age 字段。很方便地实现了 Go 结构体与不同规范的 json 文本之间的转换。

    	package main
    
    import "fmt"
    import "encoding/json"
    
    type Stu struct {
      Name string `json:"stu_name"`
      ID   string `json:"stu_id"`
      Age  int    `json:"-"`
    }
    
    func main() {
      buf, _ := json.Marshal(Stu{"Tom", "t001", 18})
      fmt.Printf("%s\n", buf)
    }

5. 如何判断 2 个字符串切片(slice) 是相等的

  • 遍历一个个比较

  • 通过反射reflect.DeepEqual(a, b),但是性能较低,不推荐。

6. 字符串打印时,%v 和 %+v 的区别

  • %v 和 %+v 都可以用来打印 struct 的值,区别在于 %v 仅打印各个字段的值,%+v 还会打印各个字段的名称。

7. Go 语言中如何表示枚举值(enums)

  • 通常使用常量(const) 来表示枚举值。

8. 空 struct{} 的用途

  • 使用空结构体 struct{} 可以节省内存,一般作为占位符使用,表明这里并不需要一个值。

  1. 比如使用 map 表示集合时,只关注 key,value 可以使用 struct{} 作为占位符。如果使用其他类型作为占位符,例如 int,bool,不仅浪费了内存,而且容易引起歧义。

  2. 使用信道(channel)控制并发时,我们只是需要一个信号,但并不需要传递值,这个时候,也可以使用 struct{} 代替。

  3. 声明只包含方法的结构体。

9. map多键索引

  • map类型的key可以是个对象,支持对对象中每个字段的多键索引。

2. 进阶(实现原理)

1. init() 函数是什么时候执行的

  1. Go 程序初始化先于 main 函数,由 runtime 初始化每个导入的包,初始化顺序不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。

  2. 每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的 init() 函数。

  3. 同一个包,甚至是同一个源文件可以有多个 init() 函数。init() 函数没有入参和返回值,不能被其他函数调用,同一个包内多个 init() 函数的执行顺序不作保证。

  4. 执行顺序:import –> const –> var –> init() –> main()。其中不被依赖的包的init()最先执行,同一个包中的多个init()执行顺序没有保证。

2. Go 语言的局部变量分配在栈上还是堆上(逃逸分析)

  1. 由编译器决定。Go 语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有超出函数范围,就可以在栈上,反之则必须分配在堆上。

3. 2 个 interface 可以比较吗

  1. Go 语言中,interface 的内部实现包含了 2 个字段,类型 T 和 值 V,interface 可以使用 == 或 != 比较。2 个 interface 相等有以下 2 种情况:

    1. 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)

    2. 类型 V 相同,且对应的值 V 相等。

4. 接口与非接口的比较

  1. 接口(interface) 是对非接口值(例如指针,struct等)的封装,内部实现包含 2 个字段,类型 T 和 值 V。一个接口等于 nil,当且仅当 T 和 V 处于 unset 状态(T=nil,V is unset):

    • 两个接口值比较时,会先比较 T,再比较 V。

    • 接口值与非接口值比较时,会先将非接口值尝试转换为接口值,再比较。

  2. 下面这个例子中,将一个 nil 非接口值 p 赋值给接口 i,此时,i 的内部字段为(T=*int, V=nil),i 与 p 作比较时,将 p 转换为接口后再比较,因此 i == p,p 与 nil 比较,直接比较值,所以 p == nil。但是当 i 与 nil 比较时,会将 nil 转换为接口 (T=nil, V=nil),与i (T=*int, V=nil) 不相等,因此 i != nil。因此 V 为 nil ,但 T 不为 nil 的接口不等于 nil。

5. Go 语言GC(垃圾回收)的工作原理

  1. 最常见的垃圾回收算法有标记清除(Mark-Sweep) 和引用计数(Reference Count),Go 语言采用的是标记清除算法。并在此基础上使用了三色标记法和写屏障技术,提高了效率。

  2. 标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:

    1. 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象

    2. 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表

  3. 标记清除算法的一大问题是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。为了能够异步执行,减少 STW 的时间,Go 语言采用了三色标记法。

  4. 三色标记法:

    1. 三色标记算法将程序中的对象分成白色、黑色和灰色三类。

      • 白色:不确定对象。

      • 灰色:存活对象,子对象待处理。

      • 黑色:存活对象。

    2. 标记开始时,所有对象加入白色集合(这一步需 STW )。首先将根对象标记为灰色,加入灰色集合,垃圾搜集器取出一个灰色对象,将其标记为黑色,并将其指向的对象标记为灰色,加入灰色集合。重复这个过程,直到灰色集合为空为止,标记阶段结束。那么白色对象即可需要清理的对象,而黑色对象均为根可达的对象,不能被清理。

    3. 三色标记法因为多了一个白色的状态来存放不确定对象,所以后续的标记阶段可以并发地执行。当然并发执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative(假阴性)的算法。

    4. 三色标记法并发执行仍存在一个问题,即在 GC 过程中,对象指针发生了改变。比如下面的例子:正常情况下,D 对象最终会被标记为黑色,不应被回收。但在标记和用户程序并发执行过程中,用户程序删除了 C 对 D 的引用,而 A 获得了 D 的引用。标记继续进行,D 就没有机会被标记为黑色了(A 已经处理过,这一轮不会再被处理)。

    1. 为了解决这个问题,Go 使用了内存屏障技术。

  5. 内存屏障技术:

  6. 它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,类似于一个钩子。垃圾收集器使用了写屏障(Write Barrier)技术,当对象新增或更新时,会将其着色为灰色。这样即使与用户程序并发执行,对象的引用发生改变时,垃圾收集器也能正确处理了。

  7. 一次完整的 GC 分为四个阶段:

    1. 标记准备(Mark Setup,需 STW),打开写屏障(Write Barrier)

    2. 使用三色标记法标记(Marking, 并发)

    3. 标记结束(Mark Termination,需 STW),关闭写屏障。

    4. 清理(Sweeping, 并发)

6. 函数返回局部变量的指针是否安全

  1. 这在 Go 中是安全的,Go 编译器将会对每个局部变量进行逃逸分析。如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,而是分配在堆上。

7. 非接口的任意类型 T都能够调用 *T 的方法吗?反过来呢?

  1. 一个T类型的值可以调用为T类型声明的方法,但是仅当此T的值是可寻址(addressable) 的情况下。编译器在调用指针属主方法前,会自动取此T值的地址。因为不是任何T值都是可寻址的,所以并非任何T值都能够调用为类型T声明的方法。

  2. 反过来,一个T类型的值可以调用为类型T声明的方法,这是因为解引用指针总是合法的。事实上,你可以认为对于每一个为类型 T 声明的方法,编译器都会为类型T自动隐式声明一个同名和同签名的方法。

  3. 哪些值是不可寻址的

    1. 字符串中的字节;

    2. map 对象中的元素(slice 对象中的元素是可寻址的,slice的底层是数组);

    3. 常量;

    4. 包级别的函数等。

  4. 举一个例子,定义类型 T,并为类型 *T 声明一个方法 hello(),变量 t1 可以调用该方法,但是常量 t2 调用该方法时,会产生编译错误。

8. go 在defer中修改return的内容会生效吗

  1. 执行顺序:defer在return赋值之后

  2. 分为无名返回值、有名返回值、返回指针

  3. 如果是无名返回值,在defer中的修改不影响返回值,有名返回值和返回指针,return的内容会修改

9. append扩容

  1. 切片append()的时候,如果容量不够会扩容,重新分配空间,所以指针地址就变化了,在函数中修改slice显得外面没有变,应该返回这个新的slice,不过它复制的是指针地址,所以开销也不大

10. 协程:

  1. 协程是程序层面的,不由操作系统控制。比如python的yield

  2. 线程调度是这个线程让出cpu使用权,下次还能抢到。协程是程序真正的暂停,等到对方执行完自己才会继续执行。

  3. 一个线程里可以有多个协程,一个协成阻塞了,就全部阻塞了。但是goruntine通过一个逻辑处理器解决了这个问题。

11. Go语言逻辑处理器(GPM)

  1. 每个线程有一个逻辑处理器,一个逻辑处理器管理很多协程,如果这个协程需要阻塞(比如IO操作),就把这个协程从逻辑处理器中剔除,新建一个线程管理这个逻辑处理器。

  2. Go也有一个goruntime来进行内存管理垃圾回收等操作,只是生成可执行文件的时候包含进去了,所以可执行文件比较大。

3. 并发

1. 无缓冲的 channel 和 有缓冲的 channel

  1. 对于无缓冲的 channel,发送方将阻塞该信道,直到接收方从该信道接收到数据为止,而接收方也将阻塞该信道,直到发送方将数据发送到该信道中为止。

  2. 对于有缓存的 channel,发送方在没有空插槽(缓冲区使用完)的情况下阻塞,而接收方在信道为空的情况下阻塞。

2. 什么是协程泄露(Goroutine Leak)

  1. 协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。

  2. 常见的导致协程泄露的场景有以下几种:

    1. 缺少接收器,导致发送阻塞

    2. 缺少发送器,导致接收阻塞

    3. 死锁(dead lock)

    4. 无限循环(infinite loops)

3. Go 限制运行时操作系统线程的数量

  1. 可以使用环境变量 GOMAXPROCS 或 runtime.GOMAXPROCS(num int) 设置。

    • runtime.GOMAXPROCS(1) // 限制同时执行Go代码的操作系统线程数为 1

  2. GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。

最后更新于

这有帮助吗?