目录

面试题精选--Golang篇

总结一些常考的 Golang 面试题,相关资料皆从网络上收集


切片 - Slice

同一 slice 上的切片其底层数组是同一个吗

同一切片上的切片共享相同的底层数组。在 Go 中,切片是对数组的一个引用,而不是值的拷贝。当你对一个切片进行切片操作时,产生的新切片将仍然引用相同的底层数组,而不会创建新的底层数组。这意味着,无论你如何对切片进行切割或修改,底层数组都会相应地被修改。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// PASS
func TestSlice1(t *testing.T) {
	s := []int{1, 2, 3, 4, 5}

	s1 := s[:]
	s2 := s[1:4]

	s[0], s[2] = 100, 99

	assert.True(t, s1[0] == 100)
	assert.True(t, s1[2] == 99)
	assert.True(t, s2[1] == 99)
}

append 操作返回的底层数组会变吗

在使用 append 函数向切片中添加元素时,如果当前切片容量不足以容纳新元素,append 函数会分配一个新的底层数组,将原切片中的元素复制到新的数组中,并在新的数组中添加新元素。这意味着 append 操作可能会返回一个指向不同底层数组的新切片。

1
2
3
4
5
6
7
// PASS
func TestSlice2(t *testing.T) {
	s1 := []int{1, 2, 3}
	s2 := append(s1, 4)
	s1[0] = 100
	assert.True(t, s1[0] != s2[0])
}

当切片容量足够时,append 函数会原地修改原切片,因此返回的切片仍然指向相同的底层数组。在这种情况下,原切片可能会被修改,但它仍然引用相同的底层数组。

如果对 slice 中的元素取指针,放到一个新的数组中,新数组中的值是什么样的

考察对 for...range... 的了解。

对于for _, v := range array,每次都会对 v 进行赋值,所以最后取到的都是变量 v 的地址。

可通过下列两种方式获得所有元素的地址:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 方法一:局部变量拷贝v,也可以用其他变量名
for i, v := range nums {
    v := v
    pointers[i] = &v
}

// 方法二:直接通过下标获取
for i := range nums {
    pointers[i] = &nums[i]
}

映射 - Map

Map 是并发安全的吗

不是,Map 不并发安全,如果同时写同一个 Map,会造成 panic

通道 - Channel

有缓冲与无缓冲的 Channel 之间的区别

主要体现在两个方面:阻塞行为和并发性能。

  1. 缓冲 channel:

    • 缓冲 channel 允许在发送数据时,如果 channel 中还有可用的缓冲区,则发送操作不会被阻塞,直接将数据发送到缓冲区中。
    • 在接收数据时,如果 channel 中有数据可用,则接收操作不会被阻塞,直接从缓冲区中读取数据。
    • 当 channel 中的缓冲区满了时,发送操作会阻塞直到有其他 goroutine 从 channel 中接收数据,腾出缓冲区空间。
    • 同样地,当 channel 中的缓冲区为空时,接收操作会阻塞直到有其他 goroutine 向 channel 中发送数据。
  2. 无缓冲 channel:

    • 无缓冲 channel 发送数据时,发送操作会阻塞直到有其他 goroutine 准备好接收数据。
    • 接收数据时,接收操作会阻塞直到有其他 goroutine 发送数据到 channel 中。
    • 无缓冲 channel 的发送和接收操作是同步的,它们会导致发送和接收两个 goroutine 同时被阻塞,直到它们能够匹配到对应的操作。
    • 无缓冲 channel 通常用于同步 goroutine 之间的通信,保证数据的可靠传输和同步。

上下文 - Context

什么是 Context

在 Go 语言中,context.Context 是一个用于在 Goroutine 之间传递请求相关值、取消信号和截止时间的标准方式。它主要用于控制 Goroutine 的生命周期,以及在并发环境下进行请求处理、超时控制和取消操作等。

