上一篇文章「坐标上海,20K的面试强度」很受欢迎呀,看来大家喜欢看这种系列的文章,今天继续更新。
今天分享的依旧是[组织内部](https://mp.weixin.qq.com/s/OQD8MJakqkgMxdyvaYoX7w)朋友的面经,面试的岗位是**北京七猫的Go开发岗,薪资水平是25~35K**。
据他本人描述,在面试的一开始面试官就抛出了一个代码题,他当时还**刚睡醒有点迷糊**,所以写的不太好。在这里我建议大家在**约面试时间的时候尽量选择自己头脑最清晰的时间段**,用最好的状态去面试,拿到offer的概率才会最高。
下面就是我**整理好的面试问题**,请大家放心食用:

### 代码题
#### 手动实现一个并发安全的map
```go
package main
import (
"fmt"
"sync"
)
// SafeMap 是一个并发安全的 map 结构
type SafeMap struct {
mu sync.Mutex // 互斥锁,用于保护对内部 map 的并发访问
data map[string]interface{} // 存储实际数据的 map
}
// NewSafeMap 初始化并返回一个新的 SafeMap
func NewSafeMap() *SafeMap {
return &SafeMap{
data: make(map[string]interface{}),
}
}
// Set 方法用于向 SafeMap 中设置键值对
func (sm *SafeMap) Set(key string, value interface{}) {
sm.mu.Lock() // 加锁,确保同一时间只有一个 goroutine 可以写入
defer sm.mu.Unlock() // 在函数返回时解锁
sm.data[key] = value // 设置键值对
}
// Get 方法用于从 SafeMap 中获取键对应的值
func (sm *SafeMap) Get(key string) (interface{}, bool) {
sm.mu.Lock() // 加锁,确保读取时不会有其他 goroutine 修改数据
defer sm.mu.Unlock() // 在函数返回时解锁
val, exists := sm.data[key]
return val, exists // 返回键对应的值和是否存在
}
// Delete 方法用于从 SafeMap 中删除键值对
func (sm *SafeMap) Delete(key string) {
sm.mu.Lock() // 加锁,确保删除操作是线程安全的
defer sm.mu.Unlock() // 在函数返回时解锁
delete(sm.data, key) // 删除键值对
}
```
##### 代码解释:
1. **SafeMap 结构体**:
- `mu sync.Mutex`:互斥锁,用于保护对内部 `map` 的并发访问。每次对 `map` 进行读写操作时,都需要先加锁,操作完成后再解锁。
- `data map[string]interface{}`:实际存储数据的 `map`,键为 `string` 类型,值为 `interface{}` 类型(可以存储任意类型的值)。
2. **NewSafeMap 函数**:
- 初始化并返回一个新的 `SafeMap` 实例。`data` 字段被初始化为一个空的 `map`。
3. **Set 方法**:
- 用于向 `SafeMap` 中设置键值对。
- `sm.mu.Lock()`:在写入之前加锁,确保同一时间只有一个 `goroutine` 可以写入 `map`。
- `defer sm.mu.Unlock()`:使用 `defer` 确保在函数返回时解锁,即使在函数执行过程中发生 panic 也能正确解锁。
- `sm.data[key] = value`:将键值对写入 `map`。
4. **Get 方法**:
- 用于从 `SafeMap` 中获取键对应的值。
- `sm.mu.Lock()`:在读取之前加锁,确保读取时不会有其他 `goroutine` 修改数据。
- `defer sm.mu.Unlock()`:在函数返回时解锁。
- `val, exists := sm.data[key]`:从 `map` 中获取键对应的值,并检查键是否存在。
- 返回键对应的值和是否存在。
5. **Delete 方法**:
- 用于从 `SafeMap` 中删除键值对。
- `sm.mu.Lock()`:在删除之前加锁,确保删除操作是线程安全的。
- `defer sm.mu.Unlock()`:在函数返回时解锁。
- `delete(sm.data, key)`:从 `map` 中删除指定的键值对。
### 问答题
### atomic包实现原理,为什么可以做到原子操作?
##### atomic包实现原理
- atomic包主要利用了底层硬件提供的原子指令来实现原子操作。
- 在不同的操作系统和硬件架构下,会有不同的原子指令支持。例如在x86架构下,会使用CMPXCHG指令(比较并交换)。
- Go语言的运行时系统会针对不同的平台调用相应的原子指令。这些原子指令在执行过程中不会被其他线程或goroutine中断,从而保证了操作的原子性。
##### 为什么可以做到原子操作
- 以CAS操作为例,它是一种原子指令。CAS操作包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。
- 执行CAS操作时,硬件会自动比较内存位置V中的值是否等于预期原值A。如果相等,则将内存位置V的值更新为新值B;如果不相等,则不进行更新操作。
- 整个比较和更新的过程是作为一个原子操作执行的,也就是说在这个过程中不会被其他线程或goroutine干扰。这是由硬件层面保证的,硬件会锁定相关的缓存行或者内存区域,防止其他操作同时修改该内存位置的值,从而确保了操作的原子性。
### 垃圾回收?GO垃圾回收触发的时机?
##### 一、Go语言的垃圾回收机制
Go的垃圾回收(GC)机制通过**并发标记清除算法**实现自动内存管理,核心目标是减少程序暂停时间(STW)并提升效率。其核心原理和优化技术如下:
##### 1. **三色标记法**
这是Go GC的核心算法,用于标记存活对象:
• **白色**:未被访问的潜在垃圾对象。
• **灰色**:已访问但子对象未完全扫描的对象。
• **黑色**:已访问且子对象全部扫描完成的对象。
标记过程从根对象(全局变量、栈指针等)出发,逐步将可达对象标记为灰色→黑色,最终白色对象被回收。
##### 2. **并发执行与混合写屏障**
• **并发标记**:GC与用户程序并发运行,减少停顿。
• **写屏障(Write Barrier)**:在标记阶段,通过写屏障捕获对象引用的修改,确保黑色对象不会直接指向白色对象,维护三色不变性。
• **混合写屏障**(Go 1.8+):进一步减少STW时间,仅需极短暂停处理栈空间,实现“几乎无STW”的并发GC。
##### 3. **内存分配优化**
• **逃逸分析**:编译器判断对象生命周期,将短生命周期对象分配在栈上,减少堆内存压力。
• **内存池化**:复用小对象内存,降低GC频率。
##### 二、Go垃圾回收触发时机
触发条件主要包括以下四类:
1. **内存分配阈值**(主要触发方式)
• 当堆内存达到**上次GC存活对象的两倍**时触发(默认`GOGC=100`,可调整)。
• 例如,存活对象占100MB时,堆内存增至200MB触发GC。
2. **定时强制触发**
• 若2分钟内未触发GC,则**每2分钟强制触发一次**,避免内存泄漏。
3. **手动触发**
• 调用`runtime.GC()`可强制立即执行GC。
4. **内存分配压力**
• 大对象分配(>32KB)或堆内存不足时,直接触发GC释放空间。
### 总结
Go的GC通过**三色标记+并发写屏障**实现高效回收,触发时机以内存增长为核心,辅以定时和手动控制。从Go 1.8开始,混合写屏障技术大幅降低STW时间,使其成为高并发场景下的理想选择。开发者可通过调整`GOGC`或分析`GODEBUG=gctrace=1`日志优化GC行为。
### 垃圾回收占CPU比较多,有什么优化方法?
**调整GOGC参数**
默认`GOGC=100`表示堆内存增长至前次GC后的两倍时触发回收。若CPU占用高但内存充足,可增大`GOGC`(如设为200),减少GC频率。反之,若内存紧张则降低该值,通过更频繁回收避免内存压力。
**减少内存分配**
高频分配临时对象会增加GC压力。可通过复用对象(如`sync.Pool`缓存临时缓冲区)、预分配切片/Map容量、避免逃逸分析失败(减少堆内存分配)来降低分配频率。例如,复用`bytes.Buffer`而非每次创建新对象。
**优化数据结构**
选择低内存占用的结构,例如用切片替代链表、避免深度嵌套结构体。减少对象引用层级可降低GC扫描复杂度,从而节省CPU时间。同时,避免在循环内创建短生命周期对象。
**调整并发参数**
通过`GOMAXPROCS`增加GC的并发线程数(如设为CPU核数的1.5倍),提升标记阶段的并行效率。但需注意线程竞争问题,避免过度设置。
**启用性能分析**
使用`GODEBUG=gctrace=1`查看GC日志,分析触发频率与耗时。结合`pprof`工具定位内存分配热点,针对性优化高频分配代码段。例如,识别某函数频繁分配大切片并优化为对象池。
### 逃逸分析?局部变量多大才会在堆上分配?
**逃逸分析**是编译器决定变量分配在栈(Stack)还是堆(Heap)的核心机制。
#### **逃逸分析的核心原则**
1. **栈分配**(Stack):
- **条件**:变量的作用域仅限于当前函数,生命周期随函数调用结束而结束。
- **优点**:分配和释放速度快,无需垃圾回收(GC)。
- **缺点**:栈空间有限(默认初始大小为 2KB,可动态扩展,但频繁扩展可能影响性能)。
2. **堆分配**(Heap):
- **条件**:变量的作用域超出当前函数(例如被返回、传递到外部或生命周期不确定)。
- **优点**:适合大对象或需要跨函数共享的变量。
- **缺点**:分配和释放较慢,依赖 GC 回收,可能增加 GC 压力。
#### **局部变量何时会逃逸到堆上?**
以下情况可能导致变量逃逸到堆:
**1. 作用域超出当前函数**
- 返回局部变量的指针/地址
- 将变量传递给外部作用域
**2. 接口赋值**
- 将栈上的变量赋值给接口类型时,需要在堆上存储其动态类型信息:
**3. 闭包捕获变量**
- 闭包引用的外部变量可能逃逸
**4. 变量过大**
- 如果变量的大小超过栈的剩余空间,编译器可能选择将其分配到堆以避免栈溢出:
#### **变量大小与堆分配**
- **Go 的栈默认初始大小**:2KB(不同版本可能调整,但通常较小)。
- **变量大小的影响**:
- **小对象**(如基础类型或小结构体):通常分配在栈上,除非逃逸。
- **大对象**(如大数组、结构体):
- 如果未逃逸,但大小超过栈的剩余空间,可能逃逸到堆。
- 如果逃逸,无论大小均分配到堆。
- **动态分配的切片/映射**:默认分配到堆(如 `make([]int, 1000)`)。
### 写代码的什么时候会将局部变量的引用返回出去?
以下情况会将局部变量的引用(指针)返回出去:
1. **函数返回局部变量的指针**
当函数需要返回一个指向局部变量的指针时,Go 的逃逸分析会自动将该变量分配到堆上,确保其生命周期超出函数作用域。例如,函数返回 `*int` 或 `*struct` 类型时,若返回局部变量的地址,变量会逃逸到堆。
2. **闭包捕获外部变量**
当函数返回一个闭包时,若闭包引用了外部函数的局部变量,该变量会逃逸到堆,以确保闭包存活期间变量仍有效。
3. **接口赋值**
将局部变量赋值给 `interface{}` 类型时,Go 会将变量复制到堆上,以存储其动态类型信息,此时变量的引用可能被返回。
4. **共享大对象**
需要频繁修改或共享大对象(如结构体、数组)时,返回指针可避免值拷贝的性能开销,此时局部变量会逃逸到堆。
### channel分成有缓冲无缓冲,这两个区别说一下?什么时候选有缓冲什么时候选无缓冲?
无缓冲通道和有缓冲通道的核心区别在于**阻塞行为**和**数据暂存能力**:
- **无缓冲通道**:发送和接收必须同时完成。发送方会阻塞直到接收方准备好接收,接收方同样阻塞直到有数据到达。适合需要严格同步的场景(如协程间简单信号传递、确保操作顺序)。
- **有缓冲通道**:允许在缓冲区暂存数据。发送方在缓冲未满时可直接发送不阻塞,接收方在缓冲不空时可直接取数据不阻塞。适合处理速率不一致的场景(如生产者-消费者模型,避免发送方因接收方处理慢而频繁阻塞)。
**选择建议**:
- **选无缓冲**:当需要强制发送和接收同步,确保操作即时响应(如协程间状态同步、简单任务通知)。
- **选有缓冲**:当需要解耦发送和接收(如生产者快速生成数据,消费者处理较慢),或需要临时存储数据避免阻塞(如高吞吐量场景)。缓冲区大小需根据实际吞吐需求设置,过大浪费内存,过小失去缓冲意义。
### channel的底层实现讲一下?
Go channel底层基于`hchan`结构体实现,核心是环形缓冲区+等待队列。关键点如下:
- **核心结构**:每个channel对应一个`hchan`对象,包含:
- `buf`:指向环形缓冲区的指针(仅带缓冲的channel)。
- `qcount`:当前缓冲区元素数量。
- `dataqsiz`:缓冲区总容量(`make(chan, n)`中的n)。
- `sendx/recvx`:发送/接收指针(用模运算实现环形)。
- `sendq/recvq`:发送/接收阻塞的goroutine队列(通过`sudog`链表实现)。
- `lock`:互斥锁,保护访问。
- **发送流程**:
1. 加锁,检查是否有等待接收的goroutine(`recvq`非空):
- 有:直接将数据拷贝给接收方,唤醒对方。
- 无:检查缓冲区是否未满:
- 未满:存入缓冲区,解锁。
- 已满:将当前goroutine包装成`sudog`加入`sendq`,阻塞等待。
- **接收流程**:
1. 加锁,检查是否有等待发送的goroutine(`sendq`非空):
- 有:直接取数据给发送方,唤醒对方。
- 无:检查缓冲区是否有数据:
- 有:从缓冲区取数据,解锁。
- 无:将当前goroutine加入`recvq`,阻塞等待。
- **阻塞与唤醒**:
- 当队列为空且缓冲区满/空时,goroutine会被封装为`sudog`节点,挂起在对应队列。
- 当另一端操作完成时(如发送方存入数据),会尝试唤醒队列中的等待者。
- **无缓冲channel**:
- 直接要求发送和接收goroutine配对,无需缓冲区(`dataqsiz=0`)。
- 发送时直接检查`recvq`是否有等待者,否则阻塞进`sendq`;接收反之。
- **缓冲channel**:
- 利用`buf`暂存数据,发送/接收可异步进行。
- 缓冲区满时发送阻塞,空时接收阻塞。
- **关闭与GC**:
- `closed`标记关闭状态,关闭后禁止发送,接收读取剩余数据后返回零值。
- 元素含指针时,缓冲区内存需被GC追踪,无指针则无需。
简而言之:channel通过`hchan`管理数据和goroutine阻塞状态,锁保证安全,缓冲区解耦发送接收,队列实现高效唤醒。
### kafka的使用场景?如何去重?
#### Kafka的使用场景:
Kafka主要用于高吞吐量、分布式的数据处理场景,常见用途包括:
1. **解耦系统**:生产者发送消息到Kafka,消费者异步处理,降低服务间耦合。
2. **削峰填谷**:应对突发流量(如促销秒杀),通过消息队列缓冲请求,避免系统过载。
3. **日志收集**:集中收集应用日志、服务器日志,供实时分析或持久化存储。
4. **流处理**:与Flink、Spark Streaming等结合,实现实时数据处理(如实时监控、用户行为分析)。
5. **事件溯源**:将业务状态变化记录为事件流,支持回放和分析。
6. **消息系统**:替代传统消息队列(如ActiveMQ),支持高并发、低延迟的消息传递。
#### Kafka数据去重方法:
去重需结合场景选择策略,常见方案如下:
1. **生产端去重**:
- 开启生产者幂等性(`enable.idempotence=true`),确保重复发送的消息不会重复写入Kafka。
- 适用于消息由单生产者发送的场景,保证每条消息唯一写入。
2. **消费端去重**:
- **消费者组+偏移量管理**:通过消费者组确保消息被组内消费者消费一次,结合手动提交偏移量避免重复消费。
- **数据库去重**:在业务系统中对消息的唯一键(如订单ID)添加唯一索引,重复消息写入时触发冲突拦截。
- **缓存存储**:用Redis等缓存已处理消息的唯一标识,消费时先查询缓存,存在则跳过。
3. **Kafka Streams去重**:
- 使用`KTable`或窗口化聚合(如`reduce`/`aggregate`),保留最新数据或去重后的结果。例如:
```java
// 示例逻辑(伪代码)
KStream grouped = stream.groupByKey();
KTable deduped = grouped.reduce((agg, newVal) -> newVal); // 保留最新值
```
- 适用于需要基于业务键(如用户ID)去重的场景。
4. **流处理框架(如Flink)**:
- 在Flink中使用状态后端(如RocksDB)存储已处理消息的唯一标识,通过窗口或状态管理去重。
### kafka高性能的原理?压缩方法了解吗?
#### Kafka高性能的核心原理包括:
- **批量处理**:生产者将消息批量发送,消费者批量拉取,减少网络请求次数和RTT(往返时间),提升吞吐量。
- **磁盘顺序写入**:消息以追加方式写入日志文件,避免随机IO,利用磁盘顺序写入的高效率(机械硬盘顺序写入比随机快几十倍,SSD更高)。
- **零拷贝技术**:通过`sendfile()`系统调用,数据直接从操作系统PageCache传输到网卡,避免内核与用户空间之间的多次复制。
- **PageCache缓存**:数据先写入操作系统页缓存,由后台线程异步刷盘,减少磁盘IO延迟。
- **分区与副本机制**:Topic分区分散到不同Broker,实现负载均衡;副本(ISR同步副本)保障高可用,同时隔离慢节点。
- **内存池复用**:Producer端使用内存池管理消息缓冲区,避免频繁GC,提升内存利用率。
#### 关于压缩方法:
Kafka支持**GZIP、Snappy、LZ4、Zstd**等算法,生产者端对批量消息压缩后发送,Broker直接存储压缩数据,消费者消费时解压。
- **GZIP**:高压缩率,适合存储空间敏感场景,但CPU消耗高、速度慢。
- **Snappy**:压缩/解压速度快,适合高吞吐场景,但压缩率较低。
- **LZ4**:速度接近Snappy,压缩率略低,平衡性能与空间。
- **Zstd**:可调压缩级别,在高压缩率和高性能间折中,适合对带宽和CPU有灵活需求的场景。
## 欢迎关注 ❤
我们搞了一个**免费的面试真题共享群**,互通有无,一起刷题进步。
**没准能让你能刷到自己意向公司的最新面试题呢。**
感兴趣的朋友们可以加我微信:**wangzhongyang1993**,备注:面试群。
有疑问加站长微信联系(非本文作者))
