面试:Go语言常见问题

通用

与其他语句比较

Go语言相较于其他编程语言的几个关键特点和优势

解析
  1. 并发支持:Go内置了对并发的支持,通过goroutines和channels这两个核心概念。goroutines是轻量级线程,启动成本极低,使得并发处理大量任务成为可能,而channels则用于goroutines之间的安全通信,避免了共享内存的竞态条件问题,让并发编程既强大又易于理解。

  2. 没有继承:没有传统意义上的类继承机制。在诸如Java或C++这类语言中,类继承允许一个类(子类)继承另一个类(父类)的属性和方法,形成层次结构。然而,Go语言设计时有意避开了这种继承体系,转而采用了更简单的组合来实现复用。

  3. 实现interface不需要显式声明(Duck Typing):Go语言的接口它遵循鸭子类型(Duck Typing)的原则。也就是说,一个类型是否实现了某个接口,不是看它是否显式声明实现了该接口,而是看它是否“看起来像”那个接口,即是否具有接口所要求的所有方法。这样,就不需要在类型定义时指定实现了哪些接口,增加了灵活性和代码的简洁性。

  4. 没有异常处理(Error is value):在Go中,错误处理不是通过异常(exception)机制,而是通过返回值来实现的。

  5. 基于首字母的可访问特性:Go中,标识符(如变量、函数名)的可访问性由其首字母的大小写决定。如果标识符名字的第一个字母是大写的,那么它在包外是可访问的(导出),相当于公有(public);如果是小写,则只能在同一个包内访问,相当于私有(private)。这种简单的规则使得包的API设计直观且易于理解。

  6. 不用的import或者变量引起编译错误:Go编译器强制要求代码中所有的import语句和声明的变量、常量、函数等必须被使用。如果存在未使用的导入包或声明,编译时就会报错。这一策略有助于避免无用代码的累积,促使开发者写出更加干净、紧凑的代码,同时也减少了潜在的依赖问题。

  7. 强一致类型:每个变量、常量、函数参数和返回值都有明确的类型,而且不会进行隐式转换。

  8. 内存管理:Go语言提供了自动垃圾回收机制,减少了手动管理内存带来的复杂性和潜在错误,同时它的垃圾回收器设计得较为高效,尽量减小了运行时的性能影响。(与C/C++的区别)

  9. 代码格式化工具:Go自带了go fmt工具,可以自动格式化代码,确保团队间的代码风格统一,减少了因格式问题引起的代码审查冲突。

  10. 函数多返回值:Go支持函数存在多个返回值。C、C++、Java都不支持。

    Python:支持多返回值,实际上是通过返回一个元组(tuple)来实现的。在调用时,直接将这个元组解包为多个独立的变量,使得使用起来非常直观和方便。

    Rust:也支持通过 Tuple 返回多个值。

内存相关

内存布局的几个区域

解析

Go语言的内存布局主要可以划分为以下几个区域:

  1. 栈(Stack):

    • 存放函数调用时的局部变量、函数参数、返回地址等。
    • 由编译器自动分配和释放,生长方向依赖于平台,通常在高地址向低地址增长。
    • 栈的大小相对固定,且分配速度较快。
  2. 堆(Heap):

    • 用于动态分配的内存区域,存储程序运行时创建的对象。
    • 堆内存由Go的运行时管理系统进行分配和回收,实现了自动垃圾回收(GC)机制。
    • 在Go 1.10之后的版本中,堆被进一步细分为不同的管理区域,如:
      • arena:真正存放对象的大块内存区域,被分割成多个8KB大小的页(mspan)。
      • spans:管理arena中的内存页,跟踪哪些页是已分配或空闲的。
      • bitmap:用来标记arena中哪些地址保存了对象以及对象的一些属性,如是否包含指针、GC标记信息等。
  3. 代码区(Text Segment/Code Segment):

    • 存储程序的机器指令,是只读的,以防止程序意外修改。

      程序的机器指令:这是编译后生成的二进制代码,包含了程序执行的实际操作序列,如函数的实现、跳转指令等。

      只读的常量数据:例如,字符串字面量、编译时常量表达式的结果等。这些数据与程序代码一样,不需要在程序运行时修改,因此也被放置在代码区。

      静态数据中的只读部分:某些静态初始化的数据,如果被标记为只读(例如,const关键字定义的数据),也可能存储在这里。

    • 通常这部分是共享的,因为多个进程如果执行相同的代码,可以映射到同一段物理内存。

  4. 全局区(Data Segment):

    • 分为初始化数据区(data segment)和未初始化数据区(bss segment)。
      • 初始化数据区存储了程序中已初始化的全局变量和静态变量。
      • 未初始化数据区存储了未初始化的全局变量和静态变量,初始化时通常默认为零值。
  5. 内存对齐和填充:

    • 结构体和其它复合类型在内存中的布局需遵循内存对齐规则,以优化访问速度。
    • 这可能导致额外的内存填充(padding),以确保各字段起始地址满足对齐要求。

这些区域共同构成了Go程序运行时的内存布局,不同的数据根据其生命周期和访问特性被安排在最适合的区域中。

逃逸分析

在Golang中,如何理解和避免内存逃逸?请举例说明。

如何识别和避免内存逃逸现象以提高程序性能?

解析

参考:Go内存分配和逃逸分析-理论篇

在Go语言中,“逃逸分析"是指编译器确定一个变量是否需要分配在堆上而不是栈上的过程。如果一个变量的地址被取到并可能在函数作用域之外使用,或者其生命周期超出了定义它的函数范围,那么这个变量就会发生逃逸。

这一决策对程序的性能有着直接的影响,因为栈上的内存分配和回收比堆上的操作更为高效。