context.Context 的核心方法包括:

  1. context.Background():返回一个空的 Context,用作父 Context,通常在整个请求的生命周期中作为顶级的 Context 使用。

  2. context.WithCancel(parent):返回一个带有取消信号的 Context 和一个取消函数。当调用取消函数时,该 Context 及其所有子 Context 都会收到取消信号。

  3. context.WithDeadline(parent, deadline):返回一个带有截止时间的 Context。当到达截止时间时,该 Context 及其所有子 Context 都会收到取消信号。

  4. context.WithTimeout(parent, timeout):返回一个带有超时时间的 Context。当超过指定的超时时间时,该 Context 及其所有子 Context 都会收到取消信号。

  5. context.WithValue(parent, key, value):返回一个带有请求相关值的 Context。这些值可以在 Goroutine 之间传递,但不应该用于传递可选参数。

Context 的值应该是请求范围内的元数据,例如请求 ID、用户身份验证信息等。不应该用于传递可选参数,而应该通过函数参数传递。

context.Context 的主要用途包括:

  • 取消信号传递:用于在 Goroutine 中传递取消信号,以便在需要时取消处理。
  • 超时控制:用于在一定时间内进行请求处理,并在超时时取消处理。
  • 请求范围值传递:用于在 Goroutine 之间传递请求相关值,例如请求 ID、用户身份验证信息等。

使用 context.Context 可以有效地控制 Goroutine 的生命周期,避免资源泄漏和 Goroutine 泄漏,并实现更健壮的并发程序。

并发模型 - Goroutine 与 GMP

什么是协程泄漏

协程泄露是指在使用协程时,由于某些原因导致协程无法被及时回收和释放,从而占用了系统资源而无法被重复利用的情况。协程泄露可能会导致内存泄露或者系统资源耗尽等问题,影响程序的性能和稳定性。

常见引起协程泄露的原因包括:

  1. 未关闭通道(Channel): 当一个协程向通道发送数据后,如果没有其他协程接收数据,通道将会一直阻塞,导致该协程无法退出,从而产生协程泄露。因此,在使用通道时需要确保及时关闭通道以释放协程。

  2. 循环引用: 如果协程持有某些资源,而这些资源又持有了对协程的引用,形成了循环引用,那么即使协程不再需要这些资源,也无法被回收,从而导致协程泄露。这种情况下,需要仔细检查资源的生命周期,确保适时释放资源。

  3. 阻塞操作: 协程在执行过程中可能会发生阻塞,例如等待 I/O 操作完成或者等待锁释放等,如果阻塞时间过长或者无法及时结束,可能会导致协程泄露。需要谨慎设计协程的执行逻辑,避免长时间的阻塞操作。

  4. 未处理异常: 如果协程发生了未捕获的异常,并且没有恢复或处理这些异常,那么协程可能会被永久性地终止而无法被回收,从而导致泄露。

什么是 GMP 模型

https://picx-img.pjmcode.top/image.2dog450tyj.webp

  • Goroutine(Go 协程): Goroutine 是 Go 语言中的轻量级线程,它由 Go 运行时(runtime)调度和管理。Goroutine 可以看作是执行并发任务的独立单位,相较于传统的线程,Goroutine 的创建和切换成本非常低,因此可以高效地支持大量的并发任务。
  • M(线程): M 代表着操作系统的线程(machine thread)。每个 M 都会关联一个线程,它负责执行 Goroutine。M 的数量是由 Go 运行时动态管理的,它会根据系统的核心数量等因素动态调整 M 的数量,以充分利用系统资源。
  • P(处理器): P 是一种逻辑处理器,它负责调度和管理 Goroutine。P 的数量通常是固定的,并且与 CPU 核心数有关。P 会将 Goroutine 分配给 M,并负责调度 M 在 CPU 上执行。P 的数量可以通过 Go 语言的 GOMAXPROCS 环境变量来控制。

M 和 P 是一对一的吗

