2025Go面试八股(含100道答案)
最近很多朋友都在找Go开发相关的岗位,本文总结了100道Go常见的面试题(含答案),内容如下:
01 = 和 := 的区别?
=是赋值变量,:=是定义变量。
02 指针的作用
一个指针可以指向任意变量的地址,它所指向的地址在32位或64位机器上分别固定占4或8个字节。指针的作用有:
- 获取变量的值
import fmtfunc main(){a := 1p := &a//取址&fmt.Printf("%d\n", *p);//取值*}
- 改变变量的值
// 交换函数
func swap(a, b *int) {*a, *b = *b, *a
}
- 用指针替代值传入函数,比如类的接收器就是这样的。
type A struct{}func (a *A) fun(){}
03 Go 允许多个返回值吗?
可以。通常函数除了一般返回值还会返回一个error。
04 Go 有异常类型吗?
有。Go用error类型代替try…catch语句,这样可以节省资源。同时增加代码可读性:
_, err := funcDemo()
if err != nil {fmt.Println(err)return
}
也可以用errors.New()来定义自己的异常。errors.Error()会返回异常的字符串表示。只要实现error接口就可以定义自己的异常,
type errorString struct {s string}func (e *errorString) Error() string {return e.s}// 多一个函数当作构造函数func New(text string) error {return &errorString{text}}
package mainimport ("errors""fmt"
)func divide(a, b int) (int, error) {if b == 0 { //通过errors.New 可类似实现throw new Exception捕获异常return 0, errors.New("division by zero")}return a / b, nil
}func main() {result, err := divide(10, 2)if err != nil {fmt.Println("Error:", err)} else {fmt.Println("Result:", result)}result, err = divide(10, 0)if err != nil {fmt.Println("Error:", err)} else {fmt.Println("Result:", result)}
}
05 什么是协程(Goroutine)
协程是用户态轻量级线程,它是线程调度的基本单位。通常在函数前加上go关键字就能实现并发。一个Goroutine会以一个很小的栈启动2KB或4KB,当遇到栈空间不足时,栈会自动伸缩, 因此可以轻易实现成千上万个goroutine同时启动。
06 ❤ 如何高效地拼接字符串
拼接字符串的方式有:+
fmt.Sprintf
, strings.Builder
, bytes.Buffer
, strings.Join
+
使用+操作符进行拼接时,会对字符串进 行遍历,计算并开辟一个新的空间来存储原来的两个字符串。
fmt.Sprintf
由于采用了接口参数,必须要用反射获取值,因此有性能损耗。
3 strings.Builder
用WriteString()进行拼接,内部实现是指针+切片,同时String()返回拼接后的字符串,它是直接把[]byte转换为string,从而避免变量拷贝。
4 bytes.Buffer
bytes.Buffer是一个一个缓冲byte类型的缓冲器,这个缓冲器里存放着都是byte,
bytes.buffer底层也是一个[]byte切片。
5 strings.join
strings.join也是基于strings.builder来实现的,并且可以自定义分隔符,在join方法内调用了b.Grow(n)方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。
性能比较:
strings.Join` ≈ `strings.Builder` > `bytes.Buffer` > `+` > `fmt.Sprintf
5种拼接方法的实例代码:
func main(){a := []string{"a", "b", "c"}//方式1:+ret := a[0] + a[1] + a[2]//方式2:fmt.Sprintfret := fmt.Sprintf("%s%s%s", a[0],a[1],a[2])//方式3:strings.Buildervar sb strings.Buildersb.WriteString(a[0])sb.WriteString(a[1])sb.WriteString(a[2])ret := sb.String()//方式4:bytes.Bufferbuf := new(bytes.Buffer)buf.Write(a[0])buf.Write(a[1])buf.Write(a[2])ret := buf.String()//方式5:strings.Joinret := strings.Join(a,"")
}
参考资料:字符串拼接性能及原理 | Go 语言高性能编程 | 极客兔兔
答疑
在Go语言中,strings.Builder
的性能通常比bytes.Buffer
好,主要有以下几个原因:
- 零拷贝:
strings.Builder
在内部使用了可变长度的[]byte
切片来存储字符串,而bytes.Buffer
使用了固定长度的[]byte
切片。当进行字符串拼接时,strings.Builder
可以直接修改切片中的内容,而不需要进行额外的内存分配和拷贝操作,从而避免了不必要的性能开销。 - 预分配内存:
strings.Builder
在初始化时会预分配一定大小的内存空间,避免了频繁的内存分配和释放操作。这样可以减少内存分配的次数,提高性能。 - 字符串连接优化:
strings.Builder
提供了WriteString
方法,可以直接将字符串追加到内部的[]byte
切片中,而不需要进行类型转换和拷贝操作。这样可以减少不必要的中间步骤,提高字符串连接的效率。
需要注意的是,strings.Builder
和bytes.Buffer
都是用于字符串拼接和缓冲的类型,选择使用哪个取决于具体的需求和场景。如果需要频繁进行字符串拼接操作,尤其是在循环中,strings.Builder
通常会更高效。而如果只是简单的缓冲操作,bytes.Buffer
也可以满足需求。
总结来说,strings.Builder
的性能比bytes.Buffer
好,主要是因为它采用了零拷贝、预分配内存和字符串连接优化等技术,避免了不必要的内存分配和拷贝操作,提高了字符串拼接的效率。
07 什么是 rune 类型
ASCII 码只需要 7 bit 就可以完整地表示,但只能表示英文字母在内的128个字符,为了表示世界上大部分的文字系统,发明了 Unicode, 它是ASCII的超集,包含世界上书写系统中存在的所有字符,并为每个代码分配一个标准编号(称为Unicode CodePoint),在 Go 语言中称之为 rune,是 int32 类型的别名。
Go 语言中,字符串的底层表示是 byte (8 bit) 序列,而非 rune (32 bit) 序列。
sample := "我爱GO"
runeSamp := []rune(sample)
runeSamp[0] = '你'
fmt.Println(string(runeSamp)) // "你爱GO"
fmt.Println(len(runeSamp)) // 4
08 如何判断 map 中是否包含某个 key ?
var sampleMap map[int]int
if _, ok := sampleMap[10]; ok {...
} else {...
}
// sampleMap[10] 返回vlaue 和 bool
09 Go 支持默认参数或可选参数吗?
不支持。但是可以利用结构体参数,或者...
传入参数切片数组。
// 传入结构体参数
struct Options {concurrent bool
}
func pread(offset int64, len int64, o *Options) {...
}
// 这个函数可以传入任意数量的整型参数
func sumN(nums ...int) int {total := 0for _, num := range nums {total += num}return total
}
10 defer 的执行顺序
defer执行顺序和调用顺序相反,类似于栈后进先出(LIFO)。
defer在return之前(*修改@放)执行,但在函数退出之前,defer可以修改返回值。下面是一个例子:
func test() int {i := 0defer func() {fmt.Println("defer1")}()defer func() {i += 1fmt.Println("defer2")}()return i
}func main() {fmt.Println("return", test())
}
// defer2
// defer1
// return 0
上面这个例子中,test返回值并没有修改,这是由于Go的返回机制决定的,执行Return语句后,Go会创建一个临时变量保存返回值。如果是有名返回(也就是指明返回值func test() (i int)
)
func test() (i int) {i = 0defer func() {i += 1fmt.Println("defer2")}()return i
}func main() {fmt.Println("return", test())
}
// defer2
// return 1
这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。
11 如何交换 2 个变量的值?
对于变量而言a,b = b,a; 对于指针而言*a,*b = *b, *a
12 Go 语言 tag 的用处?
tag可以为结构体成员提供属性。常见的:
- json序列化或反序列化时字段的名称
- db: sqlx模块中对应的数据库字段名
- form: gin框架中对应的前端的数据字段名
- binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端
13 如何获取一个结构体的所有tag?
利用反射:
import reflect
type Author struct {Name int `json:Name`Publications []string `json:Publication,omitempty`
}func main() {// 获取结构体类型信息t := reflect.TypeOf(Author{})for i := 0; i < t.NumField(); i++ {name := t.Field(i).Names, _ := t.FieldByName(name)fmt.Println(name, s.Tag)}
}
上述例子中,reflect.TypeOf方法获取对象的类型,之后NumField()获取结构体成员的数量。 通过Field(i)获取第i个成员的名字。 再通过其Tag 方法获得标签。
14 如何判断 2 个字符串切片(slice) 是相等的?
reflect.DeepEqual()
, 但反射非常影响性能。
15 结构体打印时,%v 和 %+v 的区别
%v输出结构体各成员的值;
%+v输出结构体各成员的名称和值;
%#v输出结构体名称和结构体各成员的名称和值;
16 Go 语言中如何表示枚举值(enums)?
在常量中用iota可以表示枚举。iota从0开始。
const (B = 1 << (10 * iota)KiB MiBGiBTiBPiBEiB
)
17 空 struct{} 的用途
- 用map模拟一个set,那么就要把值置为struct{},struct{}本身不占任何空间,可以避免任何多余的内存分配。
type Set map[string]struct{}func main() {set := make(Set)for _, item := range []string{"A", "A", "B", "C"} {set[item] = struct{}{}}fmt.Println(len(set)) // 3if _, ok := set["A"]; ok {fmt.Println("A exists") // A exists}
}
- 有时候给通道发送一个空结构体,
channel<-struct{}{}
,也是节省了空间。
func main() {ch := make(chan struct{}, 1)go func() {<-ch// do something}()ch <- struct{}{}// ...
}
- 仅有方法的结构体
type Lamp struct{}
18 go里面的int和int32是同一个概念吗?
不是一个概念。go语言中的int的大小是和操作系统位数相关的,如果是32位操作系统,int类型的大小就是4字节。如果是64位操作系统,int类型的大小就是8个字节。除此之外uint也与操作系统有关,占字节与int类型情况一致。而int后有数字的话,占用空间大小是固定的:
int8占1个字节,int16占2个字节,int32占4个字节,int64占8个字节。
19 init() 函数是什么时候执行的?
简答: 在main函数之前执行。
详细:init()函数是go初始化的一部分,由runtime初始化每个导入的包,初始化不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。
每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的init()函数。同一个包,甚至是同一个源文件可以有多个init()函数。init()函数没有入参和返回值,不能被其他函数调用,同一个包内多个init()函数的执行顺序不作保证。
执行顺序:import –> const –> var –>init()–>main()
一个文件可以有多个init()函数!
20 ❤如何知道一个对象是分配在栈上还是堆上?
Go和C++不同,Go的逃逸分析是在编译器完成的;go局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢?
go build -gcflags ‘-m -m -l’ xxx.go.
关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。如果变量内存占用较大时,优先放在堆上;如果函数外部没有引用,优先放在栈中;如果变量在函数外部存在引用;必定在堆中。
21 2 个 interface 可以比较吗 ?
Go 语言中,interface 的内部实现包含了 2 个字段,类型 T 和 值 V,interface 可以使用 == 或 != 比较。2 个 interface 相等有以下 2 种情况
- 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态)
- 类型 T 相同,且对应的值 V 相等。
看下面的例子:
type Stu struct{ Name string }
type StuInt interface{}func main() {var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"}var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"}fmt.Println(stu1 == stu2) // falsefmt.Println(stu3 == stu4) // true
}
stu1 和 stu2 对应的类型是 *Stu,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。 stu3 和 stu4 对应的类型是 Stu,值是 Stu 结构体,且各字段相等,因此结果为 true。
22 2 个 nil 可能不相等吗?
可能不等。interface在运行时绑定值,只有值为nil接口值才为nil,但是与指针的nil不相等。举个例子:
var p *int = nil var i interface{} = nil if(p == i){ fmt.Println(“Equal”) }
两者并不相同。总结:两个nil只有在类型相同时才相等。
23 ❤简述 Go 语言GC(垃圾回收)的工作原理
垃圾回收机制是Go一大特(nan)色(dian)。Go1.3采用标记清除法, Go1.5采用三色标记法,Go1.8采用三色标记法+混合写屏障。
标记清除法
分为两个阶段:标记和清除
标记阶段:从根对象出发寻找并标记所有存活的对象。
清除阶段:遍历堆中的对象,回收未标记的对象,并加入空闲链表。
缺点是需要暂停程序STW(stop the world)。
三色标记法:
将对象标记为白色,灰色或黑色。
白色:不确定对象(默认色);黑色:存活对象。灰色:存活对象,子对象待处理。
标记开始时,先将所有对象加入白色集合(需要STW)。首先将根对象标记为灰色,然后将一个对象从灰色集合取出,遍历其子对象,放入灰色集合。同时将取出的对象放入黑色集合,直到灰色集合为空。最后的白色集合对象就是需要清理的对象。
这种方法有一个缺陷,如果对象的引用被用户修改了,那么之前的标记就无效了。因此Go采用了写屏障技术,当对象新增或者更新会将其着色为灰色。
一次完整的GC分为四个阶段:
- 准备标记(需要STW),开启写屏障。
- 开始标记
- 标记结束(STW),关闭写屏障
- /清理(并发)
基于插入写屏障和删除写屏障在结束时需要STW来重新扫描栈,带来性能瓶颈。混合写屏障分为以下四步:
- GC开始时,将栈上的全部对象标记为黑色(不需要二次扫描,无需STW);
- GC期间,任何栈上创建的新对象均为黑色
- 被删除引用的对象标记为灰色
- 被添加引用的对象标记为灰色
总而言之就是确保黑色对象不能引用白色对象,这个改进直接使得GC时间从 2s降低到2us。
24 函数返回局部变量的指针是否安全?
这一点和C++不同,在Go里面返回局部变量的指针是安全的。因为Go会进行逃逸分析,如果发现局部变量的作用域超过该函数则会把指针分配到堆区,避免内存泄漏。
25 非接口的任意类型 T() 都能够调用 *T 的方法吗?反过来呢?
一个T类型的值可以调用*T类型声明的方法,当且仅当T是可寻址的。
反之:*T 可以调用T()的方法,因为指针可以解引用。
26 go slice是怎么扩容的?
1.7版本:如果当前容量小于1024,则判断所需容量是否大于原来容量2倍,如果大于,当前容量加上所需容量;否则当前容量乘2。
如果当前容量大于1024,则每次按照1.25倍速度递增容量,也就是每次加上cap/4。
1.8版本:Go1.18不再以1024为临界点,而是设定了一个值为256的threshold
,以256为临界点;超过256,不再是每次扩容1/4,而是每次增加(旧容量+3*256)/4;
- 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
- 当原 slice 容量 < threshold 的时候,新 slice 容量变成原来的 2 倍;
- 当原 slice 容量 > threshold,进入一个循环,每次容量增加(旧容量+3*threshold)/4。
27 ❤无缓冲的 channel 和有缓冲的 channel 的区别?
(这个问题笔者也纠结了很久,直到看到一篇文章,阻塞与否是分别针对发送接收方而言的,才茅塞顿开)
对于无缓冲区channel:
发送的数据如果没有被接收方接收,那么**发送方阻塞;**如果一直接收不到发送方的数据,接收方阻塞;
有缓冲的channel:
发送方在缓冲区满的时候阻塞,接收方不阻塞;接收方在缓冲区为空的时候阻塞,发送方不阻塞。
可以类比生产者与消费者问题。
编辑切换为居中
28 为什么有协程泄露(Goroutine Leak)?
协程泄漏是指协程创建之后没有得到释放。主要原因有:
- 缺少接收器,导致发送阻塞
- 缺少发送器,导致接收阻塞
- 死锁。多个协程由于竞争资源导致死锁。
- 创建协程的没有回收。
29 Go 可以限制运行时操作系统线程的数量吗? 常见的goroutine操作函数有哪些?
可以,使用runtime.GOMAXPROCS(num int)
可以设置线程数目。该值默认为CPU逻辑核数,如果设的太大,会引起频繁的线程切换,降低性能。
runtime.Gosched(),用于让出CPU时间片,让出当前goroutine的执行权限,调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。 runtime.Goexit(),调用此函数会立即使当前的goroutine的运行终止(终止协程),而其它的goroutine并不会受此影响。runtime.Goexit在终止当前goroutine前会先执行此goroutine的还未执行的defer语句。请注意千万别在主函数调用runtime.Goexit,因为会引发panic。
30 如何控制协程数目。
The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit.
从官方文档的解释可以看到,GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。
另外对于协程,可以用带缓冲区的channel来控制,下面的例子是协程数为1024的例子
var wg sync.WaitGroup
ch := make(chan struct{}, 1024)
for i:=0; i<20000; i++{wg.Add(1)ch<-struct{}{}go func(){defer wg.Done()<-ch}
}
wg.Wait()
此外还可以用协程池:其原理无外乎是将上述代码中通道和协程函数解耦,并封装成单独的结构体。常见第三方协程池库,比如tunny等。
31 ❤new和make的区别?
- new只用于分配内存,返回一个指向地址的指针。它为每个新类型分配一片内存,初始化为0且返回类型*T的内存地址,它相当于&T{}
- make只可用于slice,map,channel的初始化,返回的是引用。
32 请你讲一下Go面向对象是如何实现的?
Go实现面向对象的两个关键是struct和interface。
封装:对于同一个包,对象对包内的文件可见;对不同的包,需要将对象以大写开头才是可见的。
继承:继承是编译时特征,在struct内加入所需要继承的类即可:
type A struct{}
type B struct{A
}
多态:多态是运行时特征,Go多态通过interface来实现。类型和接口是松耦合的,某个类型的实例可以赋给它所实现的任意接口类型的变量。
Go支持多重继承,就是在类型中嵌入所有必要的父类型。
33 uint型变量值分别为 1,2,它们相减的结果是多少?
var a uint = 1
var b uint = 2
fmt.Println(a - b)
答案,结果会溢出,如果是32位系统,结果是232-1,如果是64位系统,结果264-1.
34 讲一下go有没有函数在main之前执行?怎么用?
go的init函数在main函数之前执行,使用方法:
func init() {...
}
init函数非常特殊:
- 初始化不能采用初始化表达式初始化的变量;
- 程序运行前执行注册
- 实现sync.Once功能
- 不能被其它函数调用
- init函数没有入口参数和返回值:
- 每个包可以有多个init函数,每个源文件也可以有多个init函数。
- 同一个包的init执行顺序,golang没有明确定义,编程时要注意程序不要依赖这个执行顺序。
- 不同包的init函数按照包导入的依赖关系决定执行顺序。
35 下面这句代码是什么作用,为什么要定义一个空值?
type GobCodec struct{conn io.ReadWriteCloserbuf *bufio.Writerdec *gob.Decoderenc *gob.Encoder
}type Codec interface {io.CloserReadHeader(*Header) errorReadBody(interface{}) errorWrite(*Header, interface{}) error
}var _ Codec = (*GobCodec)(nil)
答:将nil转换为GobCodec类型,然后再转换为Codec接口,如果转换失败,说明GobCodec没有实现Codec接口的所有方法。
36 ❤golang的内存管理的原理清楚吗?简述go内存管理机制。
golang内存管理基本是参考tcmalloc来进行的。go内存管理本质上是一个内存池,只不过内部做了很多优化:自动伸缩内存池大小,合理的切割内存块。
一些基本概念:
页Page:一块8K大小的内存空间。Go向操作系统申请和释放内存都是以页为单位的。
span : 内存块,一个或多个连续的 page 组成一个 span 。如果把 page 比喻成工人, span 可看成是小队,工人被分成若干个队伍,不同的队伍干不同的活。
sizeclass : 空间规格,每个 span 都带有一个 sizeclass ,标记着该 span 中的 page 应该如何使用。使用上面的比喻,就是 sizeclass 标志着 span 是一个什么样的队伍。
object : 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object 。假设 object 的大小是 16B , span 大小是 8K ,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object 。所谓内存分配,就是分配一个 object 出去。
mheap
一开始go从操作系统索取一大块内存作为内存池,并放在一个叫mheap的内存池进行管理,mheap将一整块内存切割为不同的区域,并将一部分内存切割为合适的大小。
编辑切换为
mheap.spans :用来存储 page 和 span 信息,比如一个 span 的起始地址是多少,有几个 page,已使用了多大等等。
mheap.bitmap 存储着各个 span 中对象的标记信息,比如对象是否可回收等等。
mheap.arena_start : 将要分配给应用程序使用的空间。
mcentral
用途相同的span会以链表的形式组织在一起存放在mcentral中。这里用途用sizeclass来表示,就是该span存储哪种大小的对象。
找到合适的 span 后,会从中取一个 object 返回给上层使用。
mcache
为了提高内存并发申请效率,加入缓存层mcache。每一个mcache和处理器P对应。Go申请内存首先从P的mcache中分配,如果没有可用的span再从mcentral中获取。
参考资料:Go 语言内存管理(二):Go 内存管理
37 ❤mutex有几种模式?
mutex有两种模式:normal 和 starvation
正常模式
所有goroutine按照FIFO的顺序进行锁获取,被唤醒的goroutine和新请求锁的goroutine同时进行锁获取,通常新请求锁的goroutine更容易获取锁(持续占有cpu),被唤醒的goroutine则不容易获取到锁。公平性:否。
饥饿模式
所有尝试获取锁的goroutine进行等待排队,新请求锁的goroutine不会进行锁获取(禁用自旋),而是加入队列尾部等待获取锁。公平性:是。
参考链接:Go Mutex 饥饿模式,GO 互斥锁(Mutex)原理
38 ❤go如何进行调度的?GMP中状态流转。
Go里面GMP分别代表:G:goroutine,M:线程(真正在CPU上跑的),P:调度器Processor。
调度器是M和G之间桥梁。
go进行调度过程:
- 某个线程尝试创建一个新的G,那么这个G就会被安排到这个线程的G本地队列LRQ中,如果LRQ满了,就会分配到全局队列GRQ中;
- 尝试获取当前线程的M,如果无法获取,就会从空闲的M列表中找一个,如果空闲列表也没有,那么就创建一个M,然后绑定G与P运行。
- 进入调度循环。
- 找到一个合适的G。
- 执行G,完成以后退出。
- work stealing 机制
当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。
- hand off 机制
当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。
39 ❤Go什么时候发生阻塞?阻塞时,调度器会怎么做。
- 用于原子、互斥量或通道操作导致goroutine阻塞,调度器将把当前阻塞的goroutine从本地运行队列LRQ换出,并重新调度其它goroutine;
- 由于网络请求和IO导致的阻塞,Go提供了网络轮询器(Netpoller)来处理,后台用epoll等技术实现IO多路复用。
其它回答:
- channel阻塞:当goroutine读写channel发生阻塞时,会调用gopark函数,该G脱离当前的M和P,调度器将新的G放入当前M。
- 系统调用:当某个G由于系统调用陷入内核态,该P就会脱离当前M,此时P会更新自己的状态为Psyscall,M与G相互绑定,进行系统调用。结束以后,若该P状态还是Psyscall,则直接关联该M和G,否则使用闲置的处理器处理该G。
- 系统监控:当某个G在P上运行的时间超过10ms时候,或者P处于Psyscall状态过长等情况就会调用retake函数,触发新的调度。
- 主动让出:由于是协作式调度,该G会主动让出当前的P(通过GoSched),更新状态为Grunnable,该P会调度队列中的G运行。
更多关于netpoller的内容可以参看:https://strikefreedom.top/go-netpoll-io-multiplexing-reactor
40 ❤Go中GMP有哪些状态?
G的状态:
_Gidle:刚刚被分配并且还没有被初始化,值为0,为创建goroutine后的默认值
_Grunnable: 没有执行代码,没有栈的所有权,存储在运行队列中,可能在某个P的本地队列或全局队列中(如上图)。
_Grunning: 正在执行代码的goroutine,拥有栈的所有权(如上图)。
_Gsyscall:正在执行系统调用,拥有栈的所有权,与P脱离,但是与某个M绑定,会在调用结束后被分配到运行队列(如上图)。
_Gwaiting:被阻塞的goroutine,阻塞在某个channel的发送或者接收队列(如上图)。
_Gdead: 当前goroutine未被使用,没有执行代码,可能有分配的栈,分布在空闲列表gFree,可能是一个刚刚初始化的goroutine,也可能是执行了goexit退出的goroutine(如上图)。
_Gcopystac:栈正在被拷贝,没有执行代码,不在运行队列上,执行权在系统线程上
_Gscan : GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在。
P的状态:
_Pidle :处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
_Prunning :被线程 M 持有,并且正在执行用户代码或者调度器(如上图)
_Psyscall:没有执行用户代码,当前线程陷入系统调用(如上图)
_Pgcstop :被线程 M 持有,当前处理器由于垃圾回收被停止
_Pdead :当前处理器已经不被使用
M的状态:
自旋线程:处于运行状态但是没有可执行goroutine的线程,数量最多为GOMAXPROC,若是数量大于GOMAXPROC就会进入休眠。
非自旋线程:处于运行状态有可执行goroutine的线程。
41 ❤GMP能不能去掉P层?会怎么样?
P层的作用
- 每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。
- 每个 P 相对的平衡上,在 GMP 模型中也实现了 Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。
参考资料:Go 面试官:GMP 模型,为什么要有 P? - 掘金
42 如果有一个G一直占用资源怎么办?什么是work stealing算法?
在Go语言中,Goroutine的调度是通过GMP模型实现的。当一个Goroutine一直占用资源,导致其他Goroutine无法得到执行时,GMP模型会从正常模式转变为饥饿模式,这意味着调度器会采取一些策略来解决这个问题。
其中,work stealing算法是一种常用的策略之一。它的原理是,当一个线程处于空闲状态时,它会从其他正在忙碌的线程的本地队列中偷取(steal)一个Goroutine任务来执行。这样做的目的是为了充分利用系统资源,提高并发执行的效率。
具体来说,当一个线程(P)的本地队列中没有可执行的Goroutine时,它会尝试从其他线程(P)的本地队列中偷取一个Goroutine任务。这样可以避免线程空闲,提高整体的并发性能。
至于是从其他P的队列中偷取Goroutine,还是从全局队列中偷取,这取决于具体的实现。在Go语言的调度器中,首先会尝试从其他P的本地队列中偷取Goroutine任务,如果没有可偷取的任务,则会从全局队列中获取。这样可以尽量减少全局队列的竞争,提高调度的效率。
总结起来,work stealing算法是一种用于解决Goroutine饥饿问题的策略,通过从其他线程的本地队列中偷取任务来提高并发执行的效率。具体实现中,会优先从其他P的本地队列中偷取任务,然后再从全局队列中获取。这样的设计可以充分利用系统资源,提高并发性能。
43 goroutine什么情况会发生内存泄漏?如何避免。
在Go中内存泄露分为暂时性内存泄露和永久性内存泄露。
暂时性内存泄露
- 获取长字符串中的一段导致长字符串未释放
- 获取长slice中的一段导致长slice未释放
- 在长slice新建slice导致泄漏
- 获取长slice中的一段导致长slice未释放:
- 当从一个长slice中获取一段子slice时,如果没有正确处理,子slice可能会持有对原长slice的引用,导致原长slice无法被释放。
- 这种情况下,子slice会持有原长slice的底层数组的引用,即使子slice被丢弃,原长slice的底层数组也无法被释放,造成内存泄漏。
- 避免内存泄漏的方法是使用
copy
函数将子slice复制到一个新的slice中,而不是直接引用原长slice。
- 在长slice新建slice导致泄漏:
- 当在一个长slice上再次使用切片操作创建一个新的slice时,新的slice会共享原长slice的底层数组,导致原长slice无法被释放。
- 这种情况下,新的slice会持有原长slice的底层数组的引用,即使新的slice被丢弃,原长slice的底层数组也无法被释放,造成内存泄漏。
- 避免内存泄漏的方法是使用
copy
函数将新的slice复制到一个新的slice中,而不是直接引用原长slice。为避免这两种情况下的内存泄漏,需要注意在处理slice时避免共享底层数组的引用,而是使用
copy
函数创建新的slice。确保在不需要使用的时候及时释放不再需要的内存,避免长期持有对底层数组的引用。
同时,合理使用
defer
语句、关闭资源、避免循环引用等方法也有助于避免内存泄漏的发生。
string相比切片少了一个容量的cap字段,可以把string当成一个只读的切片类型。获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏.
永久性内存泄露
- goroutine永久阻塞而导致泄漏
- time.Ticker未关闭导致泄漏
- 不正确使用Finalizer(Go版本的析构函数)导致泄漏
44 Go GC有几个阶段
目前的go GC采用三色标记法和混合写屏障技术。
Go GC有四个阶段:
- STW,开启混合写屏障,扫描栈对象;
- 将所有对象加入白色集合,从根对象开始,将其放入灰色集合。每次从灰色集合取出一个对象标记为黑色,然后遍历其子对象,标记为灰色,放入灰色集合;
- 如此循环直到灰色集合为空。剩余的白色对象就是需要清理的对象。
- STW,关闭混合写屏障;
- 在后台进行GC(并发)。
STW:stop the world,指暂停用户业务。
45 go竞态条件了解吗?
所谓竞态竞争,就是当两个或以上的goroutine访问相同资源时候,对资源进行读/写。
比如var a int = 0
,有两个协程分别对a+=1,我们发现最后a不一定为2。这就是竞态竞争。
通常我们可以用go run -race xx.go
来进行检测。
解决方法是,对临界区资源上锁,或者使用原子操作(atomics),原子操作的开销小于上锁。
46 如果若干个goroutine,有一个panic会怎么做?
有一个panic,那么剩余goroutine也会退出,程序退出。如果不想程序退出,那么必须通过调用 recover() 方法来捕获 panic 并恢复将要崩掉的程序。
参考理解:goroutine配上panic会怎样。
47 defer可以捕获goroutine的子goroutine吗?
不可以。它们处于不同的调度器P中。对于子goroutine,必须通过 recover() 机制来进行恢复,然后结合日志进行打印(或者通过channel传递error),下面是一个例子:
// 心跳函数
func Ping(ctx context.Context) error {... code ...go func() {defer func() {if r := recover(); r != nil {log.Errorc(ctx, "ping panic: %v, stack: %v", r, string(debug.Stack()))}}()... code ...}()... code ...return nil
}
48 gRPC是什么?
基于go的远程过程调用。RPC 框架的目标就是让远程服务调用更加简单、透明,RPC 框架负责屏蔽底层的传输方式(TCP 或者 UDP)、序列化方式(XML/Json/ 二进制)和通信细节。服务调用者可以像调用本地接口一样调用远程的服务提供者,而不需要关心底层通信细节和调用过程。
49 ❤channel 死锁的场景
- 当一个channel中没有数据,而直接读取时,会发生死锁:
q := make(chan int,2)
<-q
解决方案是采用select语句,再default放默认处理方式:
q := make(chan int,2)
select{case val:=<-q:default:...}
- 当channel数据满了,再尝试写数据会造成死锁:
q := make(chan int,2)
q<-1
q<-2
q<-3
解决方法,采用select
func main() {q := make(chan int, 2)q <- 1q <- 2select {case q <- 3:fmt.Println("ok")default:fmt.Println("wrong")}}
- 向一个关闭的channel写数据。
注意:一个已经关闭的channel,只能读数据,不能写数据。
参考资料:Golang关于channel死锁情况的汇总以及解决方案
50 ❤对已经关闭的chan进行读写会怎么样?
- 读已经关闭的chan能一直读到东西,但是读到的内容根据通道内关闭前是否有元素而不同。
- 如果chan关闭前,buffer内有元素还未读,会正确读到chan内的值,且返回的第二个bool值(是否读成功)为true。
- 如果chan关闭前,buffer内有元素已经被读完,chan内无值,接下来所有接收的值都会非阻塞直接成功,返回 channel 元素的零值,但是第二个bool值一直为false。
写已经关闭的chan会panic。
51 说说atomic底层怎么实现的.
atomic源码位于sync\atomic
。通过阅读源码可知,atomic采用CAS(CompareAndSwap)的方式实现的。所谓CAS就是使用了CPU中的原子性操作。在操作共享变量的时候,CAS不需要对其进行加锁,而是通过类似于乐观锁的方式进行检测,总是假设被操作的值未曾改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换。本质上是不断占用CPU资源来避免加锁的开销。
参考资料:Go语言的原子操作atomic - 编程猎人
52 channel底层实现?是否线程安全。
channel底层实现在src/runtime/chan.go中
channel内部是一个循环链表。内部包含buf, sendx, recvx, lock ,recvq, sendq几个部分;
buf是有缓冲的channel所特有的结构,用来存储缓存数据。是个循环链表;
- sendx和recvx用于记录buf这个循环链表中的发送或者接收的index;
- lock是个互斥锁;
- recvq和sendq分别是接收(<-channel)或者发送(channel <- xxx)的goroutine抽象出来的结构体(sudog)的队列。是个双向链表。
channel是线程安全的。
参考资料:Kitou:Golang 深度剖析 – channel的底层实现
53 map的底层实现。
源码位于src\runtime\map.go 中。
go的map和C++map不一样,底层实现是哈希表,包括两个部分:hmap和bucket。
里面最重要的是buckets(桶),buckets是一个指针,最终它指向的是一个结构体:
// A bucket for a Go map.
type bmap struct {tophash [bucketCnt]uint8
}
每个bucket固定包含8个key和value(可以查看源码bucketCnt=8).实现上面是一个固定的大小连续内存块,分成四部分:每个条目的状态,8个key值,8个value值,指向下个bucket的指针。
创建哈希表使用的是makemap函数.map 的一个关键点在于,哈希函数的选择。在程序启动时,会检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。这是在函数 alginit() 中完成,位于路径:src/runtime/alg.go 下。
map查找就是将key哈希后得到64位(64位机)用最后B个比特位计算在哪个桶。在 bucket 中,从前往后找到第一个空位。这样,在查找某个 key 时,先找到对应的桶,再去遍历 bucket 中的 key。
关于map的查找和扩容可以参考map的用法到map底层实现分析。
54 select的实现原理?
select源码位于src\runtime\select.go,最重要的scase 数据结构为:
type scase struct {c *hchan // chanelem unsafe.Pointer // data element
}
scase.c为当前case语句所操作的channel指针,这也说明了一个case语句只能操作一个channel。
scase.elem表示缓冲区地址:
- caseRecv : scase.elem表示读出channel的数据存放地址;
- caseSend : scase.elem表示将要写入channel的数据存放地址;
select的主要实现位于:select.go函数:其主要功能如下:
- 锁定scase语句中所有的channel
- 按照随机顺序检测scase中的channel是否ready
2.1 如果case可读,则读取channel中数据,解锁所有的channel,然后返回(case index, true)
2.2 如果case可写,则将数据写入channel,解锁所有的channel,然后返回(case index, false)
2.3 所有case都未ready,则解锁所有的channel,然后返回(default index, false)
- 所有case都未ready,且没有default语句
3.1 将当前协程加入到所有channel的等待队列
3.2 当将协程转入阻塞,等待被唤醒
- 唤醒后返回channel对应的case index
4.1 如果是读操作,解锁所有的channel,然后返回(case index, true)
4.2 如果是写操作,解锁所有的channel,然后返回(case index, false)
参考资料:[Go select的使用和实现原理](https://www.cnblogs.com/wuyepeng/p/13910678.html#:~:text=一、select简介. 1.Go的select语句是一种仅能用于channl发送和接收消息的专用语句,此语句运行期间是阻塞的;当select中没有case语句的时候,会阻塞当前groutine。. 2.select是Golang在语言层面提供的I%2FO多路复用的机制,其专门用来检测多个channel是否准备完毕:可读或可写。.,3.select语句中除default外,每个case操作一个channel,要么读要么写. 4.select语句中除default外,各case执行顺序是随机的. 5.select语句中如果没有default语句,则会阻塞等待任一case. 6.select语句中读操作要判断是否成功读取,关闭的channel也可以读取).
55 go的interface怎么实现的?
go interface源码在runtime\iface.go
中。
go的接口由两种类型实现iface和eface。iface是包含方法的接口,而eface不包含方法。
- iface
对应的数据结构是(位于src\runtime\runtime2.go
):
type iface struct {tab *itabdata unsafe.Pointer
}
可以简单理解为,tab表示接口的具体结构类型,而data是接口的值。
itab
:
type itab struct {inter *interfacetype //此属性用于定位到具体interface_type *_type //此属性用于定位到具体interfacehash uint32 // copy of _type.hash. Used for type switches._ [4]bytefun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}
属性interfacetype类似于_type,其作用就是interface的公共描述,类似的还有maptype、arraytype、chantype…其都是各个结构的公共描述,可以理解为一种外在的表现信息。interfaetype和type唯一确定了接口类型,而hash用于查询和类型判断。fun表示方法集。
- eface
与iface基本一致,但是用_type直接表示类型,这样的话就无法使用方法。
type eface struct {_type *_typedata unsafe.Pointer
}
这里篇幅有限,深入讨论可以看:深入研究 Go interface 底层实现
56 go的reflect底层实现
go reflect源码位于src\reflect\下面,作为一个库独立存在。反射是基于接口实现的。
Go反射有三大法则:
- 反射从接口映射到反射对象;
- 反射从反射对象映射到接口值;
- 只有值可以修改(settable),才可以修改反射对象。
Go反射基于上述三点实现。我们先从最核心的两个源文件入手type.go和value.go.
type用于获取当前值的类型。value用于获取当前的值。
参考资料:The Laws of Reflection, 图解go反射实现原理
57 go GC的原理知道吗?
如果需要从源码角度解释GC,推荐阅读(非常详细,图文并茂):
https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector/
58 go里用过哪些设计模式 ?
我写了一篇专门讲设计模式的文章:
https://zhuanlan.zhihu.com/p/542596378
59 go的调试/分析工具用过哪些。
go的自带工具链相当丰富,
- go cover : 测试代码覆盖率;
- godoc: 用于生成go文档;
- pprof:用于性能调优,针对cpu,内存和并发;
- race:用于竞争检测;
60 进程被kill,如何保证所有goroutine顺利退出
goroutine监听SIGKILL信号,一旦接收到SIGKILL,则立刻退出。可采用select方法。
var wg = &sync.WaitGroup{}func main() {wg.Add(1)go func() {c1 := make(chan os.Signal, 1)signal.Notify(c1, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)fmt.Printf("goroutine 1 receive a signal : %v\n\n", <-c1)wg.Done()}()wg.Wait()fmt.Printf("all groutine done!\n")
}
61 说说context包的作用?你用过哪些,原理知道吗?
context可以用来在goroutine之间传递上下文信息,相同的context可以传递给运行在不同goroutine中的函数,上下文对于多个goroutine同时使用是安全的,context包定义了上下文类型,可以使用background、TODO创建一个上下文,在函数调用链之间传播context,也可以使用WithDeadline、WithTimeout、WithCancel 或 WithValue 创建的修改副本替换它,听起来有点绕,其实总结起就是一句话:context的作用就是在不同的goroutine之间同步请求特定的数据、取消信号以及处理请求的截止日期。
关于context原理,可以参看:小白也能看懂的context包详解:从入门到精通
62 Go语言支持哪些数据类型?
Go语言支持丰富的数据类型,主要分为基本数据类型、复合数据类型和特殊数据类型三类:
- 基本数据类型:
- 布尔型(bool):取值为
true
或false
。 - 整数型:包括有符号整数(
int
、int8
、int16
、int32
、int64
)和无符号整数(uint
、uint8
、uint16
、uint32
、uint64
),其中int
和uint
的宽度取决于平台(32位或64位)。 - 浮点型:
float32
(32位)和float64
(64位),后者是默认浮点类型。 - 复数型:
complex64
(实部和虚部均为float32
)和complex128
(实部和虚部均为float64
)。 - 字符串型(string):UTF-8编码的不可变文本,本质是字节序列。
- 字节型:
byte
(uint8
的别名)和rune
(int32
的别名,用于存储Unicode字符)。
- 布尔型(bool):取值为
- 复合数据类型:
- 数组(array):固定长度的相同类型元素序列,长度是类型的一部分(如
[5]int
)。 - 切片(slice):可变长度的数组封装,支持动态扩容(如
[]int
)。 - 映射(map):键值对集合,键需为相同类型(如
map[string]int
)。 - 结构体(struct):用户自定义的聚合类型,可包含不同类型字段(如
type Point struct { X, Y int }
)。 - 指针(pointer):存储变量内存地址(如
*int
)。 - 函数(function):可作为类型传递(如
func(a int) int
)。 - 接口(interface):定义方法集合,实现多态(如
type Reader interface { Read() }
)。 - 通道(channel):用于协程(goroutine)间通信(如
chan int
)。
- 数组(array):固定长度的相同类型元素序列,长度是类型的一部分(如
- 特殊数据类型:
- 空值(nil):表示无值,常用于指针、切片等。
- 错误(error):内置接口类型,用于错误处理。
63 如何在Go中实现变量的类型转换?
在Go中,类型转换需显式操作,不支持隐式转换,主要方法包括:
- 显式类型转换(T(v)):
- 语法:
目标类型(表达式)
,例如将int
转为float64
:f := float64(42)
。 - 适用场景:数值类型间转换(如整型与浮点型),但需注意精度丢失(如
float
转int
会截断小数)。
- 语法:
- 使用
strconv
包处理字符串转换:- 字符串转基本类型:如
strconv.Atoi("123")
将字符串转为int
,返回值和错误信息。 - 基本类型转字符串:如
strconv.Itoa(123)
或strconv.FormatFloat(3.14, 'f', 2, 64)
。
- 字符串转基本类型:如
- 类型断言(针对接口类型):
- 语法:
value, ok := interfaceVar.(具体类型)
,例如从空接口interface{}
提取字符串:s, ok := i.(string)
。 - 失败时
ok
为false
,若忽略ok
可能触发panic
。
- 语法:
- 强制转换(需谨慎):
- 使用
unsafe.Pointer
实现跨类型指针转换,如newPtr := (*T2)(unsafe.Pointer(ptr))
,但可能引发安全问题。
- 使用
注意事项:
- 不兼容类型(如
float
转[]byte
)会编译失败。 - 字符串与
[]byte
可直接互转:b := []byte(s)
或s := string(b)
。
64 const
和var
的区别是什么?
const
和 var
均用于声明标识符,但核心区别在于可变性与初始化:
- 可变性:
var
声明变量,值可修改(如var x int = 10; x = 20
)。const
声明常量,值不可修改(如const Pi = 3.14; Pi = 3.14
会编译错误)。
- 初始化要求:
const
必须在声明时赋值,且值需为编译时可确定的表达式(如字面量或常量运算)。var
可不初始化(默认为零值,如int
的零值为0
),或后续赋值。
- 类型处理:
const
支持无类型常量(如const a = 42
),类型由上下文推断;var
需显式或隐式指定类型。- 常量可参与不同类型运算(如
const b = 1.5 + 2
),而变量需显式转换。
- 作用域与批量声明:
- 两者均支持批量声明(如
var (x int; y string)
或const (A=1; B=2)
)。 const
常用于枚举(结合iota
),如const (Sunday = iota; Monday)
。
- 两者均支持批量声明(如
总结:const
适用于固定值(如配置参数),var
用于需变化的场景。
65 select
语句有什么用途?
select
是Go中处理多通道(channel)操作的控制结构,核心用途包括:
- 多路复用(监听多个channel):
- 同时等待多个channel的读写操作,随机选择一个就绪的case执行(如
case <-ch1
或case ch2 <- data
),避免阻塞。
- 同时等待多个channel的读写操作,随机选择一个就绪的case执行(如
- 超时控制:
- 结合
time.After
实现超时机制,例如:select { case <-dataChan: ...; case <-time.After(3*time.Second): ... }
。
- 结合
- 非阻塞操作:
- 添加
default
分支实现非阻塞,当无channel就绪时立即执行default
(如用于检查channel状态)。
- 添加
- 程序退出与错误处理:
- 监听退出信号channel(如
<-doneChan
),实现优雅终止。 - 检测channel关闭(如
case v, ok := <-ch
中ok
标识状态)。
- 监听退出信号channel(如
注意事项:
select
的case必须是channel操作,且随机选择就绪case以保证公平性。- 无
default
且无channel就绪时,会阻塞直到至少一个就绪。
典型场景:高并发服务中协调goroutine通信,如API网关或消息队列。
66 sync.Pool
有什么用途?
sync.Pool
是Go标准库的对象池,用于优化临时对象管理,主要用途包括:
- 减少内存分配与GC压力:
- 复用短期对象(如缓冲区、解析器状态),避免频繁分配和垃圾回收,提升性能(尤其在高并发场景)。
- 实现原理:
- 层级缓存:每个处理器(P)维护私有队列(
private
)和共享环形队列(shared
),优先本地存取。 - GC敏感性:池中对象可能在两次GC间被回收,防止内存泄漏。
- 并发安全:通过无锁设计减少竞争(如
Get()
和Put()
方法)。
- 层级缓存:每个处理器(P)维护私有队列(
- 适用场景:
- 高频创建/销毁对象(如JSON解码中的临时结构体)。
- 资源复用(如数据库连接池),但需注意对象重置(
Put()
前清除状态)。
67 Go 常用的开发工具有哪些?
Go开发工具涵盖IDE、编辑器、调试和依赖管理等,常用工具包括:
- IDE与编辑器:
- GoLand:JetBrains出品,提供智能补全、调试、重构等专业功能,适合大型项目。
- Visual Studio Code (VS Code):轻量级,通过Go插件(如Go Extension Pack)支持语法高亮、调试和测试,免费且跨平台。
- 包管理与构建:
- Go Modules:官方依赖管理工具(Go 1.11+),自动处理版本和依赖冲突(如
go mod init
)。 - Go Build/Test:内置编译和测试工具(如
go build
编译,go test
运行测试)。
- Go Modules:官方依赖管理工具(Go 1.11+),自动处理版本和依赖冲突(如
- 调试与分析:
- Delve:专为Go设计的调试器,支持断点、变量检查,可集成到IDE。
- Pprof:性能分析工具,用于CPU和内存剖析。
- 代码质量工具:
- Golint:静态代码分析,检查风格和潜在错误。
- GORM:ORM库,简化数据库操作。
建议:初学者用VS Code快速上手,专业开发者选GoLand;调试首选Delve,依赖管理用Go Modules。
68 Go 常用的测试工具有哪些?
Go 的测试工具主要分为以下几类,覆盖单元测试、覆盖率分析、性能测试和集成测试等场景:
- 单元测试工具:
testing
包:Go 内置的标准库,提供基础断言和测试函数结构,例如TestXxx(t *testing.T)
。testify/assert
:第三方库,扩展断言功能(如Equal()
、Nil()
),支持自定义错误消息,简化测试代码。
- 代码覆盖率工具:
go test -cover
:生成代码覆盖率报告,-coverprofile
输出详细数据文件,结合go tool cover -html
可视化分析。go-cover
:第三方工具,提供更丰富的 HTML 报告和集成支持。
- 基准测试工具:
testing/benchmark
:内置基准测试框架,通过BenchmarkXxx(b *testing.B)
测量代码性能,支持-benchmem
分析内存分配。go-benchmark
:第三方库,简化性能测试编写和结果解析。
- 集成测试与 Mock 工具:
gocheck
/echo/test
:用于 HTTP 服务集成测试,模拟请求和验证响应。- Mock 工具:
gock
:HTTP 请求模拟,录制和重放网络交互。go-sqlmock
:数据库模拟,替代真实 SQL 驱动进行测试。miniredis
:Redis 内存模拟,用于无依赖测试。
- BDD 测试框架:
Ginkgo
/Gomega
:行为驱动开发(BDD)风格框架,提升测试可读性。
💡 面试提示:重点掌握
testing
和testify
的单元测试实践,覆盖率与基准测试是高频考点。Mock 工具在微服务测试中尤为重要。
69 Go 常用的调试工具有哪些?
Go 调试工具涵盖日志、调试器和性能分析,适用于不同场景:
- 内置工具:
fmt
/log
包:通过Println
或log.Println
输出变量值和执行路径,适合快速定位简单问题。go test
:运行单元测试并输出详细日志(-v
参数),结合-run
过滤特定测试用例。
- 外部调试器:
- Delve (
dlv
):- 功能:设置断点(
break
)、单步执行(next
/step
)、查看变量(print
)。 - 使用:
dlv debug main.go
启动交互会话,或集成到 CI/CD 流程。
- 功能:设置断点(
- Delve (
- IDE 集成调试:
- Goland:内置调试支持,可视化断点管理和堆栈跟踪。
- VSCode:通过 Go 插件和
launch.json
配置调试环境,支持远程调试。
- 性能分析工具:
pprof
:分析 CPU 和内存瓶颈,生成火焰图(go test -cpuprofile
)。trace
:追踪协程调度和阻塞事件,优化并发性能。
⚠️ 注意:Delve 是 Go 调试首选工具,尤其在并发调试中优于 GDB。生产环境慎用调试器,优先依赖日志和测试。
70 反射机制是什么?如何使用?
反射机制允许程序在运行时动态获取类型信息、修改变量值或调用方法,通过 reflect
包实现。核心是 Type
(类型描述)和 Value
(值操作)两个结构。
使用方式:
-
获取类型信息:
t := reflect.TypeOf(variable) // 获取类型 fmt.Println(t.Name(), t.Kind()) // 输出类型名和种类(如 struct)
遍历结构体字段:t.NumField()和t.Field(i)。
-
操作变量值:
v := reflect.ValueOf(&variable).Elem() // 获取可修改的 Value if v.Kind() == reflect.Struct { v.FieldByName("Name").SetString("NewName") // 修改字段值 }
需通过指针调用 Elem(),避免值拷贝。
-
动态调用方法:
method := v.MethodByName("MethodName") args := []reflect.Value{reflect.ValueOf(arg1)} result := method.Call(args) // 调用并获取返回值
适用于工厂模式或插件化系统。
应用场景:
- JSON 序列化/反序列化(如
encoding/json
包)。 - 框架中动态处理接口类型(如 ORM 映射字段)。
⚠️ 性能提示:反射有运行时开销,频繁操作时优先考虑代码生成或接口设计。
71 类型断言是什么?如何使用?
类型断言用于从接口变量中提取具体类型值,语法为 value, ok := interfaceVar.(TargetType)
。
使用方式:
-
基础用法:
var i interface{} = "hello" s, ok := i.(string) // ok=true 时 s 为字符串值 if !ok { // 处理类型不匹配 }
失败时 ok=false,避免直接 s := i.(string) 引发 panic。
-
结合
switch
类型判断:switch v := i.(type) { case int: fmt.Println("int:", v) case string: fmt.Println("string:", v) default: fmt.Println("unknown type") }
简化多类型分支处理。
应用场景:
- 处理
interface{}
空接口(如 JSON 解析后map[string]interface{}
的字段提取)。 - 接口多态实现中获取具体类型方法(如
Shape
接口转为Circle
调用Circumference()
)。
💡 技巧:JSON 数字默认解析为
float64
,需断言后转换:int(data["age"].(float64))
。
72 闭包是什么?
闭包是匿名函数与其外部作用域变量的绑定,即使外部函数执行结束,闭包仍能访问和修改这些变量。
特性与机制:
-
状态封装:闭包捕获的变量生命周期延长至闭包本身销毁(存储在堆上),例如计数器:
func counter() func() int { count := 0 return func() int { count++; return count } } c := counter() c() // 1, c() // 2 (count 状态持续)
-
词法作用域:闭包在定义时捕获变量引用(非值拷贝),循环中使用需避免共享变量问题。
主要应用场景:
- 回调与异步处理:如 HTTP 中间件(
loggingMiddleware
闭包记录请求)。 - 函数工厂:生成定制化函数(
multiplier(2)
返回加倍闭包)。 - 资源清理:
defer
结合闭包确保资源释放(如解锁互斥锁)。 - 装饰器模式:扩展函数功能(如
logExecutionTime
闭包包装耗时函数)。
⚠️ 注意:闭包可能导致循环引用或内存泄漏(如大对象被闭包长期引用),并发场景需加锁保证安全。
74 Go与Python的比较?
Go和Python在设计和应用场景上有显著差异:
- 性能与执行模型:Go是编译型语言,编译为机器码直接运行,执行速度更快(尤其在CPU密集型任务中),平均响应时间比Python快数倍。Python是解释型语言,依赖解释器逐行执行,运行时性能较低,但开发迭代快。
- 并发处理:Go内置轻量级协程(goroutine)和通道(channel),支持高并发(如百万级任务),资源开销小(每个goroutine约2KB内存)。Python受全局解释器锁(GIL)限制,多线程效率低,需依赖多进程或异步库(如asyncio),并发能力较弱。
- 内存管理:Go的垃圾回收(GC)优化较好,暂停时间短(<1ms),适合长时间运行服务。Python的GC机制简单,但内存占用高(相同任务内存消耗是Go的3倍以上),易引发性能波动。
- 开发效率与生态:Python语法简洁,库丰富(如NumPy、Django),适合快速原型开发、数据分析和AI领域。Go语法严谨,标准库强大(如HTTP、JSON解析),但第三方生态不如Python成熟,更适合高性能后端和微服务。
- 适用场景:Python适用于I/O密集型任务(如Web快速开发、脚本),Go更适合高并发系统(如API网关、云原生应用)。
75 Go与Java的比较?
Go和Java在性能和架构上各有优势:
- 启动速度与部署:Go静态编译为单一二进制文件,启动快(毫秒级),无外部依赖,适合容器化部署。Java需JVM环境,启动慢(含类加载和JIT编译),部署复杂。
- 并发模型:Go的goroutine轻量(开销仅KB级),基于CSP模型实现高效并发;Java依赖线程池(线程开销MB级),需显式同步(如synchronized),高并发下资源消耗大。
- 内存管理:Go的GC采用并发标记清除,暂停时间可控;Java的GC(如G1、ZGC)更成熟,适合长期运行的大内存应用,但配置不当易导致性能波动。
- 运行时性能:Go直接执行机器码,短期任务响应快;Java依赖JIT优化,长期运行后性能更优(如大数据处理)。
- 生态与适用性:Java生态庞大(Spring、Hibernate),适合企业级复杂系统;Go生态较新(如Gin框架),更契合微服务和云原生场景。
76 Go与C++的比较?
Go和C++在系统级开发中定位不同:
- 性能:C++编译后接近硬件层,执行速度最快(尤其计算密集型任务),适合游戏引擎等极致性能场景。Go性能接近C++,但略低(约慢10-30%),优势在于并发和安全性。
- 内存安全:Go通过GC自动管理内存,减少泄漏风险;C++需手动管理,易引发空指针等问题,但提供更精细控制。
- 开发效率:Go语法简洁,编译快(秒级),内置工具链(如gofmt);C++编译慢(模板实例化复杂),学习曲线陡峭。
- 并发支持:Go原生支持goroutine和channel,简化并发编程;C++需依赖第三方库(如Boost)或标准线程,实现复杂。
- 适用场景:C++适用于操作系统、嵌入式等底层开发;Go更适合网络服务和高并发分布式系统。
77 Go与Ruby的比较?
Go和Ruby在Web开发中差异显著:
- 性能:Go是编译型语言,执行速度快(响应时间毫秒级),Ruby解释执行速度慢(响应时间常超10ms)。高并发下,Go的吞吐量是Ruby的5倍以上。
- 并发能力:Go内置goroutine,轻松处理万级并发;Ruby依赖进程/线程模型(如Rails线程池),效率低且资源占用高。
- 内存管理:Go的GC高效,内存占用稳定;Ruby的GC易引发停顿,高负载下需频繁重启Worker进程。
- 生态:Ruby生态成熟(如Rails框架),适合快速开发中小型Web应用;Go生态较新,但更适合构建高性能API和微服务。
- 开发体验:Ruby语法灵活,开发效率高;Go强调简洁性,适合团队协作和大型项目维护。
78 Go与PHP的比较?
Go和PHP在Web后端领域各有侧重:
- 性能:Go编译执行,响应快(平均0.8ms),PHP解释执行速度慢(平均2.5ms),高并发下Go的QPS是PHP的3倍以上。
- 并发处理:Go原生支持高并发(goroutine开销小),PHP需依赖Swoole扩展模拟协程,效率较低。
- 内存占用:Go内存管理高效(50MB/协程),PHP进程占用高(180MB/进程),易引发资源瓶颈。
- 开发效率:PHP上手快(如Laravel框架),适合快速迭代;Go开发周期较长,但代码可维护性强。
- 适用场景:PHP适合CMS、中小型网站;Go适合云原生、高并发服务(如API网关)。
79 Go与Rust的比较?
Go和Rust在系统编程中代表不同设计哲学:
- 内存安全:Rust通过所有权模型在编译时保证内存安全(无GC),避免数据竞争;Go依赖GC,可能引入延迟。
- 性能:Rust性能接近C++(零成本抽象),CPU密集型任务更快;Go性能优秀但略低于Rust,优势在开发效率。
- 并发模型:Go的goroutine易用,适合快速实现高并发;Rust的异步模型(如tokio)性能更高(延迟降低60%),但实现复杂。
- 开发体验:Go语法简单,学习曲线平缓(约2周);Rust所有权机制严格,学习成本高(约8周)。
- 适用场景:Go适合微服务和快速开发;Rust适合嵌入式、操作系统等对安全和性能要求极高的领域。
80 数组和切片的区别?
数组(Array)和切片(Slice)是Go语言中两种不同的数据结构,核心区别如下:
- 长度固定性:
- 数组的长度在声明时确定,无法动态改变(如
var arr [3]int
)。 - 切片的长度可变,支持通过
append
动态扩展(如slice := []int{1, 2, 3}
)。
- 数组的长度在声明时确定,无法动态改变(如
- 内存分配与传递:
- 数组是值类型,赋值或传参时会复制整个数据,内存开销大。
- 切片是引用类型,底层包含指向数组的指针、长度和容量,传递时仅复制结构体(24字节),共享底层数组。
- 容量机制:
- 数组无容量概念,大小固定。
- 切片有容量(
cap
),表示底层数组可容纳的元素上限,支持自动扩容。
- 初始化方式:
- 数组需显式指定长度(如
[3]int{1,2,3}
)。 - 切片可通过
make
、字面量或从数组截取(如arr[1:4]
)创建。
- 数组需显式指定长度(如
面试提示:强调切片基于数组封装,但类型不兼容(
[3]int
与[]int
是不同类型),优先使用切片以提升灵活性。
81 切片的底层结构
切片的底层是一个结构体,包含三个字段:
type slice struct {array unsafe.Pointer // 指向底层数组的指针len int // 当前元素数量(长度)cap int // 底层数组总容量
}
82 map的底层结构
Map 的底层由哈希表实现,核心结构为 hmap
和 bmap
:
type hmap struct {count int // 元素数量B uint8 // 桶数量的对数(桶数 = 2^B)buckets unsafe.Pointer // 指向桶数组的指针oldbuckets unsafe.Pointer // 扩容时旧桶指针hash0 uint32 // 哈希种子// ... 其他字段
}type bmap struct {tophash [8]uint8 // 存储哈希值高8位(快速定位)keys [8]keytype // 键数组values [8]valuetype // 值数组(内存紧凑)overflow *bmap // 溢出桶指针(链表结构)
}
83 map定位过程?
Map 的键值定位流程如下:
- 计算哈希值:对键调用哈希函数(如
hash(key)
),生成64位哈希值。 - 确定桶位置:用哈希低位定位桶(
桶索引 = hash & (1<<B - 1)
)。 - 匹配 tophash:在桶的 tophash数组中匹配哈希值高8位:
- 匹配成功:比较键值是否相等(解决哈希碰撞)。
- 匹配失败:检查溢出桶链表。
- 处理冲突:若当前桶无匹配,遍历溢出桶链表直至找到或结束。
⏱️ 时间复杂度:平均 O(1),最坏 O(n)(哈希退化时退化为链表遍历)。
84 为什么map是无序的?
Map 的无序性由设计机制决定:
- 哈希表本质:元素存储位置由哈希值决定,与插入顺序无关。
- 随机遍历起始点:每次遍历随机选择起始桶和偏移量,避免开发者依赖顺序。
- 扩容扰动:扩容时元素重新哈希到新桶,顺序彻底改变。
- 设计意图:强制开发者不依赖顺序,防止潜在逻辑错误(如顺序敏感场景应改用切片排序)。
解决方案:需有序遍历时,提取键排序后访问(如
sort.Strings(keys)
)。
85 map是并发安全的吗?
Map 非并发安全,原因与解决方案如下:
- 风险点:并发读写可能触发
fatal error: concurrent map read and map write
。 - 解决方案:
- 互斥锁:用
sync.Mutex
或sync.RWMutex
包裹操作(读多写少时用读写锁)。 - sync.Map:专为并发场景设计,适合读多写少或键值稳定的场景(如配置管理)。
- 分区锁:对大Map分片加锁(如
[]map
+ 分片锁)提升性能。
- 互斥锁:用
⚠️ 注意:
sync.Map
的Range
方法保证遍历时一致性,但性能略低于手动分片。
86 channel的底层结构
Channel 的底层由 hchan
结构体表示:
type hchan struct {buf unsafe.Pointer // 环形缓冲区指针(有缓冲channel)sendx uint // 发送索引recvx uint // 接收索引sendq waitq // 发送等待队列(sudog链表)recvq waitq // 接收等待队列lock mutex // 互斥锁closed uint32 // 关闭标志// ... 其他字段
}
87 defer的特点
Defer 的关键特性如下:
-
延迟执行:函数退出前按声明顺序的逆序执行(LIFO)。
-
参数预求值:参数在注册时计算(如
defer fmt.Println(i)
的i
立即求值)。 -
栈式管理:多个defer压入链表,函数返回时从链表头部依次执行。
-
应用场景:
- 资源释放(如文件关闭
defer file.Close()
)。 - 锁解锁(
defer mu.Unlock()
)。 - 捕获panic(
defer func() { recover() }()
)。
- 资源释放(如文件关闭
-
返回值修改:若函数返回具名变量,defer可修改返回值(通过闭包捕获变量):
func f() (x int) {defer func() { x++ }()return 1 // 实际返回2 }
性能影响:Go 1.14+ 优化后开销降低,但高频短函数中仍建议避免滥用。
88 解释 Go 语言中的指针,并描述其与 C/C++ 指针的主要区别
指针是存储变量内存地址的变量。Go 语言中的指针与 C/C++ 指针的主要区别在于 Go 没有指针运算,不能对指针进行加减等操作,这减少了指针操作的风险。
89 如何优化内存使用?
90 如何优化垃圾回收?
91 单例模式实现
使用 sync.Once 实现线程安全的单例模式。
package main
import ("fmt""sync"
)
type Singleton struct{}
var instance *Singleton
var once sync.Once
func GetInstance() *Singleton {once.Do(func() {instance = &Singleton{}})return instance
}
func main() {a := GetInstance()b := GetInstance()fmt.Println(a == b) // 输出 true
}
92 读写锁实现
使用 sync.RWMutex 实现读写锁,保证数据读写的线程安全。
package main
import ("fmt""sync""time"
)
type Data struct {sync.RWMutexvalue int
}
func (d *Data) Read() int {d.RLock()defer d.RUnlock()return d.value
}
func (d *Data) Write(v int) {d.Lock()defer d.Unlock()d.value = v
}
func main() {data := &Data{}var wg sync.WaitGroupfor i := 0; i < 10; i++ {wg.Add(1)go func() {defer wg.Done()for j := 0; j < 5; j++ {data.Write(j)time.Sleep(10 * time.Millisecond)}}()}for i := 0; i < 10; i++ {wg.Add(1)go func() {defer wg.Done()for j := 0; j < 5; j++ {fmt.Println(data.Read())time.Sleep(10 * time.Millisecond)}}()}wg.Wait()
}
93 使用 channel 实现消息队列
使用 channel 实现一个简单的消息队列,支持并发生产者和消费者。
package main
import ("fmt""sync""time"
)
type MessageQueue struct {queue chan intwg sync.WaitGroup
}
func NewMessageQueue(size int) *MessageQueue {return &MessageQueue{queue: make(chan int, size),}
}
func (mq *MessageQueue) Produce(v int) {mq.queue <- v
}
func (mq *MessageQueue) Consume() int {return <-mq.queue
}
func main() {mq := NewMessageQueue(10)for i := 0; i < 3; i++ {go func() {for j := 0; j < 10; j++ {mq.Produce(j)time.Sleep(100 * time.Millisecond)}}()}for i := 0; i < 3; i++ {go func() {for j := 0; j < 10; j++ {fmt.Println(mq.Consume())time.Sleep(100 * time.Millisecond)}}()}time.Sleep(5 * time.Second)
}
94 使用 timer 实现定时任务
使用 timer 实现每 2 秒打印一次当前时间。
package main
import ("fmt""time"
)
func main() {timer := time.NewTimer(2 * time.Second)for {select {case <-timer.C:fmt.Println(time.Now().Format("2006-01-02 15:04:05"))timer.Reset(2 * time.Second)}}
}
95 使用 context 实现取消机制
使用 context 实现取消机制,取消多个 goroutine 的执行。
package main
import ("context""fmt""time"
)
func worker(ctx context.Context, id int) {for {select {case <-ctx.Done():fmt.Printf("worker %d: received cancel signal\n", id)returndefault:fmt.Printf("worker %d: working\n", id)time.Sleep(1 * time.Second)}}
}
func main() {ctx, cancel := context.WithCancel(context.Background())for i := 0; i < 3; i++ {go worker(ctx, i)}time.Sleep(3 * time.Second)cancel()time.Sleep(1 * time.Second)
}
96 实现使用字符串函数名,调用函数。
思路:采用反射的Call方法实现。
package main
import ("fmt""reflect"
)type Animal struct{}func (a *Animal) Eat(){fmt.Println("Eat")
}func main(){a := Animal{}reflect.ValueOf(&a).MethodByName("Eat").Call([]reflect.Value{})
}
97 有三个函数,分别打印"cat", “fish”,"dog"要求每一个函数都用一个goroutine,按照顺序打印100次。
此题目考察channel,用三个无缓冲channel,如果一个channel收到信号则通知下一个。
package mainimport ("fmt""time"
)var dog = make(chan struct{})
var cat = make(chan struct{})
var fish = make(chan struct{})func Dog() {<-fishfmt.Println("dog")dog <- struct{}{}
}func Cat() {<-dogfmt.Println("cat")cat <- struct{}{}
}func Fish() {<-catfmt.Println("fish")fish <- struct{}{}
}func main() {for i := 0; i < 100; i++ {go Dog()go Cat()go Fish()}fish <- struct{}{}time.Sleep(10 * time.Second)
}
98 两个协程交替打印10个字母和数字
思路:采用channel来协调goroutine之间顺序。
主线程一般要waitGroup等待协程退出,这里简化了一下直接sleep。
package mainimport ("fmt""time"
)var word = make(chan struct{}, 1)
var num = make(chan struct{}, 1)func printNums() {for i := 0; i < 10; i++ {<-wordfmt.Println(1)num <- struct{}{}}
}
func printWords() {for i := 0; i < 10; i++ {<-numfmt.Println("a")word <- struct{}{}}
}func main() {num <- struct{}{}go printNums()go printWords()time.Sleep(time.Second * 1)
}
99 启动 2个groutine 2秒后取消, 第一个协程1秒执行完,第二个协程3秒执行完。
思路:采用ctx, _ := context.WithTimeout(context.Background(), time.Second*2)实现2s取消。协程执行完后通过channel通知,是否超时。
package mainimport ("context""fmt""time"
)func f1(in chan struct{}) {time.Sleep(1 * time.Second)in <- struct{}{}}func f2(in chan struct{}) {time.Sleep(3 * time.Second)in <- struct{}{}
}func main() {ch1 := make(chan struct{})ch2 := make(chan struct{})ctx, _ := context.WithTimeout(context.Background(), 2*time.Second)go func() {go f1(ch1)select {case <-ctx.Done():fmt.Println("f1 timeout")breakcase <-ch1:fmt.Println("f1 done")}}()go func() {go f2(ch2)select {case <-ctx.Done():fmt.Println("f2 timeout")breakcase <-ch2:fmt.Println("f2 done")}}()time.Sleep(time.Second * 5)
}
100 当select监控多个chan同时到达就绪态时,如何先执行某个任务?
可以在子case再加一个for select语句。
func priority_select(ch1, ch2 <-chan string) {for {select {case val := <-ch1:fmt.Println(val)case val2 := <-ch2:priority:for {select {case val1 := <-ch1:fmt.Println(val1)default:break priority}}fmt.Println(val2)}}}
欢迎关注 ❤
我们搞了一个免费的面试真题共享群,互通有无,一起刷题进步。
没准能让你能刷到自己意向公司的最新面试题呢。
感兴趣的朋友们可以私信我,备注:面试群。