面试题 (基础部分)收集
数据类型
nil切片 VS 空切片
len([]T)对nil切片和空切片都会返回0,只有判断是否为nil才能知道是不是nil切片
对nil切片赋值会发生nil panic,因为切片变量本身还是;对空切片的任何一处索引赋值或进行访问操作,都会nil,都没有方法表panic:index x out of range,x甚至可以为0,因为此时length为0
对不满的切片的未赋值部分进行访问操作也会发生索引越界panic
- 底层结构差异:
- nil 切片:其 SliceHeader 的 Data 指针为 0 (nil)。声明方式:
var s []int(官方称呼是零切片声明)。 - 空切片:其 SliceHeader 的 Data 指针指向一个预定义的零字节地址(如 zerobase)。声明方式:
s := []int{}或make([]int, 0)。
- nil 切片:其 SliceHeader 的 Data 指针为 0 (nil)。声明方式:
- 用法区别:
- 相同点:len 和 cap 均为 0。直接索引访问(如
s[0])都会 panic。 - 不同点:append 操作对两者都是安全的(nil 切片在 append 时会自动触发扩容分配内存)
- 相同点:len 和 cap 均为 0。直接索引访问(如
- 序列化差异(高分项):
- 在
encoding/json序列化时,nil切片会转为null,而空切片会转为[]。这在前后端对接时是非常经典的坑。
- 在
字符串转换成[]byte,会发生内存拷贝吗?
会
除非用unsafe的StringData方法
- 标准转换:
b := []byte(s)会发生内存拷贝。因为 Go 语言中 string 是不可变的,而[]byte是可变的。为了保证 string 的不可变性,Go 运行时必须重新分配内存并拷贝数据。 - 零拷贝实现(进阶):
- 在 Go 1.20 之前,通常使用
reflect.SliceHeader进行指针强转。 - 在 Go 1.21+,官方推荐使用 unsafe 包的新方法,既安全又高效:
- 在 Go 1.20 之前,通常使用
// String -> []byte (仅在确信不修改 byte 数组时使用,否则违反 string 不可变性)
s := "hello"
b := unsafe.Slice(unsafe.StringData(s), len(s))注意点:零拷贝转换后的 []byte 绝不能进行修改操作,否则会导致程序崩溃(写入只读内存)或不可预知的行为。
注
全局常量字符串会被程序加载到.rdata或.rodata段中,对段的修改操作会直接触发操作系统层面的段错误
运行时动态分配的字符串则是在堆上的,修改这些字符串不会发生panic,但考虑到代码可读性,不建议修改默认不可变的变量
翻转含有中文、数字、英文字母的字符串
先转成[]rune类型,套个结构体,实现Slice接口,然后调用slice.Reverse方法
Go的字符串底层是字节数组,而中文字符一般是2字节(UTF8) 3字节(UTF-8),有的变长Unicode甚至是4字节大,直接翻转字符串可能会导致中文乱码
- 核心逻辑:由于字符串底层是 UTF-8 编码的字节数组,直接翻转字节会导致多字节字符(如中文)乱码。必须先转换为
[]rune(等价于 int32 数组,代表 Unicode 码点),再进行位置交换。 - 实现方案:使用双指针(前后交换)。
func reverseString(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}底层认知:
- ASCII 占 1 字节,中文 在 UTF-8 中通常占 3 字节。
[]rune转换会发生内存申请和数据拷贝(O(n) 复杂度)
拷贝大切片一定比小切片代价小吗
【直接append】如果小的切片(目标切片)预留的cap不够,那么会触发内存扩容;内存空间代价取决于max(src []T, dest []T)更靠近2的哪个指数数量级
- 当切片长度来到
256时,扩容倍率会变为x1.25
【直接copy】copy(src []T, dest []T)倒是缓冲区安全的,只会拷贝min(len(src []T, dest []T))的字节,如果目标切片没有可用容量,那么copy就什么也不做
- 结论:视情况而定。
- 场景 A:切片赋值(Header 拷贝)
s2 := s1。此时代价完全一样,且非常小。- 因为切片本质是一个 SliceHeader 结构体(包含 Data 指针、Len、Cap),共 24 字节(64位系统)。无论切片背后是 10 个元素还是 1000 万个元素,Header 的拷贝都是常数级代价 O(1)。
- 场景 B:数据深拷贝(Elements 拷贝)
- 使用
copy(dest, src)或 append。此时大切片代价远大于小切片,代价为 O(n)。因为需要逐个元素的内存复制。
- 使用
- 扩容考量(你的亮点):
- 如果是通过 append 拷贝,当小切片触发扩容时,会有额外的内存申请和旧数据搬迁开销
流程控制
for循环里append元素的后果
我从来不会这么做,至多是遍历A切片时给B切片append前所未见的用法
for ... := range 的迭代序列在进入for循环时就已经确定,对被循环的切片进行循环内append不会影响后面的迭代序列
循环结束之后的切片可能已经发生内存扩容,可能不再指向原来的区域(如果循环前cap不够的话)
for range场景:不会死循环。在循环开始前,range 会对切片进行一次副本拷贝,并记住其当时的 len。循环过程中 append 导致原切片扩容或长度增加,都不会改变 range 拷贝副本里的迭代次数。for i := 0; i < len(s); i++场景:会死循环。因为每次循环都会重新评估 len(s),如果循环内不断 append,len(s) 会持续增长,导致索引永远无法到达终点。- 注意事项:虽然 for range 不会增加迭代次数,但在循环内通过索引修改元素(如
s[i] = v),修改的是原切片内存,后续迭代(如果是读原切片)可能会读到修改后的值。
for select时通道已经关闭会怎么样?如果只有一个case呢?
- 对于
for range <-chan T情形:通道关闭时for range也会退出;通道关闭时会返回类型零值,不过一般不用关心,for range会自动退出 - 对于
for select 多case情形:通道关闭时通常会进入读阻塞状态,除非使用了case v, ok := <- chan额外检查通道是否关闭;如果只有一个case,那大概就卡那里了
我在实践中都会保留一个context.Done分支兜底,哪怕是用ticker.C也会留一个ctx兜底,所以这种情况我也没见过
go defer 与 for defer
go defer:defer函数会被放入子协程中运行,如果里面是recovery,那么它无法捕获其他协程的panic,形同虚设了for defer:会造成资源泄露,因为defer是在return函数之前执行,而不是在for进入下一个循环前执行;Goland对这种用法会用黄色波浪线标出
select的作用
多通道监听。当多个通道同时返回数据时,select case的执行优先级是不确定的。需要带优先级的写法请参考下面的代码:
select {
case <- ctx.Done:
return
default:
select {
case v, ok := <-message:
// processing...
case err, notZero := <-errChan:
// logging
default:
continue // 避免阻塞
}
}