M 和 P 是多对多的关系,一个 P 可以管理多个 M,但一个 M 同一时间只能被一个 P 所关联。

处理器 P 如何管理多个线程 M

  • 本地队列(Local Queue): 每个 P 都有一个本地队列,用于存储被当前 P 管理的 Goroutine。这个队列是针对当前 P 而言的,因此可以高效地进行操作,比如入队和出队。本地队列可以减少锁的竞争,提高并发性能。
  • 全局队列(Global Queue): 所有 P 共享一个全局队列,用于存储没有被任何 P 管理的 Goroutine。当一个 P 的本地队列空了,它可以从全局队列中获取 Goroutine 来执行。全局队列通常会使用锁进行保护,因为多个 P 可能同时竞争全局队列中的 Goroutine。
  • 空闲 M 列表(Idle M List): P 还会维护一个空闲 M 的列表。当 P 的本地队列满了或者被阻塞时,它可以从空闲 M 列表中获取一个空闲的 M 来执行 Goroutine。

P 通过这些队列来动态管理多个 M 的调度和执行,从而实现高效的并发调度。当一个 M 执行完成或者阻塞时,它会回到 P 的管理下,然后再次被分配 Goroutine 执行。

死循环的协程如何调度

  • Preemption(抢占): Go 语言的运行时系统会在一定的时间间隔内(通常几毫秒)进行抢占调度,即使一个协程正在执行一个死循环,也会在一定的时间后将处理器让给其他协程执行。这种机制能够保证其他协程不会因为某个协程的死循环而无法执行。
  • 系统调用或阻塞操作: 如果一个协程在执行死循环时发起了系统调用或者进行了阻塞操作(如等待 IO 完成),那么它会被 Go 运行时系统暂时挂起,直到系统调用完成或阻塞操作解除,从而让其他协程有机会执行。
  • 手动控制: 在一些特殊情况下,可以手动控制死循环协程的执行。例如,可以使用 Go 语言的 select{} 语句结合 time.After 通道来设置一个超时,以便让死循环协程周期性地让出处理器,从而允许其他协程执行。但这种方法需要手动在死循环中加入超时检查的逻辑。

阻塞的协程如何调度

  • 系统调用阻塞: 当一个协程执行系统调用(如文件读取、网络请求等)而导致阻塞时,Go 调度器会将该协程暂停,并尝试调度其他可运行的协程来继续执行。当系统调用完成后,阻塞的协程会重新被调度执行。
  • 通道阻塞: 当一个协程试图发送或接收数据到或从一个无缓冲通道时,如果通道中没有对应的接收方或发送方,该协程会被阻塞。在这种情况下,Go 调度器会暂停被阻塞的协程,并尝试调度其他可运行的协程执行,直到通道操作可以完成。
  • 等待组(WaitGroup)阻塞: 当主协程等待一组其他协程完成时,通常会使用 sync.WaitGroup 进行同步。在这种情况下,主协程会调用 WaitGroup.Wait() 方法阻塞等待,直到所有其他协程完成并调用 WaitGroup.Done() 通知完成。Go 调度器会在这种情况下暂停主协程,并继续执行其他可运行的协程。

垃圾回收 - GC

Go 中的垃圾回收是怎样的

Go 语言的垃圾回收(Garbage Collection,GC)是一种自动管理内存的机制,它负责在程序运行时自动识别并释放不再使用的内存对象,以避免内存泄漏和提高内存利用率。

