腾讯后端开发岗面经合集:带锁 Map 与 AI 协同开发
- 轮次
- 多轮面试合集
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- (面经 01)介绍项目的业务场景
- (面经 01)常见的排序算法有哪些?
- (面经 01)快速排序的原理、实现方法和复杂度是什么?
- (面经 01)请介绍 TCP 三次握手和四次挥手
- (面经 01)算法:实现 LRU,并自行构造输入输出
- (面经 02)HashMap 的底层原理是什么,如何保证线程安全?
- (面经 02)算法:使用两个队列实现一个栈
- (面经 02)堆排序的原理是什么?
- (面经 02)排序算法的稳定性是什么意思?
- (面经 02)开发中如何与 AI 协作?
- (面经 02)是否阅读过 Skill 的具体内容?
- (面经 02)实习中遇到过什么难题,如何保障系统可靠性?
- (面经 03)在大型项目中,AI 根据文档生成代码后,如何验收代码是否符合需求且功能正常?
- (面经 03)既然代码由 AI 生成,为什么仍需要人工测试?
- (面经 03)主链路验证通过后,如何确认代码可以安全合入线上?
- (面经 03)从需求分析、方案设计到代码实现,是否有标准的 AI 协同开发模板、框架或工具?
- (面经 03)没有标准 AI 协同开发流程时,如何保证开发质量和工程效果?
- (面经 03)了解哪些成熟的 AI 协同开发实践?
- (面经 03)拿到 AI 输出后,如何判断内容是否正确?
- (面经 03)请介绍对 Go 的掌握程度
- (面经 04)系统设计:实现一个带锁的 Map,说明核心数据结构
- (面经 04)带锁 Map 的 Read Map 和 Dirty Map 如何设计,各自承担什么职责?
- (面经 04)读取 Read Map 的同时并发写入 Dirty Map 应如何处理?
- (面经 04)在 CP 或 AP 取舍下,读写锁应采用什么粒度?
- (面经 04)如何降低多锁设计带来的开销?
- (面经 04)该设计与 Redis Cluster、MIT 6.824 中相关方案相比有什么优缺点?
- (面经 04)系统设计:单线程接收十万个计算型请求,如何设计任务管理和执行函数?
- (面经 04)该计算任务如何使用 MapReduce 思路处理?
- (面经 04)任务调度应使用什么数据结构,为什么使用队列而不是 Map?
- (面经 04)十万个请求同时到达时如何处理?
- (面经 04)应创建多少个 Goroutine,每个任务传递哪些参数?
- (面经 04)执行具体任务前是否需要加锁?
- (面经 04)算法:一个文件包含 20 亿个 32 位整数……(原帖在此截断)
《参考解析》
1. HashMap 的底层原理与线程安全。JDK 1.8 的 HashMap 是「数组 + 链表 + 红黑树」:用 (n − 1) & hash 定位桶,hash 先经过扰动(h ^ (h >>> 16))让高位参与运算,减少冲突;链表长度达到 8 且数组长度不小于 64 时树化(否则先扩容),退化阈值是 6;默认负载因子 0.75,扩容是 2 倍,1.8 改用尾插(1.7 是头插)。并发问题要说清版本差异:1.7 头插在多线程扩容时会形成环导致死循环、CPU 打满;1.8 改尾插后不会死循环了,但仍会丢数据(两个线程同时 put 到同一个空桶,后写的覆盖先写的),size 计数也不准。所以并发场景必须用 ConcurrentHashMap:1.8 放弃了分段锁,改成「CAS 插入空桶 + synchronized 锁桶头节点」,size 用 baseCount 加 CounterCell[] 分散热点(类似 LongAdder);它的读操作不加锁(Node 的 val 和 next 都是 volatile),并用 volatile 的 tabAt 保证桶数组可见性。追问一般会落到「为什么不允许 null 键值」——并发下 get 返回 null 无法区分「键不存在」和「值就是 null」,所以干脆禁用。
2. 排序算法与稳定性。快排是选基准分区、递归处理两侧:平均 O(n log n)、最坏 O(n²)(已排序数组加固定取首元素)、原地、不稳定;工程实现要随机化或三数取中选 pivot,小区间切插入排序,并用尾递归优化控制栈深。堆排序建堆是 O(n),每次取堆顶后 O(log n) 调整,总 O(n log n)、原地、不稳定、常数比快排大,但对最坏情况有保证——这也是 introsort 在快排递归过深时切堆排的原因。稳定性的定义是「相等的元素排序后相对次序不变」:冒泡、插入、归并、计数、基数稳定,选择、快排、堆排不稳定。为什么工程上在意:多字段排序要能「先按 B 排、再按 A 排」并保留 B 的顺序,Java 的 Collections.sort 用 TimSort 就是为了稳定。再补一句复杂度边界:比较排序的下界是 Ω(n log n),要突破必须用非比较排序(计数、基数、桶),代价是对数据范围或位数有约束。
3. 带锁 Map 的 Read Map 与 Dirty Map。这是 sync.Map 的思路,适合「读多写少、key 基本不变」的场景。结构上是两个 map:read 是只读的,用 atomic.Pointer[readOnly] 存,里面有 m map[any]*entry 和 amended bool 标记;dirty 是普通 map,由 mutex 保护。读路径先查 read,未命中且 amended 为 true 时加锁查 dirty 并对 misses 计数;misses 累加到 dirty 的长度时,把 dirty 整体提升为 read、dirty 置空——这是为了不让「read 未命中就查 dirty」变成常态。写路径:key 已存在于 read 时只原子地改 entry 指针,不必加锁;不在 read 里才加锁写 dirty,并把 read 中对应的 entry 标成 expunged(表示这个 key 在 dirty 提升时不需要带过来)。entry 用指针包一层,是为了把「删除」变成一次原子的指针置 nil,而不需要真的删 map。取舍要讲透:它偏向读性能(读几乎无锁),代价是写和删除更贵、read 与 dirty 并存时内存有冗余;读写比不高时直接用 RWMutex + map 更省内存、语义也更简单;另一个常见折中是分片锁——把 key 哈希到 N 个「Mutex + map」上,既降低锁竞争又保留普通 map 的语义。和 Redis Cluster、MIT 6.824 的方案对比时,抓住「都是为了降低争用,区别在一致性级别和开销来源」这个主线:分片是把争用按 key 拆开,共识协议换的是强一致。
4. 十万计算型请求的任务管理。单线程接收加异步计算的标准解法是「有界队列 + worker 池 + 背压」。①接收线程只做反序列化和入队,队列用环形缓冲。为什么用队列不用 Map:任务有到达顺序和公平性要求,FIFO 天然表达「先到先服务」,而 map 按键寻址、遍历无序,要额外维护序号才能表达顺序;只有需要「按 id 取消某个任务」「按请求去重」「按 id 查状态」时才用 map 作为辅助索引。②队列必须有界,满了要有明确策略:阻塞接收(自然背压,但会顶到调用方)、返回 429 让上游重试、或按优先级丢弃低优任务。③worker 数量按任务类型定:CPU 密集取 GOMAXPROCS(核数),IO 密集可以更多;每个任务只传必要参数,传切片时注意共享底层数组的坑,要么传副本要么明确约定只读。④「执行前要不要加锁」——如果每个任务只操作自己的数据就不需要;要更新共享计数器或统计时才用 atomic 或分片锁,绝不能在整个任务执行期间持锁,那等于把并行变串行。⑤MapReduce 化:把十万个请求按 key 或按输入分片切成 map 阶段并行处理,每个 worker 产出局部结果,再用 reduce 阶段(或分层归并、sync.WaitGroup 加结果 channel)汇总,好处是分片内部无锁、只在归并时同步一次。⑥别忘了超时与取消(context)、失败重试与死信队列,以及用 pprof 确认瓶颈在调度还是在计算。
5. AI 生成代码的验收与安全合入。这题要答成一套可执行流程,而不是表态。①生成前:把需求写成可验证的规格——接口签名、输入输出、边界条件、错误码,并给足上下文(相关文件、现有约定、已有测试),这样「验收标准」在写代码之前就定好了。②生成后:先看 diff(改动要小,不接受顺手重构无关文件),再跑单测与集成测试;对关键逻辑要自己补边界用例(空值、超长输入、并发、幂等)。③为什么仍要人工测试:AI 输出是概率性的,会出现「看起来对的错」——幻觉 API、漏掉错误分支、把测试写成永远通过;它也不知道线上流量形态和历史包袱,测试与 review 是把「生成」变成「工程」的那道关。④安全合入的判断标准是「可灰度、可回滚、有监控、可对账」:用配置开关或流量比例灰度,准备好回滚路径,把错误率、延迟、日志关键字纳入告警,评估数据变更(Schema 改动)是否可逆。⑤没有标准协同流程时,就固化三条原则:改动小、可回滚、有测试覆盖。⑥判断 AI 输出对不对的通用手法:要它给出结论依据(引用哪个文件、文档或测试结果),数字和 API 一律以本地实测为准,跨模型的第二意见用来发现盲点而不是当成结论。
6. LRU 与两个队列实现栈。LRU 要 O(1) 的 get/put,结构就是「哈希表 + 双向链表」:哈希表存 key 到链表节点,链表头部是最近使用、尾部是最久未用;get 命中后把节点移到头部,put 时 key 存在则更新并移到头部,不存在则插到头部并在超容量时删掉尾节点。Go 里可以用 container/list 或手写带两个哨兵节点的链表,哨兵能省掉大量边界判断。追问方向有:并发安全(按 key 分片加锁,或直接用带锁 LRU)、按过期时间淘汰(用最小堆或时间轮,和 LRU 组合)、以及 LRU 的固有缺陷——一次全表扫描会冲垮缓存,这时要用 LRU-K 或 LFU(Redis 4.0 的 LFU 用计数加时间衰减),而 Redis 的近似 LRU 是采样淘汰,因为精确 LRU 要维护额外指针、内存开销大。用两个队列实现栈:push 时入 q1;pop 时把 q1 里前 n−1 个元素移到 q2,剩下那个出队即栈顶,然后交换两个队列的角色——push O(1)、pop O(n)。反向题(两个栈实现队列)是「入栈 O(1)、出栈摊还 O(1)」:输入栈倒到输出栈,摊还分析要能说出来。