Go 面试题
1 基础篇
1.1 Go包管理的方式有哪些
发展历史:
< Go1.5:GOPATH- 通过统一包存放的路径实现包管理
- 不支持依赖包的版本控制
>= Go1.5:Go Vendor- Go1.5 引入,需要通过环境变量
GO15VENDOREXPERIMENT=1开启 - Go1.6 Vendor 机制默认开启
- 把源码拷贝到
vendor目录并维护vendor.json文件,指定版本
- Go1.5 引入,需要通过环境变量
>= Go1.11:Go Modules- Go1.11 中,
GO111MODULE的默认值是auto:- 当项目在 GOPATH 之外,且包含
go.mod文件时,自动启用 Modules; - 当项目在 GOPATH 内时,默认不启用(仍使用 GOPATH 模式)。
- 当项目在 GOPATH 之外,且包含
- Go1.13 起,
GO111MODULE默认值改为on,彻底以 Modules 为主要包管理方式。
- Go1.11 中,
1.2 init() 是什么时候执行的
init() 函数的作用:
- 程序执行前包的初始化
init() 函数的执行顺序:
- 在同一个 Go 文件中的多个 init 方法,按照代码顺序依次执行
- 同一个包内不同文件中的
init()函数,按照文件名顺序执行 - 不同的包且不相互依赖,按照
import顺序执行 - 存在依赖关系的包,被依赖的包先执行
init()
go 文件的初始化顺序:
- 引入的包
- 当前包中的常量
- 当前包中的变量
- 当前包的
init()函数 - 若为
main包,最终执行main()函数
1.3 new 和 make 的区别
make不仅分配内存,还会初始化。new只会分配零值填充的值(例如,int的零值是0,*int的零值是nil,[]int的零值是nil)make只适用于slice、map、channel的数据,new没有限制make返回原始类型(T),new返回类型的指针(*T)
1.4 内存逃逸
1.4.1 什么是内存逃逸
Go 中,函数内的局部变量默认分配在栈上(栈内存由编译器自动分配和释放,效率极高)。但在某些情况下,变量会被移动到堆上分配,这种现象称为内存逃逸。逃逸分析是编译器决定变量分配位置的过程。
核心区别:
- 栈分配:函数退出后,栈内存自动释放,无需垃圾回收(GC)。
- 堆分配:变量生命周期不确定,需由 GC 管理,会增加 GC 压力。
1.4.2 发生内存逃逸的常见场景
编译器进行逃逸分析时,若发现变量的生命周期无法在编译期确定或栈无法容纳,就会将其分配到堆上。常见场景包括:
- 变量被外部引用(跨函数生命周期)
若函数返回变量的指针或引用,且该指针被外部持有(变量需在函数退出后继续存在),变量会逃逸到堆。
func create() *int {
x := 10 // x 会逃逸到堆
return &x // 返回指针,x 需在函数外存活
}
func main() {
p := create()
fmt.Println(*p)
}原因:函数退出后,栈会被销毁,若变量仍被外部引用,必须放在堆上。
- 变量大小超过栈的承载能力
栈的空间有限,若变量体积过大(如超大数组),编译器会将其分配到堆上。
func bigData() {
data := [1000000]int64{} // 大小超过栈限制,逃逸到堆
}原因:避免栈溢出(stack overflow),堆的空间更大且动态分配。
- 闭包引用并修改外部变量
闭包会捕获外部变量的引用,若闭包的生命周期长于变量的原始作用域,变量会逃逸到堆。
func closure() func() {
x := 10
return func() {
x++ // 闭包修改 x,x 需在 closure 退出后存活
fmt.Println(x)
}
}
func main() {
f := closure()
f() // 调用闭包时,x 仍需存在
}原因:闭包可能在函数退出后被调用,变量需脱离原函数栈存活。
- 变量类型为接口(动态类型不确定)
当变量被赋值给接口类型,且编译器无法在编译期确定其具体类型(动态类型),变量会逃逸到堆。
type animal interface {
run()
}
type dog struct{}
func (d dog) run() {}
func main() {
// 声明的同时赋值了,不会发生逃逸
var a1 animal = dog{}
a1.run()
// 接口类型声明时没赋值,动态类型不确定,发生内存逃逸
var a2 animal
a2 = dog{}
a2.run()
}原因:接口的动态类型处理需要 runtime 支持,堆分配更灵活。
- 切片 / 映射的动态扩容或长度不确定
若切片的长度是动态计算的(非编译期常量),或可能发生扩容(底层数组需更换),其底层数组可能逃逸到堆。
func main() {
n, _ := strconv.Atoi(os.Args[1])
_ = make([]int, n)
}1.5 如何手动修改容量和长度
正常情况下无法直接修改,必须通过反射的 SetLen 和 SetCap 方法操作。这两个方法本质是直接修改切片头中的长度和容量字段,而非改变底层数组本身。
SetLen(n)的限制:n必须满足0 ≤ n ≤ cap(slice)SetCap(n)的限制:n必须满足len(slice) ≤ n ≤ 原 cap(slice)
func main() {
slice := make([]int, 3, 5)
fmt.Println(len(slice), cap(slice), slice) // 3 5 [0 0 0]
reflect.ValueOf(&slice).Elem().SetLen(2)
fmt.Println(len(slice), cap(slice), slice) // 2 5 [0 0]
reflect.ValueOf(&slice).Elem().SetCap(4)
fmt.Println(len(slice), cap(slice), slice) // 2 4 [0 0]
}1.6 切片和浮点数能作为 map 的键吗
只有可比较类型才能作为 map 的键。所以切片不行,浮点数可以。
但是如果用浮点数做 map 的键会存在问题。例如:
func main() {
m := make(map[float64]int)
m[0.1] = 1
m[0.2] = 2
m[0.3] = 5
m[0.30000000000000001] = 6
fmt.Printf("%v\n", m) // map[0.1:1 0.2:2 0.3:6]
}出现上面问题,是由于浮点数自身的二进制表示精度限制,导致不同十进制数对应同一二进制值,进而引发键冲突。当浮点型作为 map 的 key 的时候会做一些特别的处理,它会先通过 math.Float64bit 函数转为 uint64 类型,再作为 key:
fmt.Println(math.Float64bits(0.3)) // 4599075939470750515
fmt.Println(math.Float64bits(0.30000000000000001)) // 4599075939470750515除了精度导致的冲突,浮点数键还有一个常见问题:NaN 与任何值都不相等,导致存入 NaN 后无法取出:
m := make(map[float64]int)
m[math.NaN()] = 100
fmt.Println(m[math.NaN()] == 100) // false
fmt.Println(math.NaN() == math.NaN()) // false1.7 判断对象是否有某个方法
type Person1 struct {
Name string
}
func (p *Person1) SayHi() string {
return "Hi"
}
type Person2 struct {
Name string
}
// 方法一:类型断言,性能好(无反射开销)
func HasMethodSay(v interface{}) bool {
_, has := v.(interface {
SayHi() string
})
return has
}
// 方法二:通过反射,灵活性高,可以通过字符串动态指定方法名
func HasMethod(v interface{}, methodName string) bool {
obj := reflect.ValueOf(v)
method := obj.MethodByName(methodName)
if !method.IsValid() {
return false
}
return true
}
func main() {
p1 := &Person1{"Zhang"}
p2 := &Person2{"Zhang"}
// 方法一
fmt.Println(HasMethodSay(p1)) // true
fmt.Println(HasMethodSay(p2)) // false
// 方法二:通过反射
fmt.Println(HasMethod(p1, "SayHi")) // true
fmt.Println(HasMethod(p2, "SayHi")) // false
}2 高级篇
2.1 switch
2.1.1 基础特性:自动 break(与其他语言的核心区别)
Go 的 switch 中,每个 case 执行完毕后自动终止(无需显式写 break),不会像 C/C++ 那样默认穿透到下一个 case。
num := 2
switch num {
case 1:
fmt.Println("1")
case 2:
fmt.Println("2") // 执行后自动退出switch,不会继续执行case 3
case 3:
fmt.Println("3")
}2.1.2 fallthrough
如果需要像 C++ 那样执行当前 case 后继续执行下一个 case,需显式使用 fallthrough。
注意事项:
fallthrough必须放在case块的最后一行。- 会忽略下一个
case的条件,直接执行其代码块。 - 不能用在最后一个
case中(否则编译错误)。
num := 1
switch num {
case 1:
fmt.Println("1")
fallthrough // 强制执行下一个case
case 2:
fmt.Println("2") // 即使num≠2,仍会执行
}2.1.3 无表达式
等价于 switch true(类似 if-else 链)。
age := 25
switch { // 等价于 switch true
case age < 18:
fmt.Println("未成年")
case age >= 18 && age < 30:
fmt.Println("青年") // 匹配成功
}2.1.4 类型 switch:判断接口动态类型
通过 switch v.(type) 语法判断接口变量 v 的动态类型(即接口实际存储的值的类型)。
var x interface{} = "hello"
switch t := x.(type) { // t是x的值,类型为case匹配的类型
case int:
fmt.Printf("int: %d\n", t)
case string:
fmt.Printf("string: %s\n", t) // 匹配成功,输出 "string: hello"
default:
fmt.Printf("unknown type: %T\n", t)
}2.1.5 switch 内的变量声明
可以在 switch 后直接声明变量,其作用域仅限于 switch 块内。
switch num := 3; num { // 声明变量num,仅在switch内有效
case 1:
fmt.Println(1)
case 3:
fmt.Println(3) // 执行
}2.2 defer
2.2.1 基础特性
defer 用于延迟一个函数(或方法)的执行,延迟到包含它的函数(父函数)返回前执行(无论父函数是正常返回还是因 panic 异常退出)。
func main() {
fmt.Println("start")
defer fmt.Println("defer 1") // 延迟到 main 返回前执行
fmt.Println("end")
}
// 输出顺序:start → end → defer 12.2.2 执行顺序——后进先出(LIFO)
多个 defer 按声明顺序入栈,父函数返回时按栈顶到栈底顺序执行(最后声明的 defer 最先执行)。
func main() {
defer fmt.Println("1") // 先入栈
defer fmt.Println("2") // 后入栈,返回时先执行
defer fmt.Println("3") // 最后入栈,最先执行
}
// 输出顺序:3 → 2 → 12.2.3 参数捕获时机——声明时计算
defer 后面的函数参数,会在 defer 声明的那一刻计算出值并固定(捕获),而非延迟函数执行时计算。
func main() {
i := 0
defer fmt.Println("defer:", i) // 声明时 i=0,参数固定为 0
i = 100 // 修改 i 不影响已捕获的参数
fmt.Println("main:", i)
}
// 输出:main: 100 → defer: 0(而非 100)注意
如果 defer 后面是匿名函数(闭包),且函数内部引用了外部变量,则变量的值会在延迟函数执行时读取(而非声明时),因为此时变量不是函数的参数,而是闭包的外部引用。
func main() {
i := 0
defer func() {
// 这里的i是闭包引用的外部变量,不是函数参数
// 延迟函数执行时(main返回前)才会读取i的当前值
fmt.Println("defer:", i)
}()
i = 100 // 延迟函数执行前修改i,会影响输出结果
fmt.Println("main:", i)
}
// 输出:main: 100 → defer: 1002.2.4 与函数返回值的交互(命名返回值 vs 匿名返回值)
仅当返回值是命名返回值时,defer 能影响函数返回值。
func f1() int {
i := 10
defer func() { i += 5 }() // 修改的是局部变量 i,而非返回值
return i // 返回的是 i 的副本(匿名返回值),此时 i=10
}
func f2() (i int) { // 命名返回值 i,函数开始时已初始化(默认 0)
i = 10
defer func() { i += 5 }() // 直接修改命名返回值 i
return // 等价于 return i,此时 i 已被修改为 15
}
func main() {
fmt.Println(f1()) // 输出 10(而非 15)
fmt.Println(f2()) // 输出 15
}2.2.5 常见使用场景
资源释放:确保文件、网络连接、锁等资源在函数结束后释放,避免泄漏(无论函数正常还是异常退出,资源都能释放)。
go// 示例:文件关闭 func readFile(path string) { file, err := os.Open(path) if err != nil { return } defer file.Close() // 函数返回前自动关闭文件,即使后续代码报错 // 读取文件逻辑... } // 示例:互斥锁释放 var mu sync.Mutex func safeAdd() { mu.Lock() defer mu.Unlock() // 函数结束后自动解锁,避免死锁 // 并发安全操作... }捕获
panic:在defer中用recover()捕获父函数的panic,实现异常恢复(避免程序崩溃)。gofunc risky() { defer func() { if err := recover(); err != nil { fmt.Printf("捕获异常:%v\n", err) // 捕获 panic,程序不会崩溃 } }() panic("致命错误") // 触发 panic } func main() { risky() fmt.Println("程序继续执行") // 正常输出 }
2.3 channel
2.3.1 基础特性
channel 是 Go 语言中用于 goroutine 间通信 的核心机制,通过传递数据实现 goroutine 同步,而非共享内存。
底层是一个带锁的队列(或环形缓冲区),保证并发安全(多个 goroutine 操作同一个 channel 无需额外加锁)。
2.3.2 核心分类:无缓冲 channel vs 有缓冲 channel
两者的核心区别在于 是否有数据缓冲区,直接影响发送 / 接收操作的阻塞行为。
无缓冲 channel(
make(chan Type))- 特点:没有缓冲区,发送和接收操作 必须同步,发送方会阻塞直到有接收方接收数据,接收方会阻塞直到有发送方发送数据。
- 示例:有容量为
n的缓冲区,发送和接收操作的阻塞条件与缓冲区状态相关:goch := make(chan int) go func() { ch <- 100 // 发送方:阻塞等待接收方 }() fmt.Println(<-ch) // 接收方:阻塞等待发送方,输出 100
有缓冲 channel(
make(chan Type, n))- 特点:有容量为
n的缓冲区,发送和接收操作的阻塞条件与缓冲区状态相关:- 发送操作:缓冲区未满时,数据存入缓冲区,不阻塞;缓冲区满时,发送方阻塞直到有数据被接收。
- 接收操作:缓冲区非空时,直接取数据,不阻塞;缓冲区空时,接收方阻塞直到有数据被发送。
- 示例:go
ch := make(chan int, 2) // 容量为2的有缓冲channel ch <- 1 // 缓冲区未满(1/2),不阻塞 ch <- 2 // 缓冲区满(2/2),不阻塞 //ch <- 3 // 缓冲区满,发送方阻塞(若无人接收) fmt.Println(<-ch) // 取走1,缓冲区剩1(1/2),不阻塞 fmt.Println(<-ch) // 取走2,缓冲区空,若再接收会阻塞
- 特点:有容量为
2.3.3 与 select 配合
select 用于同时监听多个 channel 的发送 / 接收操作,是 channel 最常用的高级特性。
基本行为
select会阻塞等待 任意一个case可执行,然后执行该case。- 若 多个
case同时可执行:随机选择一个执行(避免某一 channel 饥饿)。 - 若 所有
case都不可执行:- 有
default分支:执行default(非阻塞模式)。 - 无
default分支:select阻塞,直到某个case可执行。
- 有
常见用法
- 示例 1:非阻塞接收(避免在空 channel 上阻塞):go
ch := make(chan int) select { case val := <-ch: fmt.Println("接收:", val) default: fmt.Println("channel 为空,不阻塞") // 执行此分支 } - 示例 2:超时控制(避免永久阻塞):go
ch := make(chan int) select { case val := <-ch: fmt.Println("接收:", val) case <-time.After(1 * time.Second): // 1秒后超时 fmt.Println("超时") // 1秒内无数据则执行 }
- 示例 1:非阻塞接收(避免在空 channel 上阻塞):
2.4 鸭子类型
鸭子类型是一种以行为定义类型的设计思想,核心原则是 如果一个东西看起来像鸭子、走起来像鸭子、叫起来像鸭子,那它就可以被当作鸭子。它不关注对象的具体类型,只关注对象是否具备所需的 “行为”(即方法或属性)。
Go 没有类和继承,而是通过接口(interface) 实现鸭子类型,且是 隐式接口(无需显式声明 实现了某接口),这是 Go 的核心设计特点之一。
在 Go 中,只要一个结构体(或其他类型)拥有某接口的 全部方法,就会被视为 实现了该接口,无需像 Java 那样用 implements 显式声明。此时,该结构体的实例就可以被当作接口类型使用。
// 1. 定义一个接口(代表“能叫”的行为)
type Speaker interface {
Speak() string // 所需行为:能发出声音(Speak方法)
}
// 2. 定义两个不同类型的结构体(无继承关系)
type Dog struct {
Name string
}
type Cat struct {
Age int
}
// 3. 给两个结构体分别实现Speak方法(具备“叫”的行为)
func (d *Dog) Speak() string {
return d.Name + ": 汪汪汪"
}
func (c *Cat) Speak() string {
return fmt.Sprintf("猫咪(%d岁): 喵喵喵", c.Age)
}
// 4. 定义一个函数:接收“能叫的对象”(即Speaker接口)
func WakeUp(s Speaker) {
fmt.Println("唤醒声音:", s.Speak())
}
func main() {
// Dog和Cat虽类型不同,但都有Speak方法(具备“叫”的行为)
// 因此都能传给WakeUp(接收Speaker接口)
dog := &Dog{Name: "阿黄"}
cat := &Cat{Age: 2}
WakeUp(dog) // 输出:唤醒声音:阿黄: 汪汪汪
WakeUp(cat) // 输出:唤醒声音:猫咪(2岁): 喵喵喵
}2.5 重载(Overload)
同一作用域内,函数名相同但参数列表(类型、个数、顺序)不同的函数,编译器通过参数匹配调用对应实现(如 Java/C++)。
Go 明确不支持重载。Go 在同一包、同一作用域内,不允许定义同名函数或同名方法(即使参数列表不同),编译会直接报错。
type Calculator struct{}
// 编译错误:method Calculator.Add already declared
func (c *Calculator) Add(x, y int) int {
return x + y
}
func (c *Calculator) Add(x, y, z int) int {
return x + y + z
}2.6 重写(Override)
type Parent struct{}
func (p Parent) F() { fmt.Println("Parent.F") }
type Child struct{ Parent } // 嵌套Parent
func (c Child) F() { fmt.Println("Child.F") } // 隐藏Parent.F
func main() {
c := Child{}
c.F() // 输出:Child.F(调用外层方法)
c.Parent.F() // 需显式调用内层方法,输出:Parent.F
var p Parent = c.Parent
p.F() // 输出:Parent.F
}3 Runtime 篇
Runtime 是 Go 程序的运行时系统,主要功能包括:内存管理、垃圾回收、协程调度、反射、系统交互。
3.1 垃圾回收(GC)
垃圾回收是 Go runtime 自动管理内存的机制,负责识别并回收不再被引用的对象所占用的内存。
3.1.1 发展历程
- Go 1.0: 标记-清除算法(Mark-Sweep),全量STW(Stop The World),延迟高
- Go 1.5: 三色标记法(Tri-color Marking)+ 写屏障(Write Barrier)
- Go 1.8: 混合写屏障(Hybrid Write Barrier)
标记清除算法的流程
- 暂停程序业务逻辑,对可达和不可达的对象进行分类,做上标记
- 找出程序所有可达对象并做上标记
- 解除暂停继续执行代码,重复上述过程直到程序生命周期结束
3.1.2 三色标记法
当前 Go GC 的基础算法,通过颜色标记对象的可达性:
三色状态定义:
- 白色:未被标记的对象(初始状态),GC 结束后会被回收
- 灰色:自身已被标记,但引用的子对象未被标记(待处理)
- 黑色:自身及引用的子对象均已标记(存活对象)
执行流程:
- 初始标记(Initial Mark):
- STW(极短),标记根对象(全局变量、栈上变量等直接可达对象)
- 根对象从白色变为灰色,放入标记队列
- 并发标记(Concurrent Mark):
- 恢复程序运行,GC 后台线程并发处理灰色对象
- 将灰色对象变为黑色,同时将其引用的白色对象变为灰色
- 借助写屏障跟踪并发过程中对象引用的变化
- 并发清理(Concurrent Sweep):
- 程序运行的同时,回收所有白色对象的内存
3.1.3 三色不变式
满足下面一种三色不变式就能确保对象不丢失:
- 强三色不变式:
- 不允许黑色对象直接引用白色对象
- 弱三色不变式:
- 允许黑色对象引用白色对象,但需满足一定条件
- 这个白色对象必须存在其他灰色对象对它的引用
- 或者这个白色对象的链路上游存在灰色对象
3.1.3 屏障
屏障本质上是在程序执行过程中增加的额外判断机制。
如果满足一定条件就使用类似回调或者钩子(Hook)。
- 插入屏障
又称为增量更新屏障。插入到黑色对象后的对象会被保守的标记为灰色对象,以满足强三色不变性。
- 删除屏障
又称为基于起始快照的屏障,满足弱三色不变式。当一个白色或灰色对象的引用被移除时,该对象被标记为灰色。
3.1.4 混合写屏障
- GC 开始时扫描栈上的对象并将可达对象全部标记为黑色
- 栈上新增的对象也直接标记为黑色,避免 STW 重复扫描
- 栈上的引用新增和删除时都不启用写屏障机制和 STW
- 当对象删除时触发删除写屏障,即讲删除的对象标记为灰色
- 当对象新增的时候触发插入写屏障,将其标记为灰色
3.2 内存泄漏
内存泄漏指已不再使用的内存未被GC回收,导致程序占用内存持续增长。
常见内存泄漏场景及原因:
- 未正确关闭的资源(文件句柄、网络连接等)go
func readFile() { f, _ := os.Open("data.txt") // 遗漏 `defer f.Close()`,导致文件描述符和关联内存泄漏 data := make([]byte, 1024) f.Read(data) } - Goroutine 无法退出导致泄漏
- 错误的通道操作导致的永久阻塞。
select分支未覆盖所有情况,且所有case都无法触发,导致 goroutine 永久阻塞。- Goroutine 内部死循环。
- 未正确使用
context控制生命周期。
- 使用 cgo 时,没有手动释放 C 分配的内存。
3.3 GMP(面试必问)
3.3.1 基本概念
G(Goroutine):
- 含义:Go 协程
- 本质:包含执行栈、程序计数器、状态等信息的结构体(
runtime.g) - 特点:创建成本低(KB级内存)、启动快,由 Go runtime 调度(非 OS 内核)
M(Machine):
- 含义:工作线程
- 本质:封装了 OS 线程的结构体(
runtime.m),与内核线程一一对应 - 特点:由 OS 调度,创建成本高(MB 级内存),数量受 OS 限制
P(Processor):
- 含义:逻辑处理器,连接 G 和 M 的中间层
- 本质:包含本地 goroutine 队列、P 状态、与 M 的绑定关系等(
runtime.p) - 核心作用:
- 作为 G 的容器(本地队列存储待执行的 G)
- 限制并发执行的 M 数量(与 GOMAXPROCS 值一致)
- 缓存线程本地资源(如内存分配缓存)
3.3.2 三者关系
M 必须绑定 P 才能执行 G,才能从 P 的本地队列或全局队列获取 G 执行。
P 管理 G 的调度:每个 P 维护一个本地队列,存放待执行的 G;全局还有一个全局队列。
数量:
- P 的数量由
GOMAXPROCS控制(默认等于 CPU 核心数) - M 的数量动态调整(默认最大10000),通常略多于 P
- G 的数量理论无上限(受内存限制)
3.3.3 调度流程
G 的创建与入队
当使用
go func()创建 G 时,runtime 会:- 初始化 G 的栈、程序计数器等信息
- 优先将 G 放入当前 P 的本地队列,本地队列满时放入全局队列
- 若本地队列和全局队列都满,会触发扩容或转移逻辑
G 的调度执行
- M 绑定 P:M 启动后,会从 P 列表中获取一个空闲的 P 并绑定
- 获取 G:
- 优先从 P 的本地队列取 G。为了防止全局队列中的 G 饿死,P 每调度 61 次,就会优先获取全局队列中的 G。
- 本地队列为空时,从全局队列取一批 G(通常取
min(全局队列长度/P数量, 128)个) - 若全局队列也为空,触发工作窃取:从其他 P 的本地队列窃取一半 G
- 执行 G:M 执行 G 的代码,直到 G 阻塞、结束或被抢占
- G 状态切换:G 执行过程中可能切换状态(如
running→waiting→runnable),重新进入队列等待调度
3.3.4 调度时机
- G 阻塞于系统调用(如文件I/O)
- M 与 P 解绑,P 可被其他 M 获取
- 阻塞结束后,M 尝试绑定 P,若无空闲 P,G 进入全局队列
- G 阻塞于 Go 原生操作(如 channel、mutex)
- G 状态置为
waiting,M 立即执行其他 G - 当阻塞解除(如 Channel 数据到达),G 被重新加入队列
- G 状态置为
- 抢占式调度
- 早期 Go 调度是协作式的,若 G 无 IO 操作、不主动让出 CPU(如死循环),会导致其他 G 饥饿
- 当本地队列中有等待的 G,且当前 G 运行超过 10ms,触发抢占