写点什么

北京七猫,薪资 25~35K,瞧瞧面试强度

作者:王中阳Go
  • 2025-04-15
    北京
  • 本文字数:6181 字

    阅读完需:约 20 分钟

北京七猫,薪资25~35K,瞧瞧面试强度

上一篇文章「坐标上海,20K 的面试强度」很受欢迎呀,看来大家喜欢看这种系列的文章,今天继续更新。


今天分享的依旧是组织内部朋友的面经,面试的岗位是北京七猫的 Go 开发岗,薪资水平是 25~35K


据他本人描述,在面试的一开始面试官就抛出了一个代码题,他当时还刚睡醒有点迷糊,所以写的不太好。在这里我建议大家在约面试时间的时候尽量选择自己头脑最清晰的时间段,用最好的状态去面试,拿到 offer 的概率才会最高。


下面就是我整理好的面试问题,请大家放心食用:


代码题

手动实现一个并发安全的 map

package main
import ( "fmt" "sync")
// SafeMap 是一个并发安全的 map 结构type SafeMap struct { mu sync.Mutex // 互斥锁,用于保护对内部 map 的并发访问 data map[string]interface{} // 存储实际数据的 map}
// NewSafeMap 初始化并返回一个新的 SafeMapfunc 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 结构体

  2. mu sync.Mutex:互斥锁,用于保护对内部 map 的并发访问。每次对 map 进行读写操作时,都需要先加锁,操作完成后再解锁。

  3. data map[string]interface{}:实际存储数据的 map,键为 string 类型,值为 interface{} 类型(可以存储任意类型的值)。

  4. NewSafeMap 函数

  5. 初始化并返回一个新的 SafeMap 实例。data 字段被初始化为一个空的 map

  6. Set 方法

  7. 用于向 SafeMap 中设置键值对。

  8. sm.mu.Lock():在写入之前加锁,确保同一时间只有一个 goroutine 可以写入 map

  9. defer sm.mu.Unlock():使用 defer 确保在函数返回时解锁,即使在函数执行过程中发生 panic 也能正确解锁。

  10. sm.data[key] = value:将键值对写入 map

  11. Get 方法

  12. 用于从 SafeMap 中获取键对应的值。

  13. sm.mu.Lock():在读取之前加锁,确保读取时不会有其他 goroutine 修改数据。

  14. defer sm.mu.Unlock():在函数返回时解锁。

  15. val, exists := sm.data[key]:从 map 中获取键对应的值,并检查键是否存在。

  16. 返回键对应的值和是否存在。

  17. Delete 方法

  18. 用于从 SafeMap 中删除键值对。

  19. sm.mu.Lock():在删除之前加锁,确保删除操作是线程安全的。

  20. defer sm.mu.Unlock():在函数返回时解锁。

  21. 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. 内存分配阈值(主要触发方式)

  2. • 当堆内存达到上次 GC 存活对象的两倍时触发(默认GOGC=100,可调整)。

  3. • 例如,存活对象占 100MB 时,堆内存增至 200MB 触发 GC。

  4. 定时强制触发

  5. • 若 2 分钟内未触发 GC,则每 2 分钟强制触发一次,避免内存泄漏。

  6. 手动触发

  7. • 调用runtime.GC()可强制立即执行 GC。

  8. 内存分配压力

  9. • 大对象分配(>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):

  2. 条件:变量的作用域仅限于当前函数,生命周期随函数调用结束而结束。

  3. 优点:分配和释放速度快,无需垃圾回收(GC)。

  4. 缺点:栈空间有限(默认初始大小为 2KB,可动态扩展,但频繁扩展可能影响性能)。

  5. 堆分配(Heap):

  6. 条件:变量的作用域超出当前函数(例如被返回、传递到外部或生命周期不确定)。

  7. 优点:适合大对象或需要跨函数共享的变量。

  8. 缺点:分配和释放较慢,依赖 GC 回收,可能增加 GC 压力。

局部变量何时会逃逸到堆上?

以下情况可能导致变量逃逸到堆:


1. 作用域超出当前函数


  • 返回局部变量的指针/地址

  • 将变量传递给外部作用域


2. 接口赋值


  • 将栈上的变量赋值给接口类型时,需要在堆上存储其动态类型信息:


3. 闭包捕获变量


  • 闭包引用的外部变量可能逃逸


4. 变量过大


  • 如果变量的大小超过栈的剩余空间,编译器可能选择将其分配到堆以避免栈溢出:

变量大小与堆分配

  • Go 的栈默认初始大小:2KB(不同版本可能调整,但通常较小)。

  • 变量大小的影响

  • 小对象(如基础类型或小结构体):通常分配在栈上,除非逃逸。

  • 大对象(如大数组、结构体):

  • 如果未逃逸,但大小超过栈的剩余空间,可能逃逸到堆。

  • 如果逃逸,无论大小均分配到堆。

  • 动态分配的切片/映射:默认分配到堆(如 make([]int, 1000))。