栈与堆的基础概念

  • 栈(Stack):栈内存用于存储函数调用时的局部变量。每当函数被调用时,会为该函数分配一个新的栈帧,用于存放局部变量和一些函数调用信息。当函数执行完毕并返回时,栈帧会被自动回收,因此栈上的内存管理高效且快速。

  • 堆(Heap):堆内存用于动态分配的对象,其生命周期不由函数调用控制。堆上的内存管理较为复杂,需要垃圾回收(Garbage Collection, GC)机制来回收不再使用的内存,这相比栈上的自动管理来说效率较低。

逃逸分析的目的

逃逸分析的主要目的是减少堆上分配的对象数量,从而:

  • 提高程序性能:栈上分配的内存访问更快,且无需垃圾回收。

  • 减轻垃圾回收压力:减少堆分配可以降低GC的频率和负担。

  • 避免悬挂指针:通过精确控制对象的生命周期,减少潜在的安全问题。

    “悬挂指针”(Dangling Pointer)指的是一个指针变量仍然指向一块已经被释放或者重新分配的内存区域。

逃逸分析的规则

逃逸分析基于一系列规则来判断变量是否会发生逃逸,以下是一些基本规则:

  1. 编译期无法确定的参数类型:如interface。
  2. 返回值逃逸:如果一个函数返回一个变量的地址或者一个包含变量地址的复合类型(如切片、map、channel、interface等),那么该变量会逃逸到堆上,因为它可能被外部函数继续使用。
  3. 指针传递逃逸:当一个变量的地址被作为参数传递给其他函数,并且在该函数中被存储起来以供后续使用,该变量也会逃逸到堆上。
  4. 全局变量引用:如果一个局部变量被全局变量引用,它也会逃逸到堆上。
  5. 大对象:即使没有明显的逃逸行为,如果一个局部变量的大小超过了编译器设定的阈值,也可能被分配到堆上,以避免栈溢出。
  6. 无法确定的生命周期:如果编译器无法确定一个变量在其所在函数返回后是否还会被使用,为了安全起见,该变量会被分配到堆上。
  7. 闭包:导致局部变量的生命周期被延长,不会随着函数的调用结束而被释放。

发生逃逸的示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 示例1:编译期无法确定的参数类型
func test1() {
    a := 10
    fmt.Println(a) // 这里a发生了逃逸分析,因为Println函数的参数类型为any
}
// 示例2:返回变量的地址
func test2() *int {
    a := 10
    return &a // 这里返回了变量的地址,所以也会发生逃逸
}
// 示例3:返回了包含变量地址的复合类型
func test3() []int {
    return []int{1, 2, 3} // 返回了包含变量地址的复合类型(如切片、map、channel、interface等),也会发生逃逸
}
// 示例4:指针传递
type Test4 struct {
    data int
}
func test4(t4 *Test4) {
    t4.data = 10 // 这里不会发生逃逸
}
func test4_1(t4 *Test4) {
    var globalList []*Test4
    globalList = append(globalList, t4) // 2024.06.15 这里t4会发生逃逸,原因暂时我也不知道。
}
// 示例5:大对象
func test5() {
    arr := make([]int, 10000)
    for i := 0; i < 10000; i++ {
       arr[i] = i
    }
}
// 示例6:变量大小不确定
func test6() {
    l := 1
    arr := make([]int, l) // 编译时,认为l是不确定的值
    for i := 0; i < l; i++ {
       arr[i] = i
    }
}
// 示例7:闭包
func test7() func() int {
    i := 10
    return func() int { // 闭包,导致局部变量的生命周期被延长,不会随着函数的调用结束而被释放
       return i + 1
    }
}

实践中的应用

可以通过编译选项(如 go build -gcflags="-m")查看哪些变量发生了逃逸,从而对代码进行优化。虽然Go语言的编译器负责逃逸分析,但了解其原理有助于编写更高效的代码,比如尽量避免不必要的逃逸,通过值拷贝而非引用传递等方式。

对于动态new出来的局部变量,go语言编译器也会根据是否有逃逸行为来决定是分配在堆还是栈,而不是直接分配在堆中。

参考:go变量分配在栈上还是堆上

有些场景下我们不应该传递结构体指针,而应该直接传递结构体。

为什么会这样呢?虽然直接传递结构体需要值拷贝,但是这是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。

当然这种做法不是绝对的,要根据场景去分析:

  • 如果结构体较大,传递结构体指针更合适,因为指针类型相比值类型能节省大量的内存空间
  • 如果结构体较小,传递结构体更适合,因为在栈上分配内存,可以有效减少GC压力

GMP

Goroutine

解析

Goroutine是Go语言特有的并发执行单元,它是Go运行时(runtime)管理的轻量级线程,它的调度是在用户空间而不是内核空间,调度比较灵活。

简单的来说就是:1.占用内存更小(几kb);2.调度更灵活(runtime调度)。

通过在函数调用前加上关键字go来创建一个新的Goroutine。

Goroutine的定义与特点

  1. 轻量级线程:Goroutine是一种比传统线程更轻量级的执行单元。它占用的内存更少,创建和销毁的开销也更小。

  2. 由Go运行时管理:Goroutines的调度和管理由Go的运行时系统负责,而不是由操作系统直接管理。

  3. 高效并发:Goroutines支持大规模并发处理,一个Go程序可以创建数以千计甚至数以万计的goroutines,而不会引起明显的性能开销。

  4. 动态栈管理:Goroutines的栈空间可以根据需要动态地伸缩,初始时栈很小,随着需要可以增长到数MB。

Goroutine的同步与等待

  1. 同步机制:Go语言提供了多种同步机制,如channel、WaitGroup、Mutex等,用于协调Goroutines的执行。

  2. sync.WaitGroup:使用sync.WaitGroup可以等待一组Goroutines完成执行。