以下是 Go 语言的垃圾回收的一些特点和工作原理:

  1. 并发标记清除: Go 语言的垃圾回收器采用了并发标记清除(Concurrent Mark and Sweep)算法。在标记阶段,垃圾回收器会从根对象开始,遍历程序中的对象图,并标记出所有可达的对象。在清除阶段,垃圾回收器会扫描堆中的对象,清除所有未被标记的对象。这个过程是并发执行的,不会阻塞用户程序的执行。

  2. 三色标记法: Go 语言的垃圾回收器使用了三色标记法,将对象分为三种状态:白色、灰色和黑色。白色表示对象未被访问,灰色表示对象被标记但其子对象还未被标记,黑色表示对象及其子对象已被标记。在标记阶段,垃圾回收器通过遍历对象图,将对象从白色变为灰色,并将其子对象加入标记队列。在清除阶段,垃圾回收器清除所有白色对象。

  3. 分代回收: Go 语言的垃圾回收器采用了分代回收策略,将堆中的对象分为几个代(generation)。新分配的对象会被分配到年轻代,经过多次垃圾回收后仍然存活的对象会被晋升到老年代。由于年轻代的对象生命周期较短,因此可以采用更频繁的垃圾回收策略,而老年代的对象则采用更慢的垃圾回收策略,以提高性能。

  4. 写屏障: Go 语言的垃圾回收器使用写屏障技术,记录对象的写入操作,以便在垃圾回收过程中正确识别对象的引用关系。写屏障会在写入指针时触发,将被修改的对象标记为灰色,并将新写入的指针加入标记队列。

三色标记的过程

三色标记法是一种用于标记-清除(mark-sweep)垃圾回收算法的优化技术,它将对象的标记状态分为三种颜色:白色、灰色和黑色。以下是三色标记法的基本过程:

  1. 初始标记(Initial Mark): 在初始标记阶段,垃圾回收器从根对象开始,标记所有直接可达的对象为灰色,并将它们加入标记队列。根对象通常是全局变量、栈上的变量以及活跃的协程等。

  2. 并发标记(Concurrent Mark): 在并发标记阶段,垃圾回收器并发地遍历堆中的对象图,标记所有可达的对象为灰色,并将其子对象加入标记队列。如果发现新的灰色对象,将其标记为黑色,并将其子对象加入标记队列。

  3. 重新标记(Re-Mark): 在并发标记过程中,程序可能会产生新的对象,这些新对象可能会在标记过程中被修改,因此需要重新标记。重新标记阶段会对之前标记的所有灰色对象进行重新扫描,将其子对象加入标记队列,并标记为黑色。

  4. 清除(Sweep): 在清除阶段,垃圾回收器扫描整个堆,清除所有未被标记的白色对象,并将它们释放回内存池。清除过程是并发执行的,不会阻塞程序的执行。

其他

Go 中的 make 和 new 的区别

在 Go 语言中,makenew 都是用于创建数据结构的内置函数,但它们的使用场景和作用不同。

  1. make:

    • make 函数主要用于创建 slice、map 和 channel 这三种引用类型的数据结构,并且返回的是一个初始化之后的数据结构。
    • 例如,使用 make 函数创建一个长度为 5 的整型 slice:s := make([]int, 5)
    • make 函数的签名为:func make(t Type, size ...IntegerType) Type,其中 t 表示数据结构的类型,size 表示数据结构的大小或者容量(对于 slice 和 channel)。
  2. new:

    • new 函数用于创建某种类型的指针,并且返回该类型的指针的零值,即指向该类型的新分配的零值的指针。
    • 例如,使用 new 函数创建一个整型指针:p := new(int)
    • new 函数的签名为:func new(Type) *Type,其中 Type 表示要创建的类型。

总的来说,make 用于创建引用类型的数据结构,返回的是一个已初始化的数据结构;而 new 用于创建某种类型的指针,返回的是该类型的零值的指针。

在 defer 中修改了局部变量并 return,返回值为类型和(变量+类型)两种情况下会返回什么

在 Go 中,defer 语句中对局部变量的修改对函数的返回值没有影响。无论函数返回值是单个变量还是多个变量(包括变量及其类型),在 defer 中对局部变量的修改不会影响函数返回值。这是因为 Go 在函数返回时会将函数的返回值保存在栈中,并不会受到 defer 中对变量的修改影响。