这一篇文章中主要介绍了Go语言面试中常用的面试题,这些面试题都是按照收录顺序排布的,没有再进一步的组织和调整。
希望在未来有机会和时间的时候可以对它们做一个进一步的分析和说明。
专题系列文章:
=
和 :=
的区别?
=
是对已经声明的变量进行赋值,:=
是利用赋值声明和定义变量。
指针的作用
一个指针可以指向任意变量的地址,它所指向的地址在32位或64位机器上分别固定占4或8个字节。指针的作用有:
- 获取变量的值
|
|
- 改变变量的值
|
|
- 用指针替代值传入函数,比如类的接收器就是这样的。
|
|
Go 允许多个返回值吗?
可以。Go可以使用类似于元组的结构返回多个返回值。
Go 有异常类型吗?
有。Go用error
类型代替try…catch语句,这样可以节省资源。同时增加代码可读性:
|
|
也可以用errors.New()
来定义自己的异常。errors.Error()
会返回异常的字符串表示。只要实现error
接口就可以定义自己的异常。
|
|
什么是协程(Goroutine)
协程是用户态轻量级线程,它是线程调度的基本单位。通常在函数前加上go关键字就能实现并发。一个Goroutine会以一个很小的栈启动2KB或4KB,当遇到栈空间不足时,栈会自动伸缩, 因此可以轻易实现成千上万个goroutine同时启动。
【熟练级别】如何高效地拼接字符串
拼接字符串的方式有:+
, fmt.Sprintf
, strings.Builder
, bytes.Buffer
, strings.Join
。
+
使用+
操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。
fmt.Sprintf
由于采用了接口参数,必须要用反射获取值,因此有性能损耗。
strings.Builder
用WriteString()
进行拼接,内部实现是指针+切片,同时String()
返回拼接后的字符串,它是直接把[]byte
转换为string
,从而避免变量拷贝。
bytes.Buffer
bytes.Buffer
是一个一个缓冲byte
类型的缓冲器,这个缓冲器里存放着都是byte
,bytes.buffer
底层也是一个[]byte
切片。
strings.Join
strings.Join
也是基于strings.builder
来实现的,并且可以自定义分隔符,在Join
方法内调用了b.Grow(n)
方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice
的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。
性能比较:
strings.Join
≈ strings.Builder
> bytes.Buffer
> +
> fmt.Sprintf
5种拼接方法的实例代码
|
|
什么是 rune
类型
ASCII 码只需要 7 bit 就可以完整地表示,但只能表示英文字母在内的128个字符,为了表示世界上大部分的文字系统,发明了 Unicode, 它是ASCII的超集,包含世界上书写系统中存在的所有字符,并为每个代码分配一个标准编号(称为Unicode CodePoint),在 Go 语言中称之为 rune
,是 int32
类型的别名。
Go 语言中,字符串的底层表示是 byte
(8 bit) 序列,而非 rune
(32 bit) 序列。
|
|
如何判断 map
中是否包含某个 key ?
|
|
Go 支持默认参数或可选参数吗?
不支持。但是可以利用结构体参数,或者…传入参数切片数组。
|
|
defer
的执行顺序
defer
执行顺序和调用顺序相反,类似于栈后进先出(LIFO)。
defer
在return
之后执行,但在函数退出之前,defer
可以修改返回值。下面是一个例子:
|
|
上面这个例子中,test返回值并没有修改,这是由于Go的返回机制决定的,执行return
语句后,Go会创建一个临时变量保存返回值。如果是有名返回(也就是指明返回值func test() (i int)
)。
|
|
这个例子中,返回值被修改了。对于有名返回值的函数,执行 return
语句时,并不会再创建临时变量保存,因此,defer
语句修改了 i
,即对返回值产生了影响。
如何交换 2 个变量的值?
对于变量而言a,b = b,a
; 对于指针而言*a,*b = *b, *a
。
Go 语言中 tag 的用处?
tag可以为结构体成员提供属性。常见的:
- json序列化或反序列化时字段的名称。
- db:
sqlx
模块中对应的数据库字段名。 - form:
gin
框架中对应的前端的数据字段名。 - binding: 搭配
form
使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为required
代表没找到返回错误给前端。
如何获取一个结构体的所有tag?
利用反射:
|
|
上述例子中,reflect.TypeOf
方法获取对象的类型,之后NumField()
获取结构体成员的数量。 通过Field(i)
获取第i
个成员的名字。 再通过其Tag
方法获得标签。
如何判断 2 个字符串切片(slice) 是相等的?
reflect.DeepEqual()
, 但反射非常影响性能。
结构体打印时,%v
和 %+v
的区别
%v
输出结构体各成员的值;%+v
输出结构体各成员的名称和值;%#v
输出结构体名称和结构体各成员的名称和值。
Go 语言中如何表示枚举值(enums)?
可以用常量定义枚举。在常量中用iota
可以表示枚举索引,iota
从0
开始。
|
|
【熟练级别】空 struct{}
的用途
- 用
map
模拟一个set
,那么就要把值置为struct{}
,struct{}
本身不占任何空间,可以避免任何多余的内存分配。
|
|
- 有时候给通道发送一个空结构体,
channel<-struct{}{}
,也是节省了空间。
|
|
- 仅有方法的结构体。
|
|
Go里面的int
和int32
是同一个概念吗?
不是一个概念!千万不能混淆。Go语言中的int
的大小是和操作系统位数相关的,如果是32位操作系统,int
类型的大小就是4字节。如果是64位操作系统,int
类型的大小就是8个字节。除此之外uint也
与操作系统有关。
int8
占1个字节,int16
占2个字节,int32
占4个字节,int64
占8个字节。
init()
函数是什么时候执行的?
在main
函数之前执行。
init()
函数是Go初始化的一部分,由runtime
初始化每个导入的包,初始化不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。
每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的init()
函数。同一个包,甚至是同一个源文件可以有多个init()
函数。init()
函数没有入参和返回值,不能被其他函数调用,同一个包内多个init()
函数的执行顺序不作保证。
执行顺序:import
–> const
–> var
–> init()
–> main()
一个文件可以有多个init()
函数。
【熟练级别】如何知道一个对象是分配在栈上还是堆上?
Go和C++不同,Go局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢?
|
|
关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。
2 个 interface
可以比较吗 ?
Go 语言中,interface
的内部实现包含了 2 个字段,类型 T
和 值 V
,interface
可以使用 ==
或 !=
比较。2 个 interface
相等有以下 2 种情况
- 两个
interface
均等于nil
(此时V
和T
都处于 unset 状态) - 类型
T
相同,且对应的值V
相等。
看下面的例子:
|
|
stu1
和 stu2
对应的类型是 *Stu
,值是 Stu
结构体的地址,两个地址不同,因此结果为 false
。
stu3
和 stu4
对应的类型是 Stu
,值是 Stu
结构体,且各字段相等,因此结果为 true
。
2 个 nil
可能不相等吗?
可能不等。interface
在运行时绑定值,只有值为nil
接口值才为nil
,但是与指针的nil
不相等。举个例子:
|
|
两者并不相同。总结:两个nil
只有在类型相同时才相等。
【精通级别】简述 Go 语言GC(垃圾回收)的工作原理
垃圾回收机制是Go一大特(nan)色(dian)。Go 1.3采用标记清除法, Go 1.5采用三色标记法,Go 1.8采用三色标记法+混合写屏障。
- 标记清除法
分为两个阶段:标记和清除
- 标记阶段:从根对象出发寻找并标记所有存活的对象。
- 清除阶段:遍历堆中的对象,回收未标记的对象,并加入空闲链表。
缺点是需要暂停程序STW。
- 三色标记法
将对象标记为白色,灰色或黑色。
白色:不确定对象(默认色);黑色:存活对象。灰色:存活对象,子对象待处理。
标记开始时,先将所有对象加入白色集合(需要STW)。首先将根对象标记为灰色,然后将一个对象从灰色集合取出,遍历其子对象,放入灰色集合。同时将取出的对象放入黑色集合,直到灰色集合为空。最后的白色集合对象就是需要清理的对象。
这种方法有一个缺陷,如果对象的引用被用户修改了,那么之前的标记就无效了。因此Go采用了写屏障技术,当对象新增或者更新会将其着色为灰色。
一次完整的GC分为四个阶段:
- 准备标记(需要STW),开启写屏障。
- 开始标记
- 标记结束(STW),关闭写屏障
- 清理(并发)
基于插入写屏障和删除写屏障在结束时需要STW来重新扫描栈,带来性能瓶颈。混合写屏障分为以下四步:
- GC开始时,将栈上的全部对象标记为黑色(不需要二次扫描,无需STW);
- GC期间,任何栈上创建的新对象均为黑色
- 被删除引用的对象标记为灰色
- 被添加引用的对象标记为灰色
总而言之就是确保黑色对象不能引用白色对象,这个改进直接使得GC时间从 2s降低到2us。
函数返回局部变量的指针是否安全?
这一点和C++不同,在Go里面返回局部变量的指针是安全的。因为Go会进行逃逸分析,如果发现局部变量的作用域超过该函数则会把指针分配到堆区,避免内存泄漏。
非接口的任意类型 T()
都能够调用 *T
的方法吗?反过来呢?
一个T
类型的值可以调用*T
类型声明的方法,当且仅当T
是可寻址的。
反之:*T
可以调用T()
的方法,因为指针可以解引用。
slice
是怎么扩容的?
Go 1.17版本之前:
如果当前容量小于1024,则判断所需容量是否大于原来容量2倍,如果大于,当前容量加上所需容量;否则当前容量乘2。
如果当前容量大于1024,则每次按照1.25倍速度递增容量,也就是每次加上cap/4。
Go 1.18版本之后:
无缓冲的 channel 和有缓冲的 channel 的区别?
- 对于无缓冲区channel:
发送的数据如果没有被接收方接收,那么发送方阻塞;如果一直接收不到发送方的数据,接收方阻塞;
- 有缓冲的channel:
发送方在缓冲区满的时候阻塞,接收方不阻塞;接收方在缓冲区为空的时候阻塞,发送方不阻塞。
可以类比生产者与消费者问题。
为什么有协程泄露(Goroutine Leak)?
协程泄漏是指协程创建之后没有得到释放。主要原因有:
- 缺少接收器,导致发送阻塞
- 缺少发送器,导致接收阻塞
- 死锁。多个协程由于竞争资源导致死锁。
- 创建协程的没有回收。
Go 可以限制运行时操作系统线程的数量吗? 常见的goroutine操作函数有哪些?
可以,使用runtime.GOMAXPROCS(num int)
可以设置线程数目。该值默认为CPU逻辑核数,如果设的太大,会引起频繁的线程切换,降低性能。
runtime.Gosched()
,用于让出CPU时间片,让出当前goroutine的执行权限,调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。
runtime.Goexit()
,调用此函数会立即使当前的goroutine的运行终止(终止协程),而其它的goroutine并不会受此影响。runtime.Goexit
在终止当前goroutine前会先执行此goroutine的还未执行的defer
语句。请注意千万别在主函数调用runtime.Goexit
,因为会引发panic。
如何控制协程数目?
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的例子:
|
|
此外还可以用协程池:其原理无外乎是将上述代码中通道和协程函数解耦,并封装成单独的结构体。常见第三方协程池库,比如tunny等。
new
和make
的区别?
new
只用于分配内存,返回一个指向地址的指针。它为每个新类型分配一片内存,初始化为0且返回类型*T
的内存地址,它相当于&T{}
。make
只可用于slice
,map
,channel
的初始化,返回的是引用。
Go面向对象是如何实现的?
Go实现面向对象的两个关键是struct
和interface
。
- 封装:对于同一个包,对象对包内的文件可见;对不同的包,需要将对象以大写开头才是可见的。
- 继承:继承是编译时特征,在
struct
内加入所需要继承的类即可:
|
|
- 多态:多态是运行时特征,Go多态通过
interface
来实现。类型和接口是松耦合的,某个类型的实例可以赋给它所实现的任意接口类型的变量。
Go支持多重继承,就是在类型中嵌入所有必要的父类型。
uint
型变量值分别为 1,2,它们相减的结果是多少?
|
|
答案,结果会溢出,如果是32位系统,结果是2^32-1
,如果是64位系统,结果2^64-1
.
Go有没有函数在main
之前执行?怎么用?
Go的init
函数在main
函数之前执行,它有如下特点:
|
|
init
函数非常特殊:
- 初始化不能采用初始化表达式初始化的变量;
- 程序运行前执行注册
- 实现
sync.Once
功能 - 不能被其它函数调用
init
函数没有入口参数和返回值:- 每个包可以有多个
init
函数,每个源文件也可以有多个init
函数。 - 同一个包的
init
执行顺序,Go没有明确定义,编程时要注意程序不要依赖这个执行顺序。 - 不同包的
init
函数按照包导入的依赖关系决定执行顺序。
【熟练级别】下面最后一句代码是什么作用,为什么要定义一个空值?
|
|
将nil
转换为*GobCodec
类型,然后再转换为Codec
接口,如果转换失败,说明*GobCodec
没有实现Codec
接口的所有方法。
或者可以换一个问题:如何确定一个结构体实现了一个接口中的所有方法?
【精通级别】简述Go内存管理机制
Go内存管理基本是参考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
中获取。
mutex
有几种模式?
mutex
有两种模式:normal
和 starvation
。
- 正常模式
所有goroutine按照FIFO的顺序进行锁获取,被唤醒的goroutine和新请求锁的goroutine同时进行锁获取,通常新请求锁的goroutine更容易获取锁(持续占有cpu),被唤醒的goroutine则不容易获取到锁。公平性:否。
- 饥饿模式
所有尝试获取锁的goroutine进行等待排队,新请求锁的goroutine不会进行锁获取(禁用自旋),而是加入队列尾部等待获取锁。公平性:是。
竞态条件了解吗?
所谓竞态竞争,就是当两个或以上的goroutine访问相同资源时候,对资源进行读/写。
比如var a int = 0
,有两个协程分别对a+=1
,我们发现最后a
不一定为2
。这就是竞态竞争。
通常我们可以用go run -race xx.go
来进行检测。
解决方法是,对临界区资源上锁,或者使用原子操作(atomics),原子操作的开销小于上锁。
如果若干个goroutine,有一个panic会怎么做?
有一个panic,那么剩余goroutine也会退出,程序退出。如果不想程序退出,那么必须通过调用 recover()
方法来捕获 panic 并恢复将要崩掉的程序。
defer
可以捕获goroutine的子goroutine吗?
不可以。它们处于不同的调度器P中。对于子goroutine,必须通过 recover()
机制来进行恢复,然后结合日志进行打印(或者通过channel传递error)。
gRPC是什么?
基于Protobuf的远程过程调用。RPC 框架的目标就是让远程服务调用更加简单、透明,RPC 框架负责屏蔽底层的传输方式(TCP 或者 UDP)、序列化方式(XML/Json/ 二进制)和通信细节。服务调用者可以像调用本地接口一样调用远程的服务提供者,而不需要关心底层通信细节和调用过程。
微服务了解吗?
微服务是一种开发软件的架构和组织方法,其中软件由通过明确定义的 API 进行通信的小型独立服务组成。微服务架构使应用程序更易于扩展和更快地开发,从而加速创新并缩短新功能的上市时间。微服务有着自主,专用,灵活性等优点。
服务发现是怎么做的?
主要有两种服务发现机制:客户端发现和服务端发现。
- 客户端发现模式:当我们使用客户端发现的时候,客户端负责决定可用服务实例的网络地址并且在集群中对请求负载均衡, 客户端访问服务登记表,也就是一个可用服务的数据库,然后客户端使用一种负载均衡算法选择一个可用的服务实例然后发起请求。
- 服务端发现模式:客户端通过负载均衡器向某个服务提出请求,负载均衡器查询服务注册表,并将请求转发到可用的服务实例。如同客户端发现,服务实例在服务注册表中注册或注销。
中间件用过吗?
Middleware是Web的重要组成部分,中间件(通常)是一小段代码,它们接受一个请求,对其进行处理,每个中间件只处理一件事情,完成后将其传递给另一个中间件或最终处理程序,这样就做到了程序的解耦。
channel
死锁的场景
- 当一个channel中没有数据,而直接读取时,会发生死锁:
|
|
解决方案是采用select
语句,在default
放默认处理方式:
|
|
- 当
channel
数据满了,再尝试写数据会造成死锁:
|
|
解决方法,采用select
:
|
|
- 向一个关闭的
channel
写数据。
注意:一个已经关闭的channel,只能读数据,不能写数据。
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
是线程安全的。
说说context
包的作用?你用过哪些,原理知道吗?
context
可以用来在goroutine之间传递上下文信息,相同的context
可以传递给运行在不同goroutine中的函数,上下文对于多个goroutine同时使用是安全的,context
包定义了上下文类型,可以使用background
、TODO
创建一个上下文,在函数调用链之间传播context
,也可以使用WithDeadline
、WithTimeout
、WithCancel
或 WithValue
创建的修改副本替换它,听起来有点绕,其实总结起就是一句话:context
的作用就是在不同的goroutine之间同步请求特定的数据、取消信号以及处理请求的截止日期。
进程被kill,如何保证所有goroutine顺利退出
goroutine监听SIGKILL
信号,一旦接收到SIGKILL
,则立刻退出。可采用select
方法。
|
|
【熟练级别】实现使用字符串函数名,调用函数。
采用反射的Call
方法实现。
|
|