Goroutine与线程

Goroutine与线程的区别

解析

Goroutine是Go语言特有的并发执行单元,而线程是操作系统层面的执行单元。以下是Goroutine与线程之间的一些主要区别:

  1. 调度机制

    • Goroutine:由Go运行时(runtime)调度,使用一个叫做M:N调度技术,其中M个Goroutines被映射到N个OS线程上。这种调度是在用户态完成的,减少了上下文切换的开销。
    • 线程:由操作系统内核调度,每次调度涉及到用户态与内核态之间的切换,这个切换过程相对较慢。
  2. 资源开销

    • Goroutine:通常只需要几KB的栈空间,而且它们的创建和销毁开销非常小,因为它们是由Go运行时管理的。

      栈空间可以根据需要动态伸缩,初始很小,如果需要更多栈空间,Go运行时会为其分配更多的内存。

    • 线程:通常需要1MB或更多的栈空间,并且创建和销毁线程需要更多的资源和时间,因为它们需要与操作系统交互。

      栈空间通常是固定的,如果需要更多栈空间,必须在创建线程时指定,这可能导致内存浪费。

  3. 轻量级

    • Goroutine:由于其轻量级特性,一个Go程序可以轻松创建数以万计的Goroutines,而不会对系统造成重大压力。
    • 线程:由于较高的资源开销,创建大量线程可能会导致系统资源紧张。
  4. 通信机制

    • Goroutine:Go语言提倡使用channel作为Goroutines之间的通信机制,遵循“通信顺序进程”(CSP)模型。

      CSP(Communicating Sequential Processes)是一种用于描述并发系统的形式化语言,主要强调进程间的通信,使用消息传递而不是共享内存来避免并发中的许多问题,如竞态条件和死锁。

    • 线程:通常使用共享内存和锁来进行通信,这种方式可能导致竞争条件和死锁。

GMP调度器

解析

GMP调度器采用M:N的调度模型,即多个Goroutines(G)可以映射到数量较少的操作系统线程(M)上执行。这种模型充分利用了多核CPU资源,提高了系统的并发处理能力。

GMP的名词概念

  • Goroutine(G):Goroutine是Go语言中的一个基本概念,类似于线程,但比线程更轻量。它们有自己的栈,栈大小初始时只有几KB。

    和线程不同的是,goroutine的调度是在用户空间进行的,减少了上下文切换的成本。

  • Machine(M):M代表了真正的操作系统线程,每个M都由操作系统调度。M是Go并发模型中的执行者,它执行Goroutines(G)中的代码。Go的运行时会尽量复用M,以减少线程的创建和销毁带来的开销。

  • Processor(P):P代表处理器,是Go语言运行时系统中的一个核心组件,负责管理和调度G 到 M 上执行。每个P都有一个本地的运行队列,用于存放待运行的Goroutines。P的数量一般设置为等于机器的逻辑处理器数量,以充分利用多核的优势。

    P的数量可以通过环境变量GOMAXPROCS或运行时函数GOMAXPROCS()进行配置。

    每个P都包含一个本地goroutine队列,是为了减少全局队列的争用。

调度过程

GMP模型有一个全局队列和P的本地队列,用于存放待执行的G。

当新建一个Goroutine时,会优先将其放入P的本地队列中;如果本地队列满了,则将本地队列中的一半Goroutines放入到全局队列中。

  1. 创建G
    • 当使用go关键字启动一个函数时,Go运行时将创建一个新的G对象,并将其放入P的本地队列中。
  2. 本地队列处理
    • 如果P的本地队列未满,G将被放入队列中等待执行。
    • 如果本地队列已满,G将被放入全局队列。
  3. 执行G
    • M会与P绑定,并从P的本地队列中获取G来执行。
    • 如果P的本地队列为空,M会尝试从全局队列获取G。
    • 如果全局队列也为空,M将尝试从其他P的本地队列“窃取”G。
  4. G的执行
    • M开始执行G,这个过程是循环的,直到G完成或者发生以下情况:
      • G执行结束,释放M。
      • G发生系统调用,导致M与P解绑。
  5. 系统调用和P的释放
    • 当G进行系统调用时,M会与P解绑,此时P可以选择一个新的M继续执行其他G。
    • 如果没有可用的M,P可能会创建一个新的M或者等待。
  6. 系统调用结束
    • 系统调用完成后,M试图重新找到空闲的P进行绑定。
    • 如果找不到,M可能会将G放回全局队列,并进入休眠状态。
  7. 全局队列的处理
    • 为了防止全局队列中的G长时间得不到调度,每个P会周期性地检查全局队列,并从中调度G。

GMP设计策略

解析

复用线程

避免频繁的创建、销毁线程,而是对线程的复用。

  • work stealing机制

    当本线程无可运行的G时(且全局队列也为空时),尝试从其他线程绑定的P偷取G,而不是销毁线程。

    通过Work Stealing机制来平衡不同P之间的负载,避免某些P过载而其他P空闲。

  • hand off机制

    当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。

利用并行

GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。

抢占

在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死。

全局G队列

在新的调度器中依然有全局G队列,当P的本地队列为空时,优先从全局队列获取,如果全局队列为空时则通过work stealing机制从其他P的本地队列偷取G。

自旋线程

解析

在Go语言中,自旋线程(Spinning Thread)是一种优化手段,用于提高调度器的效率。自旋线程是一种在没有任何可运行goroutine时仍然保持运行状态的线程(M)。它会不断地检查是否有新的goroutine可以运行,而不是进入休眠状态。