写代码的什么时候会将局部变量的引用返回出去?

以下情况会将局部变量的引用(指针)返回出去:


  1. 函数返回局部变量的指针

  2. 当函数需要返回一个指向局部变量的指针时,Go 的逃逸分析会自动将该变量分配到堆上,确保其生命周期超出函数作用域。例如,函数返回 *int*struct 类型时,若返回局部变量的地址,变量会逃逸到堆。

  3. 闭包捕获外部变量

  4. 当函数返回一个闭包时,若闭包引用了外部函数的局部变量,该变量会逃逸到堆,以确保闭包存活期间变量仍有效。

  5. 接口赋值

  6. 将局部变量赋值给 interface{} 类型时,Go 会将变量复制到堆上,以存储其动态类型信息,此时变量的引用可能被返回。

  7. 共享大对象

  8. 需要频繁修改或共享大对象(如结构体、数组)时,返回指针可避免值拷贝的性能开销,此时局部变量会逃逸到堆。

channel 分成有缓冲无缓冲,这两个区别说一下?什么时候选有缓冲什么时候选无缓冲?

无缓冲通道和有缓冲通道的核心区别在于阻塞行为数据暂存能力


  • 无缓冲通道:发送和接收必须同时完成。发送方会阻塞直到接收方准备好接收,接收方同样阻塞直到有数据到达。适合需要严格同步的场景(如协程间简单信号传递、确保操作顺序)。

  • 有缓冲通道:允许在缓冲区暂存数据。发送方在缓冲未满时可直接发送不阻塞,接收方在缓冲不空时可直接取数据不阻塞。适合处理速率不一致的场景(如生产者-消费者模型,避免发送方因接收方处理慢而频繁阻塞)。


选择建议


  • 选无缓冲:当需要强制发送和接收同步,确保操作即时响应(如协程间状态同步、简单任务通知)。

  • 选有缓冲:当需要解耦发送和接收(如生产者快速生成数据,消费者处理较慢),或需要临时存储数据避免阻塞(如高吞吐量场景)。缓冲区大小需根据实际吞吐需求设置,过大浪费内存,过小失去缓冲意义。

channel 的底层实现讲一下?

Go channel 底层基于hchan结构体实现,核心是环形缓冲区+等待队列。关键点如下:


  • 核心结构:每个 channel 对应一个hchan对象,包含:

  • buf:指向环形缓冲区的指针(仅带缓冲的 channel)。

  • qcount:当前缓冲区元素数量。

  • dataqsiz:缓冲区总容量(make(chan, n)中的 n)。

  • sendx/recvx:发送/接收指针(用模运算实现环形)。

  • sendq/recvq:发送/接收阻塞的 goroutine 队列(通过sudog链表实现)。

  • lock:互斥锁,保护访问。

  • 发送流程

  • 加锁,检查是否有等待接收的 goroutine(recvq非空):

  • 有:直接将数据拷贝给接收方,唤醒对方。

  • 无:检查缓冲区是否未满:

  • 未满:存入缓冲区,解锁。

  • 已满:将当前 goroutine 包装成sudog加入sendq,阻塞等待。

  • 接收流程

  • 加锁,检查是否有等待发送的 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. 生产端去重

  2. 开启生产者幂等性(enable.idempotence=true),确保重复发送的消息不会重复写入 Kafka。

  3. 适用于消息由单生产者发送的场景,保证每条消息唯一写入。

  4. 消费端去重

  5. 消费者组+偏移量管理:通过消费者组确保消息被组内消费者消费一次,结合手动提交偏移量避免重复消费。

  6. 数据库去重:在业务系统中对消息的唯一键(如订单 ID)添加唯一索引,重复消息写入时触发冲突拦截。

  7. 缓存存储:用 Redis 等缓存已处理消息的唯一标识,消费时先查询缓存,存在则跳过。

  8. Kafka Streams 去重

  9. 使用KTable或窗口化聚合(如reduce/aggregate),保留最新数据或去重后的结果。例如:


     // 示例逻辑(伪代码)     KStream grouped = stream.groupByKey();     KTable deduped = grouped.reduce((agg, newVal) -> newVal); // 保留最新值
复制代码


  • 适用于需要基于业务键(如用户 ID)去重的场景。


  1. 流处理框架(如 Flink)

  2. 在 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,备注:面试群。

发布于: 刚刚阅读数: 5
用户头像

王中阳Go

关注

靠敲代码在北京买房的程序员 2022-10-09 加入

【微信】wangzhongyang1993【公众号】程序员升职加薪之旅【成就】InfoQ专家博主👍掘金签约作者👍B站&掘金&CSDN&思否等全平台账号:王中阳Go

评论

发布
暂无评论
北京七猫,薪资25~35K,瞧瞧面试强度_Go_王中阳Go_InfoQ写作社区