Appearance
- java 和 go 的区别
- 线程、进程和 goroutine 的区别
- 你用过泛型么,接口和泛型的区别
- 内存泄露和内存逃逸
- map 底层
- gin 的工作原理
- GMP 并发模型
- sync.map 底层是如何实现的?
go 中 make 和 new 的区别
| 特性 | new | make | 记住的口诀 |
|---|---|---|---|
| 作用对象 | 任意类型 | 只用于 slice / map / channel | new 啥都行,make 只限三种 |
| 返回值 | *T(指针) | T(值本身) | new 给地址,make 给东西 |
| 是否初始化内容 | 只分配零值内存 | 分配内存 + 初始化内部结构 | new 只给空壳,make 给成品 |
| 常见使用场景 | 结构体、基本类型指针 | 创建可用的 slice/map/channel | —— |
| 零值表现 | &T{}(所有字段零值) | 长度/容量为 0 的 slice、空的 map、空 channel | —— |
- new:只负责分配内存 + 返回指针,内容是零值
- make:分配内存 + 初始化内部数据结构,返回可直接使用的值(只给 slice/map/channel 用)
java 和 go 的区别
Java 用重量级线程做并发,切换成本高、并发量有限,数据共享主要靠锁;Go 用用户态协程,切换轻、能轻松扛住数万级并发,并用 channel 做数据传递减少竞争。Go 的 goroutine 是用户态协程,切换成本极低所以能轻松跑上万并发;Java 线程是内核线程,栈大且切换昂贵,线程数一多就容易因为内存占用过高而 OOM
Java 依赖 JVM 运行、部署偏重,而 Go 是直接编译成二进制上服务器即跑
语言上 Java 功能齐全但更啰嗦,Go 刻意保持简洁
Java 多用于大数据、电商等企业级业务,而 Go 更适合中间件、高性能服务和云原生场景
线程、进程和 goroutine 的区别
进程和线程的切换都要让操作系统介入,保存大量状态,因此一次切换成本高、频率上来后性能急剧下降;而 goroutine 属于用户态协程,由 Go runtime 自己调度,切换时只改少量状态,不触发系统调用,所以切换开销更低,能在几十个内核线程上承载成千上万个 goroutine,从而在高并发场景下表现出远高于线程的效率
你用过泛型么,接口和泛型的区别
接口解决的是行为多态,泛型解决的是类型复用。接口像我不管你是啥,只要你会这些动作我就能用你;泛型更像你得是这一类东西,我才能让你进模板里编译
内存泄露和内存逃逸
内存泄露就是:本来应该被 GC 回收的对象,因为你的代码还在某处引用它,导致永远不会释放。
内存逃逸是编译器决定某个变量不放栈里,而是放在堆里。
逃逸不是坏事,只是让变量变成“堆对象”,让 GC 管理。性能影响是:堆=需要 GC,栈=不用 GC,所以堆分配更贵。
资源泄露和内存泄露的区别,跟 GC 没关系,不属于 Go 堆上的内存
Go 内存泄露:
- goroutine 没退出,越积越多
- map 不断累积数据,永远不删除:map 是强引用,你不删,GC 永远不能动它
- slice 当队列用,但不清空前半部分
map 底层
- Go 的 map 是分桶哈希表 + overflow 链 + 渐进扩容 + 随机迭代顺序的组合
- map 循环是无序的
slice 的底层
slice 其实是一个结构体,里面只有三样东西:ptr len cap
- ptr 底层数组的指针
- len 可用的长度
- cap 底层数组的容量
append 超过 cap 会触发扩容,Go 会新建一个更大的数组,把旧内容拷过去
gin 的工作原理
Gin 的工作原理就是 HTTP 请求 -> 封装 Context -> Radix Tree 路由匹配 -> 执行中间件链 -> 执行业务 Handler -> 写响应,同时通过 Context 池化和高效路由保证高性能
Gin 默认并不会自己维护 goroutine 池去复用协程,每个 HTTP 请求都是由 Go 的 net/http 直接在新的 goroutine 中执行的,由 Go runtime 的调度器管理(面试官还跟我说 gin 内部有协程池...)
go
func (srv *Server) Serve(l net.Listener) error {
// ...
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
for {
rw, err := l.Accept()
// ...
connCtx := ctx
// ...
c := srv.newConn(rw)
// ...
go c.serve(connCtx)
}
}GPM 并发模型
- 促进理解的几句话
- Go runtime 把很多 goroutine 调度到少量线程上跑
- 协程:用户态;线程:内核态
- Go 在我看来核心优势是高并发模型。goroutine + channel 是用户态轻量调度,GPM 模型能高效利用 CPU
- G(goroutine 队列)、P(执行单元)和 M(线程)的之间工作原理
- 阻塞 syscall:磁盘 I/O 阻塞、网络链接阻塞、time.Sleep
- 本地队列和全局队列
- P 设置
GOMAXPROCS
sync.map 底层是如何实现的?
TODO
redis 分布式锁是公平锁吗?
TODO
go 的性能分析与调优
TODO
context 超时控制
耗时操作必须可取消,比如:
- 网络请求 → 使用 http.NewRequestWithContext(ctx, ...)
- 数据库查询 → 支持 context
- 自己写循环耗时 → 每次循环检查 ctx.Done()
每个 goroutine 自己循环耗时,每次检查 ctx.Done() 的版本
go
package main
import (
"context"
"fmt"
"math/rand"
"sync"
"time"
)
func main() {
rand.Seed(time.Now().UnixNano())
// 模拟创建任务
tasks := make([]int, 20)
for i := range tasks {
tasks[i] = i + 1
}
maxConcurrency := 5
sem := make(chan struct{}, maxConcurrency)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var wg sync.WaitGroup
for _, task := range tasks {
sem <- struct{}{}
wg.Add(1)
go func(t int) {
defer wg.Done()
defer func() { <-sem }()
// 一个 for 循环任务,每次循环检查 ctx
steps := rand.Intn(5) + 3
for step := 1; step <= steps; step++ {
select {
case <-ctx.Done():
fmt.Printf("Task %d canceled at step %d\n", t, step)
return
default:
// 模拟耗时操作
duration := time.Duration(rand.Intn(400)+100) * time.Millisecond
time.Sleep(duration)
fmt.Printf("Task %d step %d done, took %v\n", t, step, duration)
}
}
fmt.Printf("Task %d fully done\n", t)
}(task)
}
// 等待所有 goroutine 完成或 context 超时
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
fmt.Println("All tasks completed")
case <-ctx.Done():
fmt.Println("Timeout reached, exiting")
}
}Go 内存模型
Go 内存模型定义了并发读写变量的可见性和顺序性,核心是 happens-before 原则。 只有通过 channel、mutex、atomic 等同步手段,才能保证 goroutine 之间看到一致的变量值。
内存逃逸和 GC
什么是内存逃逸(Escape):本来应该放在栈上的变量,被迫放到堆上了,这叫逃逸
为啥会逃逸:变量活得太久,栈装不下,只能去更持久的堆
发生逃逸的情况:
- 函数返回局部变量的指针
- interface 导致类型不确定 -> 为啥 -> 编译器无法确定变量类型大小,保险起见让它去堆上
- append 导致切片扩容 -> 为啥 -> 栈内存装不下啊
- 闭包 -> 为啥 -> 闭包还需访问函数的变量,变量无法随函数结束而消失
go
func foo() func() {
x := 10
return func() { fmt.Println(x) }
}逃逸分析
逃逸分析决定变量放栈还是堆
逃逸分析一般对某个包、某个文件、甚至某段代码做局部分析,常用法是:
bash
go build -gcflags="-m" main.go或通过 grep 定位关键输出,筛出性能敏感路径的堆分配点
bash
go build -gcflags="-m" file.go 2>&1 | grep escapes举例说明
需要分析的 go 文件
go
package main
import "fmt"
func main() {
foo()
fmt.Println("Helloworld")
}
func foo() *int {
x := 10
return &x
}执行逃逸分析命令
bash
=> go build -gcflags="-m" main.go
# command-line-arguments
./main.go:10:6: can inline foo
./main.go:6:5: inlining call to foo
./main.go:7:13: inlining call to fmt.Println
./main.go:7:13: ... argument does not escape
./main.go:7:14: "Helloworld" escapes to heap
./main.go:11:2: moved to heap: xGo 逃逸分析核心知识总表
| 分类 | 你需要关注的点 | 示例输出 | 含义(费曼式说明) | 优化方向 |
|---|---|---|---|---|
| 1. 变量逃逸到堆 | 看到 escapes to heap 就重点看 | x escapes to heap | 这个变量不能随栈销毁,被放到堆或静态区了 → 会增加 GC 压力 | 是否可改为栈变量、减少生命周期 |
| 2. 逃逸原因:取地址(&) | taking address of x | &x escapes to heap | 你把“钥匙地址”给出去了,必须放在堆 | 不返回局部变量指针;用值传递 |
| 3. 逃逸原因:interface 封装 | assigned to interface | t escapes to heap | interface 像万能收纳箱,编译器不敢放栈 | 避免频繁把小对象塞 interface |
| 4. 逃逸原因:append 导致切片扩容 | append causes allocation | make([]int) escapes to heap | 桌子(栈)放不下,搬到院子(堆) | 预分配容量:make([]T, 0, n) |
| 5. 逃逸原因:闭包捕获外部变量 | closure captures x | x escapes to heap | 闭包会带着变量一起活,比函数活得久 | 避免闭包引用大对象;或复制值 |
| 6. 字面量逃逸(字符串、map、slice) | 常见但无害 | "xxx" escapes to heap | 字面量放静态区,但分析器仍标为 escape | 一般忽略,不会频繁堆分配 |
| 7. 工具输出噪声:inline、ssa | 全部忽略 | can inline foo | 这与逃逸无关,是编译优化提示 | 无需关注 |
| 8. 真正的定位方式 | 只看含 escapes 的行 | grep escapes | 控制台噪声太多,只过滤关键点 | go build -gcflags="-m" file.go 2>&1 | grep escapes |
| 9. 局部分析方式 | 分析单个文件 | go build -gcflags="-m" file.go | 大项目输出巨大,只分析局部 | 高效、干净、可重现 |
| 10. 深度分析 | -m -m | go build -gcflags="-m -m" | 输出更详细原因链路 | 只在需要时使用 |
为啥做逃逸分析:帮助编译器尽量把变量分配到栈而不是堆,从而减少 GC 扫描次数。在高并发或性能敏感路径中,每次堆分配都可能触发 GC,逃逸分析减少堆分配 = 减少 GC 压力 = 系统更稳定高效
逃逸分析 -> GC
GC 优化关键词:
- GC 爆炸
- p99
- STW
CPU 高 + STW 暂停 → 响应延迟飙升,系统吞吐下降
减少堆分配 → 减少 GC 频率 → 系统更稳 → 更高效
Go 的 GC 主要在堆增长超过阈值时触发,GC 会占用 CPU 并引起短暂暂停(STW);频繁堆分配会导致 GC 爆炸,表现为 CPU 占用高、延迟抖动、吞吐下降。减少堆分配、优化逃逸可以降低 GC 压力,从而让系统更稳定高效。
channel
- 缓冲/无缓冲区别
- channel 关闭后 send/receive 行为
- 向已关闭 channel 发送会 panic
- 从已关闭 channel 继续接收,不会阻塞,永远读到零值
- 判断 channel 是否关闭只能通过 “comma, ok” 第二个 bool 参数
select 的调度策略
TODO select 的调度策略(随机、一致性)
| 情况 | 行为 |
|---|---|
| 多个 case 都可执行 | 随机选一个(不会固定某个 case) |
| 有 default | 若所有 case 都阻塞,立刻走 default |
| 没有 default | 所有 case 都阻塞则阻塞整个 select |
| select 实现超时 | time.After() 会创建定时器 channel |
后端系统设计
- 高并发怎么扛:负载均衡、缓存、集群
- 缓存怎么布置
- 数据一致性怎么保障:强一致(ACID)、最终一致(BASE)
- 限流、熔断、降级怎么做
Redis 使用场景
- 缓存(Cache Aside)
- 缓存问题:缓存穿透、缓存击穿、缓存雪崩
- 缓存更新
- 缓存过期 LRU
- 分布式锁(SET NX EX)
- 限流(滑动窗口 / token bucket)
可观测意识:日志 / 指标 / 链路追踪
日志:
- 库:log、zap
- 日志分级(info/debug/warn/error)
- request_id
- 日志结构化 JSON -> 阿里云 SLS
指标:
Prometheus + Grafana 没用过
链路追踪:
OpenTelemetry / Jaeger
- 上下游传递 traceID,支持 span / parent-child 层级
- 如何在 RPC 或 HTTP 微服务中传递 traceID
我会用结构化日志 + traceID 进行链路追踪,同时通过 Prometheus/Grafana 监控关键指标,保证线上可快速定位问题
性能分析与调优
- pprof
- 类似 linux 的 top 命令
- pprof 是 Go 内置性能分析工具,支持 CPU、内存、goroutine、阻塞
- 帮你找出消耗资源最多的函数
- 火焰图(Flame Graph):pprof 输出可转成火焰图,直观显示 CPU 时间分布
- CPU 消耗最多的函数在哪里?
- 怎么优化热点函数?
- GC 调优
- Go GC 特点:并发标记清扫(concurrent mark-sweep)、STW pause 短暂暂停
- 触发因素:堆分配过多、大对象频繁分配
- 调优方法:避免逃逸、减少短生命周期对象堆分配
用 pprof CPU / heap / block profile 定位热点函数和内存泄漏,再用火焰图分析消耗,最后结合逃逸分析和堆分配优化 GC
高并发 RPC 请求延迟高 → pprof 找到热点在 JSON 序列化 → 用 jsoniter 替换 → CPU 消耗下降 30% 内存暴涨 → heap profile → 大对象频繁堆分配 → 改为对象池复用 → GC pause 明显下降
grpc 的主要响应模式
TODO
defer 的执行逻辑
TODO
你用过哪些 mcp?
- playwright
- github
- context7
- n8n
- markitdown