以下是关于Go语言中自旋线程的一些关键点:

  1. 什么是自旋?

    • 自旋是指线程在等待新的工作出现时,并不立即进入休眠状态,而是循环检查是否有可运行的任务。
    • 在Go调度器中,自旋线程会重复检查处理器(P)的本地队列和全局队列,以寻找可运行的goroutines。
  2. 为什么需要自旋线程?

    • 当线程进入休眠状态后,唤醒它需要时间,这可能会导致延迟。
    • 自旋可以减少线程从休眠状态恢复的时间,因为它减少了线程状态切换的次数。
  3. 自旋的条件:

    • 当一个线程的本地队列中没有可运行的goroutines,并且全局队列中也没有goroutines时,线程可能会进入自旋状态。
    • 自旋通常有一个时间限制,如果超过这个时间仍然没有找到工作,线程可能会放弃自旋并进入休眠状态。
  4. 自旋的局限性:

    • 自旋线程可能会浪费CPU资源,因为它在等待工作时仍然占用CPU周期。
    • 因此,自旋通常只适用于短时间内能够找到工作的场景。
  5. 自旋线程的管理:

    • Go调度器会管理自旋线程的数量和状态。
    • 如果系统中有很多自旋线程,调度器可能会减少自旋线程的数量,以避免过度占用CPU资源。
  6. 自旋与工作窃取:

    • 自旋线程和工作窃取机制相结合,可以提高goroutines的调度效率。
    • 如果一个自旋线程没有在其本地队列中找到工作,它可能会尝试从其他处理器的队列中窃取goroutines。

通过自旋线程,Go语言旨在平衡调度器的响应速度和CPU资源的使用效率。自旋是一种有用的优化技术,特别是在I/O密集型或低延迟要求的应用程序中,它可以减少线程状态切换的开销,从而提高整体性能。

GC

Go的垃圾回收机制是如何运作的?

解析

GoV1.3:普通标记清除法,整体过程需要启动 STW,效率极低。

GoV1.5:三色标记法, 堆空间启动写屏障(插入屏障和删除屏障),栈空间不启动,全部扫描之后,需要重新扫描一次栈 (需要 STW),效率普通。

GoV1.8:三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要 STW,效率较高。

标记清除法

先启动 STW,使程序暂停,然后对可达对象进行标记,删除不可达对象,最后停止 STW。

标记清除法的缺点:STW 让程序暂停,程序会出现卡顿;标记需要扫描整个堆区。

三色标记法

  1. 每次新创建的对象,默认的颜色都是标记为 “白色”;

  2. 每次 GC 回收开始,然后从根节点开始遍历所有对象,把遍历到的对象从白色集合放入 “灰色” 集合。

  3. 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合。

  4. 重复第三步 , 直到灰色中无任何对象。

现在内存中的数据就只有两种颜色,白色和黑色。黑色是逻辑可达的对象,白色是不可达的对象。

  1. 回收所有的白色标记表的对象,也就是回收垃圾。

为了保证数据的安全,在开始三色标记之前会加上 STW,在扫描确定黑白对象之后会再放开 STW,但这样的效率就太低了。但如果没有 STW,下面两种情况是三色标记法不希望发生的:

  • 条件 1:一个白色对象被黑色对象引用 (白色被挂在黑色下)
  • 条件 2:灰色对象与它之间的可达关系的白色对象遭到破坏 (灰色同时丢了该白色)

“强 - 弱” 三色不变式

强三色不变式:不允许黑色对象引用白色对象。

弱三色不变式:所有被黑色对象引用的白色对象都处于灰色保护状态。

黑色对象可以引用白色对象,但这个白色对象必须存在其他灰色对象对它的引用。

屏障机制

为了遵循上述的两个方式,GC 算法演进到两种屏障方式:“插入屏障”和“删除屏障”。

插入屏障

具体操作:在 A 对象引用 B 对象的时候,B 对象被标记为灰色。(将 B 挂在 A 下游,B 必须被标记为灰色)

满足:强三色不变式(不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)

删除屏障

具体操作:被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。

满足:弱三色不变式(保护灰色对象到白色对象的路径不会断)

插入写屏障和删除写屏障的短板:

  • 插入写屏障:结束时需要 STW 来重新扫描栈,标记栈上引用的白色对象的存活;
  • 删除写屏障:回收精度低,因为一个对象即使被删除了,最后一个指向它的指针也依旧可以活过这一轮,在下一轮 GC 中才会被清理掉。

混合写屏障机制

Go V1.8 版本引入了混合写屏障机制(hybrid write barrier),避免了对栈 re-scan 的过程,极大的减少了 STW 的时间。结合了两者的优点。

1、GC 开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需 STW)。

2、GC 期间,任何在栈上创建的新对象,均为黑色。

3、被删除的对象标记为灰色。

4、被添加的对象标记为灰色。

满足:变形的弱三色不变式。

注意:屏障技术是不在栈上应用的,因为要保证栈的运行效率

混合写屏障是 GC 的一种屏障机制,只是当程序执行 GC 的时候,才会触发这种机制。

详细内容参考:golang三色标记+混合写屏障gc模式全分析

内存泄漏

go什么情况下会发生内存泄漏?

解析

