大家好,我是地鼠哥,我是今天发文的小编。后面也会在阳哥的号分享更多干货经验,欢迎大家关注我们。
今天要跟大伙分享的,是我和组织内一位学员的 Go 语言模拟面试内容。
先介绍下他的基本情况:最高学历是复旦硕士 ,学的软件工程,有 2 年工作经验(非Go岗位),之前薪资 22k,目标是 3 个月内成功上岸。他的学历是真的很不错,只是可惜前面几年走了弯路(后悔自己当年没有进互联网公司),现在想及时止损,冲击一波大厂。
主要挑几个他回答得不太理想的问题好好说一下 ,看文章的朋友也可以琢磨琢磨:这些问题换作你来,能答到点子上吗?
下面咱们就结合对话记录,一个个聊这些问题:
问题 1:子协程的 panic 能被父协程捕获吗?
我:父协程能捕获到子协程的 panic 吗?
他:可以的。
我:你有去试过吗?
他:这个我没有去试过。我没试过,但是我好像有看到。
问题背景:协程是 Go 并发的核心,而 panic 处理直接关系到程序稳定性。这个问题考的就是对协程间错误传递机制的实际理解。
他回答里的问题:上来就说 "可以的",但紧接着又承认 "没试过"。这暴露了两个点:一是对知识点记混了,二是缺了 动手验证的意识。很多朋友学 Go 时总靠死记硬背,觉得 理论懂了就行,但实际上写几行代码跑一跑,比记十遍结论都管用。
正确的回答该怎么说: 在 Go 里,子协程的 panic 没法被父协程直接捕获。
- 协程是独立的执行单元,它的 panic 只会终止自己;要是没在子协程内部用
defer+recover
处理,整个程序都会崩。 - 真想在协程间传递错误,得靠 channel 主动传(比如
chan error
),或者用errgroup
这类工具包管理。
举个简单例子:
go
func main() {
errCh := make(chan error)
go func() {
defer func() {
if e := recover(); e != nil {
errCh <- fmt.Errorf("子协程出错:%v", e)
}
}()
// 子协程里故意触发panic
panic("出错了")
}()
// 父协程通过channel接错误
if err := <-errCh; err != nil {
fmt.Println("抓到子协程错误:", err)
}
}
问题 2:怎么保证 map 遍历是有序的?
我:map 是无序的,我们要怎么样保证它的有序性?
他:在一些 C++ 这类语言中,会把它放到 set 集合里。
我:在 Go 语言里没有 set 这个数据结构,现在问的是 Go 相关的。
他:那在 Go 里怎么保证 map 有序性?我想是不是可以新建一个堆,把数据存进去,按大小对应这样。
问题背景:Go 的 map 遍历顺序是随机的,但实际开发中经常需要按固定顺序(比如插入顺序、key 的字典序)遍历,这题考的是 "结合 Go 特性解决实际问题" 的能力。
他回答里的问题:先是说到 C++ 的 set,明显没聚焦 Go;后来又说 "用堆",把简单问题复杂化了。这说明对 Go 里数据结构的实际用法不熟悉,没养成 "用 Go 的方式解决 Go 问题" 的思维。
正确的回答该怎么说: Go 里保证 map 有序遍历,核心思路就是 "用切片记顺序",两种常见办法:
- 按插入顺序遍历:插 map 的时候,同步把 key 放进切片,遍历切片时按顺序取 map 的 value;
- 按 key 排序后遍历:先把 map 的 key 全放到切片里,排序后再遍历访问 map。
举个插入顺序的例子:
go
func main() {
m := make(map[string]int)
keys := []string{} // 用切片记插入的key
// 插数据
m["x"] = 10
keys = append(keys, "x")
m["y"] = 20
keys = append(keys, "y")
// 按插入顺序遍历
for _, k := range keys {
fmt.Printf("%s: %d\n", k, m[k]) // 输出x:10 y:20
}
}
问题 3:select 语句的实现原理是什么?
我:select 实现原理你之前有了解过吗?
他:select 的实现原理我没有了解过,我觉得它可能和 IO 多路复用可能是有类似的机制。比如在 C++ 里,是基于内核,内部实现了一个队列,当队列快塞满时,操作系统会发信号,用户接收后从队列里取监听的字段,然后遍历处理。
我:你说的是 C++ 里的,建议后面把 Go 相关的也了解下,可以对比下区别。
问题背景:select 是 Go 实现 channel 多路复用的关键,懂它的原理才能真正用好并发。
他回答里的问题:直接说 "没了解过 Go 的实现",还是用C++ 的机制来套(看来他C++学的挺不错哈哈哈)。这就反映出对 Go 核心语法的底层逻辑理解不到位 ------ 很多朋友会用 select,但不知道它为啥能高效管理多个 channel,遇到复杂场景就容易掉坑。
正确的回答该怎么说: Go 的 select 底层是 "伪随机 + 轮询" 机制,大概分两步:
- 编译时:把每个 case 转成
scase
结构体,存着 channel 指针、操作类型(发 / 收)、数据指针这些; - 运行时:
- 先查所有 case 里的 channel 有没有就绪的(比如能发 / 能收),有的话随机挑一个执行(避免某个 case 饿死);
- 要是都没就绪,有 default 就走 default;
- 没 default 的话,当前协程就阻塞,等任意 channel 就绪了再唤醒。
说白了,select 就是为了高效处理多个 channel 的并发操作,不让单个 channel 阻塞拖慢整个程序。
问题 4:在高并发和内存密集型场景下,GC 的触发时机、调优策略以及对程序性能的影响
我:在高并发和内存密集型场景下,GC 的触发时机、调优策略以及对程序性能的影响你了解过吗?
他:GC 触发时机我只知道有定时触发,其他的不太清楚。
问题背景:GC 是 Go 内存管理的核心,在高并发和内存密集型场景中,知道触发时机、调优策略以及对程序性能的影响才能优化程序性能。
他回答里的问题:只了解 GC 的定时触发时机,对在高并发和内存密集型场景下的调优策略以及 GC 对程序性能的影响缺少了解。这说明对 GC 机制在复杂场景下的应用和优化理解不够,在实际开发过程中如果遇到了类似问题就无法应对了。
正确的回答该怎么说: Go 的 GC 触发主要有三种情况:
- 定时触发:默认超过 2 分钟没 GC,就强制来一次(防止内存一直不回收);
- 内存阈值触发 :新分配的内存占已用内存的比例超过阈值(由
GOGC
控制,默认 100,也就是新分配的和已用的一样多时触发); - 主动触发 :用
runtime.GC()
手动调(比如程序退出前清理资源)。
调优策略
- 调整 GOGC :在内存密集型场景下,可以适当增大
GOGC
的值,减少 GC 的频率,但可能会增加内存占用;反之,减小GOGC
的值会增加 GC 的频率,但可以降低内存占用。 - 对象池复用:使用对象池来复用对象,减少内存分配和回收的开销。
- 分代回收思想:对于频繁创建和销毁的小对象,可以采用分代回收的思想,将其隔离处理。
对程序性能的影响
- STW(Stop The World):GC 过程中会有短暂的 STW 阶段,此时所有的协程都会暂停,对高并发程序的性能影响较大。
- 内存占用:不合理的 GC 配置可能会导致内存占用过高或频繁的内存分配和回收,影响程序的性能。
问题 5:new 和 make 的区别是什么?
我:那new 关键字和 make 这两个关键字的区别讲一下。 他:new 关键字,它是相当于是去分配一块地址,然后指向需要申请的元素,然后它返回的是一个指针。然后make的话它实际上返回的是一个引用类型,但是只是用在一些固定的这种数据结构,比如说是切片,然后 map 然后 channel。 我:那 new 能用到哪些结构上面? 他:比如说是一些基本的类型,比如说 int,然后那个或者自定义的一些结构体之类的都可以。 我:没错,其实基本上就都可以知道吧,然后其实你去new个切片 map channel 也行,只不过你new出来之后,它不能直接用,你还是要再去 make 一下才能去用。
问题背景:new 和 make 是 Go 中用于内存分配的两个核心关键字,面试中高频出现。
他回答里的问题:明显误区:认为 new "只用于基本类型和结构体",忽略了 new 其实可以用于所有类型(包括切片、map 等),只是用 new 创建这些类型后无法直接使用,必须再初始化。这说明对他们的适用场景和底层作用理解不够 透彻。
正确的回答该怎么说 : new 和 make 的核心区别体现在适用类型、返回值、作用三个方面:
- 适用类型 :
- new:可用于所有类型(基本类型、结构体、切片、map 等);
- make:仅用于切片(slice)、映射(map)、通道(channel)这三种引用类型。
- 返回值 :
- new:返回指向类型的指针(如
*int
、*[]int
),分配的内存会被初始化为 "零值"(如 int 的 0、string 的 ""); - make:返回类型本身(如
[]int
、map[string]int
),分配的内存会被 "初始化"(如切片会创建底层数组并设置长度和容量,map 会初始化桶结构)。
- new:返回指向类型的指针(如
- 作用 :
- new:仅负责 "分配内存",不处理类型特有的初始化逻辑(比如用 new 创建的切片,底层数组未被真正初始化,无法直接 append 元素);
- make:不仅分配内存,还会执行类型特有的初始化(比如创建切片时指定容量,创建 map 时初始化哈希表,创建 channel 时设置缓冲区)。
举个例子对比:
go
// new的用法:返回指针,需手动初始化
func main() {
// new创建int,返回*int,值为0(零值)
num := new(int)
fmt.Println(*num) // 输出0
// new创建切片,返回*[]int,但切片未初始化,无法直接使用
s := new([]int)
// *s = append(*s, 1) // 需先初始化(如分配底层数组)才能使用
}
// make的用法:返回类型本身,可直接使用
func main() {
// make创建切片,返回[]int,已初始化(长度0,容量10)
s := make([]int, 0, 10)
s = append(s, 1) // 可直接使用
// make创建map,返回map[string]int,已初始化哈希表
m := make(map[string]int)
m["a"] = 1 // 可直接赋值
}
简单说:new 是 "通用内存分配工具",只负责给变量找块地方放;make 是 "专用初始化工具",专为三种引用类型做开箱即用的准备。
欢迎关注 ❤
我们搞了一个免费的面试真题共享群,互通有无,一起刷题进步。
没准能让你能刷到自己意向公司的最新面试题呢。
感兴趣的朋友们可以加我微信:wangzhongyang1993,备注:面试群。