在Go语言中,内存泄漏主要可能发生在以下几种情况:

  1. Goroutine 泄露:这是最常见的原因之一。当大量goroutine被创建但没有正确关闭或无法自然结束时,它们会一直占用内存。每个goroutine虽然占用的栈内存不多,但如果数量累积,会导致显著的内存消耗。确保goroutine能在完成任务后正常退出是非常重要的。
  2. 长时间阻塞的Goroutine:如果goroutine因为某些原因(如等待channel接收数据、死锁等)而长时间阻塞,它们也会导致内存不能被及时回收。
  3. 子字符串或子切片截取:在Go中,字符串和切片的操作可能会导致原始数据无法被GC回收。例如,频繁地从大字符串中截取子字符串,原始大字符串的内存会持续被占用,直到没有其他引用指向它。
  4. 不当使用defer:虽然defer语句是Go中用于资源清理的有力工具,但错误的使用方式可能导致资源不能被及时释放,从而引发内存泄漏。
  5. 未取消的定时器(如time.Ticker:如果不适当地处理time.Ticker,比如忘记调用Stop方法,它会持续产生事件,导致相关goroutine和资源无法释放。
  6. 内存逃逸:当一个变量本应分配在栈上(生命周期短暂),但由于某些操作(如作为大结构体的一部分、放入切片或map中跨函数返回)被迫分配到堆上,可能导致不必要的内存占用和潜在的泄漏。

避免内存泄漏的方法包括:

  • 确保所有goroutine都能正常退出。
  • 使用sync.WaitGroup或其他同步机制来管理goroutine的生命周期。
  • 注意字符串和切片操作,尽量复用或采用更高效的内存管理策略。
  • 正确使用defer,确保资源被适时释放。
  • 及时清理不再需要的定时器和通道。
  • 避免不必要的内存逃逸,合理设计数据结构和函数参数传递。
  • 使用pprof工具定期检查和分析程序的内存使用情况,定位潜在的泄漏点。

make与new

make与new的区别?

解析

在Go语言中,makenew都是用于内存分配的机制,但它们之间存在一些关键区别:

  1. 适用类型

    • new可以分配任何类型的内存,包括基本类型、结构体、数组等。当你调用new(T)时,它会在堆上分配一个类型为T的零值空间,并返回一个类型为*T(T的指针)的内存地址。
    • make则专门用于初始化切片(slice)、映射(map)和通道(channel)这三种引用类型。它不仅分配内存,还会对这些类型进行初始化,准备它们以便立即使用。make直接返回一个已初始化的引用类型值(即slice、map或channel本身)。
  2. 返回值

    • new(T)返回一个指向类型T的指针*T,这个指针指向的内存被初始化为T类型的零值。
    • make(T, args)直接返回一个已经初始化的T类型值(对于slice、map、channel而言),而不是指针。
  3. 初始化行为

    • 使用new分配的内存会被清零,即所有字段都会被设置为它们的零值。
    • make除了分配内存外,还会对slice、map和channel进行额外的初始化操作,确保它们处于可用状态。例如,对于map,它会初始化hash表;对于channel,它会创建一个可以用于通信的通道。

总结来说,当你需要分配和初始化切片、映射或通道时,应该使用make;而对于其他类型,尤其是需要一个零值实例的指针时,则应使用new。在实践中,由于Go支持声明同时初始化的语法,new的使用相对较少,而make则在处理动态集合和并发编程时非常关键。

数据类型

类型别名与类型定义

类型别名与类型定义的区别?

解析

详细说明:类型别名和类型定义

类型别名:给现有类型提供一个替代的名称,不会创建新类型。使用 = 来声明。可以与原类型互换使用,两者是相同的类型。

类型定义:创建一个新的类型,它和原类型在类型系统中是不同的。使用新名称直接声明。创建了一个新的类型,不能直接与原类型互换使用,需要进行显式的类型转换。

string

string的底层实现原理

解析

Go语言中的字符串(string)类型是不可变的,这意味着一旦创建,其内容就不能被修改。Go语言的字符串底层实现原理可以从以下几个方面来理解:

  1. 数据结构:在Go的运行时源码中,字符串被表示为一个结构体stringStruct,其内部包含两个字段:

    • str:类型为unsafe.Pointer,指向字符串的字节序列首地址。
    • len:表示字符串的长度,即字节的数量,类型为int

    这个结构体并没有直接暴露给用户,用户看到的是类型string,它是一个抽象的、更易于使用的表示形式。

  2. UTF-8编码:Go语言默认使用UTF-8编码来存储字符串,这意味着一个字符可能占用1到4个字节,具体取决于字符的Unicode码点。UTF-8编码使得处理ASCII字符时非常高效,同时也能很好地支持多语言字符。

  3. 内存布局:字符串的实际内容存储在连续的内存块中,而字符串变量则是一个轻量级的结构体,它包含一个指向这块内存的指针和一个表示字符串长度的整数。这种设计让字符串的传递变得高效,因为只需要复制结构体的两个字段,而不是整个字符串的内容。

  4. 不可变性:由于字符串内容不可更改,任何对字符串看似修改的操作(如拼接、截取等)都会创建一个新的字符串。这不仅简化了内存管理,也使得字符串可以在多个地方共享,而不用担心意外的修改。

  5. 安全性:字符串内容通常存储在只读内存区域,进一步确保了字符串的不可变性,避免了无意间修改数据的风险。

  6. 结束标识:在Go语言中,字符串并不需要显式的结束符号(如C语言中的\0),其长度由len字段直接指定,这使得处理字符串更加直接和高效。

字符串拼接的几种方式?

解析

在Go语言中,有多种字符串拼接的方法,每种方法都有其适用场景和性能考量。以下是最常见的几种字符串拼接方式:

  1. 使用"+“运算符:这是最直接的方式,但效率较低,因为每次拼接都会创建一个新的字符串(字符串是不可变的)。

    1
    2
    3
    
    s1 := "Hello, "
    s2 := "world!"
    result := s1 + s2
  2. fmt.Sprintf:适合包含变量或格式化需求的字符串拼接,但性能不如strings.Builderbytes.Buffer

    1
    2
    3
    
    name := "Alice"
    age := 30
    result := fmt.Sprintf("My name is %s and I am %d years old.", name, age)
  3. strings.Join:当需要拼接字符串切片时非常有用,性能较好。

    1
    2
    
    parts := []string{"Hello,", " ", "world!"}
    result := strings.Join(parts, "")
  4. bytes.Buffer:通过WriteString方法,适合多次拼接操作,性能较优。

    1
    2
    3
    4
    5
    6
    
    import "bytes"
    
    var buffer bytes.Buffer
    buffer.WriteString("Hello, ")
    buffer.WriteString("world!")
    result := buffer.String()
  5. strings.Builder:类似于bytes.Buffer,但专为字符串构建设计,通常性能最佳。

    1
    2
    3
    4
    5
    6
    
    import "strings"
    
    var builder strings.Builder
    builder.WriteString("Hello, ")
    builder.WriteString("world!")
    result := builder.String()

    字符串在Go中是不可变的,意味着每次使用”+“操作符拼接字符串时,都会创建一个新的字符串实例,这导致了大量的内存分配和复制操作。而strings.Builder内部使用一个可增长的byte slice(字节切片)来存储数据,减少了内存分配的次数。当需要更多空间时,它会根据需要逐步增加其底层数组的容量,而不是每次拼接都重新分配整个字符串的内存。

选择哪种方式取决于具体需求,如字符串拼接的频率、是否需要格式化、性能敏感度等因素。在性能敏感的场景下,通常推荐使用strings.Builderbytes.Buffer

slice

slice的底层实现原理?

解析

Go语言中的切片(slice)是一种灵活的数据结构,其底层基于数组实现,但提供了动态调整大小的能力,使得数据的存储和管理更加灵活。以下是Go切片底层实现的一些关键原理:

  1. 数据结构:切片的底层数据结构是一个结构体,定义在运行时库的源代码中(src/runtime/slice.go)。这个结构体包含三个字段:

    • array unsafe.Pointer:这是一个不安全的指针,指向切片底层数组的起始地址。由于使用了unsafe.Pointer,这个字段可以直接访问底层数组的内存。
    • len int:表示切片的长度,即切片中元素的数量。
    • cap int:表示切片的容量,即底层数组能够容纳的元素数量。切片的长度永远不会超过其容量。
  2. 基于数组:切片本身并不直接存储数据,而是对一个底层数组的封装。这意味着切片的元素是连续存储的,可以通过索引快速访问。

  3. 动态调整:虽然切片的长度可以在使用过程中改变(通过追加元素等操作),但这种“动态”调整实际上是通过创建一个新的切片来实现的,如果必要的话,还会分配一个新的、更大的底层数组,并将原数组的数据复制过去。这一过程对于程序员是透明的,给人以切片可变长的错觉。

  4. 共享与复制:当切片作为参数传递给函数或方法时,传递的是底层数组的引用和切片元数据(长度和容量)。因此,修改通过切片访问的元素会影响到原始数据。

    如果在函数中对传入的切片进行append操作,会分配一个新的底层数组,不会导致原切片值的变化。如果希望append之后,原切片进行不同变化,则需要传递指针类型的切片。

    如果需要创建一个完全独立的切片副本,可以使用copy()函数或显式地创建一个新的切片并复制数据。

  5. 零值与初始化:使用make()函数创建切片时,切片的元素会被初始化为其类型的零值。而通过数组字面量或切片表达式创建切片时,切片将直接引用已有数组的部分或全部,无需显式初始化。

  6. 内存管理:Go的垃圾回收器负责管理切片所使用的内存,包括底层数组的分配和释放,确保了内存的有效利用和避免泄露。

总之,Go切片通过轻量级的封装提供了动态数组的功能,结合了数组的高效访问特性和动态大小调整的灵活性,是Go语言中处理集合数据的常用工具。

slice的扩容机制

解析

Go语言中的切片扩容机制旨在高效地管理动态数组的大小,以适应元素的增加。以下是Go切片扩容的主要原则和步骤:

  1. 触发条件:切片扩容通常在使用append函数向切片添加元素,且当前切片的长度达到了其容量(cap)时自动发生。

  2. 扩容策略

    • 容量小于阈值:如果切片的当前容量小于一个特定阈值(历史上这个阈值有过变化,在不同版本的Go中可能不同,例如早期是1024,后来变为256),那么扩容时新容量通常是旧容量的两倍。这意味着每次扩容都会使容量翻倍。
    • 容量大于等于阈值:当切片的容量达到或超过上述阈值后,扩容不再简单地翻倍,而是采用更加精细的策略。此时,新容量会按照一定比例(如1.25倍)递增,直到新容量足以容纳所有元素。这样的设计是为了减少大容量切片不必要的大量内存分配,从而提高内存使用的效率和程序性能。
  3. 扩容过程

    • 分配新内存:当需要扩容时,Go运行时会分配一个新的、更大容量的底层数组。
    • 复制元素:将原切片中的所有元素复制到新的底层数组中。
    • 更新切片元数据:创建一个新的切片结构体,指向新数组的起始位置,并更新长度和容量信息。
    • 释放旧内存:原底层数组如果不再被其他引用持有,将会被垃圾回收。
  4. 性能考虑:虽然自动扩容简化了编程模型,但频繁的扩容操作(特别是涉及大量数据时)可能会引入性能开销,因为每次扩容都需要分配内存和复制数据。因此,在预知切片大小的情况下,可以使用make函数预先分配足够的容量来减少扩容次数。

map

map的底层实现原理?

解析

Go语言的map在底层使用以下两个主要数据结构:hmap和bmap。

1.hmap:

  • count:当前哈希表中键值对的数量。
  • B:桶的数量是对数,即2^B是桶的总数。
  • buckets:指向桶数组的指针,每个桶包含多个键值对。
  • oldbuckets:在扩容时用于暂存旧桶的数组。
  • hash0:哈希种子,用于哈希函数。

2.桶(Buckets):桶是哈希表中的基本单元,每个桶可以存储多个键值对。每个桶是一个小的数组,包含以下信息:

  • keys:存储键的数组。
  • values:存储值的数组,与keys一一对应。
  • overflow:指向下一个桶的指针,用于处理哈希冲突。

3.哈希函数:当向map中插入一个键值对时,首先会使用哈希函数对这个键进行哈希,得到一个哈希值。然后,使用这个哈希值来决定键值对应该存储在哪个桶中。

4.哈希冲突:当两个不同的键产生相同的哈希值时,会发生哈希冲突。Go语言使用链表法(也称为拉链法)来解决哈希冲突:

  • 如果一个桶已满,并且有一个新的键值对需要插入,那么会创建一个新的桶(称为溢出桶),并将新键值对存储在这个新桶中。
  • 桶中的overflow指针指向下一个桶,这样就形成了一个链表结构。

5.扩容:随着键值对数量的增加,为了保证哈希表的性能,当装载因子(键值对数量与桶数量的比值)超过某个阈值时,哈希表会进行扩容。扩容的步骤包括:

  • 创建一个新的桶数组,其大小是原数组大小的两倍。
  • 重新计算所有键在新桶数组中的位置,并将它们重新插入。
  • 释放旧的桶数组。

这个过程是渐进式的,即不会一次性迁移所有键值对,而是在每次插入或删除操作时逐步迁移。

6.并发问题Go的map不是线程安全的,不支持并发读写。如果多个goroutine同时访问或修改同一个map,会导致未定义行为。为了在并发环境中使用map,需要使用互斥锁(mutex)或其他同步机制来保证操作的原子性。

map初始化、写入和读取、扩容、迁移

解析
1
info = make(map[string]string,10)

map初始化

初始化一个可容纳10个元素的map:

  • 第一步:创建一个hmap结构体对象。

  • 第二步:生成一个哈希因子hash0并赋值到hmap对象中(用于后续为key创建哈希值)。

  • 第三步:根据hint=10,并根据算法规则来创建B,当前B应该为1。

    1
    2
    3
    4
    5
    
    hint  B 
    0-8   0
    9-13  1
    14-26 2
    ...
  • 第四步:根据B去创建去创建桶(bmap对象)并存放在buckets数组中,当前bmap的数量应为2

    • 当B<4时,根据B创建桶的个数的规则为:$2^B$(标准桶)
    • 当B>=4时,根据B创建桶的个数的规则为∶$2^B+2^{B-4}$(标准桶+溢出桶)

    注意:每个bmap中可以存储8个键值对,当不够存储时需要使用溢出桶,并将当前bmap中的overflow字段指向溢出桶的位置。

写入数据

1
info["name"] ="Hollis"

在map中写入数据时,内部的执行流程为:

  • 第一步:结合哈希因子和键name生成哈希值。

  • 第二步:获取哈希值的后B位,并根据后B为的值来决定将此键值对存放到那个桶中(bmap)。

  • 第三步:在上一步确定桶之后,接下来就在桶中写入数据。

    获取哈希值的tophash(即:哈希值的高8位),将tophash、key、value分别写入到桶中的三个数组中。如果桶已满,则通过overflow找到溢出桶,并在溢出桶中继续写入。

    注意:以后在桶中查找数据时,会基于tophash来找(tophash相同则再去比较key)。

  • 第四步: hmap的个数count++(map中的元素个数+1)

读取数据

1
value := info["name"]

在map中读取数据时,内部的执行流程为:

  • 第一步:结合哈希引子和键name生成哈希值。

  • 第二步:获取哈希值的后B位,并根据后B位的值来决定将此键值对存放到那个桶中(bmap)。

  • 第三步:确定桶之后,再根据key的哈希值计算出tophash(高8位),根据tophash和key去桶中查找数据。

    当前桶如果没找到,则根据overflow再去溢出桶中找,均未找到则表示key不存在。

扩容

在向map中添加数据时,当达到某个条件,则会引发字典扩容。

扩容条件:

  • map中数据总个数 / 桶个数 > 6.5,引发翻倍扩容。
  • 使用了太多的溢出桶时(溢出桶使用的太多会导致map处理速度降低)。
    • B<=15,已使用的溢出桶个数>=$2^B$时,引发等量扩容。
    • B>15,已使用的溢出桶个数>=$2^{15}$时,引发等量扩容。

迁移

扩容之后,必然要伴随着数据的迁移,即:将旧桶中的数据要迁移到新桶中。

翻倍扩容

如果是翻倍扩容,那么迁移规就是将旧桶中的数据分流至新的两个桶中(比例不定),并且桶编号的位置为同编号位置和翻倍后对应编号位置。

map翻倍扩容

那么问题来了,如何实现的这种迁移呢?

首先,我们要知道如果翻倍扩容(数据总个数 / 桶个数 > 6.5),则新桶个数是旧桶的2倍,即:map中的B的值要+1(因为桶的个数等于$2^B$,而翻倍之后新桶的个数就是 $2^B$ * 2,也就是$2^{B+1}$,所以新桶的B的值=原桶B+1)。

迁移时会遍历某个旧桶中所有的key(包括溢出桶),并根据key重新生成哈希值,根据哈希值的 低B位 来决定将此键值对分流道那个新桶中。

map扩容前后hash值

扩容后,B的值在原来的基础上已加1,也就意味着通过多1位来计算此键值对要分流到新桶位置,如上图:

  • 当新增的位(红色)的值为0,则数据会迁移到与旧桶编号一致的位置。
  • 当新增的位(红色)的值为1,则数据会迁移到翻倍后对应编号位置。

例如:

旧桶个数为 32 个,翻倍后新桶的个数为64。

在重新计算旧桶中的所有key哈希值时,红色位只能是0或1,所以桶中的所有数据的后B位只能是以下两种情况:

  • 000111 【7】,意味着要迁移到与旧桶编号一致的位置。
  • 100111 【39】,意味着会迁移到翻倍后对应编号位置。

特别提醒:同一个桶中key的哈希值的低B位一定是相同的,不然不会放在同一个桶中,所以同一个桶中黄色标记的位都是相同的。

等量扩容

如果是等量扩容(溢出桶太多引发的扩容),那么数据迁移机制就会比较简单,就是将旧桶(含溢出桶)中的值迁移到新桶中。

这种扩容和迁移的意义在于:当溢出桶比较多而每个桶中的数据又不多时,可以通过等量扩容和迁移让数据更紧凑,从而减少溢出桶。

channel

channel的底层实现原理?

解析

空结构体与空接口

空结构体和空接口的区别?

解析

空结构体

一种特殊的类型,其实例也只能有唯一值,也就是 struct{}{}。空结构体是几乎零内存占用的。

用途:

  • 作为占位符:在某些场景下,当需要一个类型来满足编译器要求,但实际上不需要存储任何数据时,可以使用空结构体。比如,将map用作集合(set)时,值类型可以定义为空结构体。
  • 同步信号:在并发编程中,通过一个chan struct{}传递消息,由于其体积小,可以高效地用于信号通知,如关闭channel时的信号传递。

空接口

空接口可以表示任意类型,因为接口内容为空,意味着所有类型都自动实现了空接口。

用途:

  • 通用容器:任何类型的值都可以赋给空接口类型的变量,这使得它可以作为持有任意类型数据的容器
  • 多态:函数参数可以声明为空接口类型,从而接受任何类型的参数,这对于编写通用函数非常有用。
  • 类型断言和类型switch:与空接口结合使用,可以在运行时检查并转换接口所持有的具体类型。

详细内容参考:空结构体和空接口

并发编程

交替打印奇偶数

两个协程,交替打印 1-100 的奇数和偶数。

解析

使用两个协程,交替打印奇数和偶数。使用了两个无缓冲的管道,作为 select…case… 中,case 执行的条件。

还使用了一个管道 ch3,用于阻塞主协程的提前退出。

2024.06.20 但本题存在的一个问题是,我不知道该如何使用 WaitGroup 来阻塞主协程的提前退出,怎么写都存在死锁问题 或者 wg.Done() 执行次数太多的问题 或者 打印的数字超过了100。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func main() {
    var i int64 = 1
    ch1 := make(chan bool)
    ch2 := make(chan bool)
    ch3 := make(chan bool) // 用于退出

    go func() {
       for {
          select {
          case <-ch1: // 当ch1管道中写入信号时,执行该case
             if i > 100 {
                ch3 <- true
                return
             }
             fmt.Println(i)
             atomic.AddInt64(&i, 1) // 原子加,并发安全
             ch2 <- true            // 本case执行完成后,向ch2管道写入信号
          }
       }
    }()

    go func() {
       for {
          select {
          case <-ch2:
             if i > 100 {
                ch3 <- true
                return
             }
             fmt.Println(i)
             atomic.AddInt64(&i, 1)
             ch1 <- true
          }
       }
    }()
    ch1 <- true // 向ch1管道中写入信号
    <-ch3       // 阻塞,防止主协程提前退出
}

3个协程顺序执行100次

3个函数分别打印cat、dog、fish,要求每个函数都要起一个goroutine,按照cat、dog、fish顺序打印在屏幕上100次。

本题目来自:中高级 Golang 面试,这个候选人表现出乎了我的预料

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
var count int64 = 0
func printCat(catChan <-chan bool, dogChan, existChan chan<- bool) {
    for {
       select {
       case <-catChan:
          if count >= 100 {  
             existChan <- true
             return
          }
          fmt.Println("cat")
          atomic.AddInt64(&count, 1)
          dogChan <- true
       }
    }
}
func printDog(dogChan <-chan bool, fishChan chan<- bool) {
    for {
       select {
       case <-dogChan:
          fmt.Println("dog")
          fishChan <- true
       }
    }
}
func printFish(fishChan <-chan bool, catChan chan<- bool) {
    for {
       select {
       case <-fishChan:
          fmt.Println("fish")
          catChan <- true
       }
    }
}
func main() {
    catChan := make(chan bool)
    dogChan := make(chan bool)
    fishChan := make(chan bool)
    existChan := make(chan bool) // 用于阻塞主协程的退出

    go printCat(catChan, dogChan, existChan)
    go printDog(dogChan, fishChan)
    go printFish(fishChan, catChan)
    catChan <- true
    <-existChan
}

解释一下Gin框架的中间件机制及其在web服务中的应用

Goroutines与操作系统线程有何不同,以及如何有效地使用goroutine和channel来实现并发任务?

请描述一个场景,说明在Go中如何使用channel进行goroutines间的通信,并保证数据安全。

详细解释接口的定义和用途,以及如何实现接口、接口嵌套和空接口(interface{})的特殊意义。

除了goroutines和channels,Go还有哪些并发控制的工具或模式?例如,sync包中的Mutex、WaitGroup等,它们分别在什么场景下使用,以及如何避免死锁?

反射原理

defer的实现原理

参考:Golang面试知识点总结

slice和数组的区别?

slice扩容

简述map的结构

为什么桶内的元素要用遍历而不用映射呢?

map如果遇到hash冲突,怎么处理?

channel有什么比较重要的结构?

select如何实现?

panic是如何执行的?

unsafe.Pointer 和 uintptr关系及应用

关于go的安全并发和锁的实现

0%