虾皮支付部门后端开发一面
- 轮次
- 一面
- 时间
- 2026-08
- 来源
- 牛客网
《面试题目》
- HashMap 是线程安全的吗?
- 为什么 ConcurrentHashMap 是线程安全的?请举个例子说明。
- 讲一下常见的 GC 收集器和 GC 原理。
- 讲一下跳表的数据结构,以及为什么这样设计多层链表?
- 讲一下 Redis 中的 RDB 和 AOF 机制,这两个有什么区别?Redis 的持久化机制是怎样的?
- 如果 Redis 里某个 key 占用的内存有一个 G,这个 key 怎么删除?
- 了解 Kafka 吗?怎么保证消息不丢失的?
- 讲一下 RocketMQ 是如何保证消息不丢失的,从技术层面去讲。
- 讲一下 TCP 拥塞机制。
- 算法题:k 个一组反转链表。
《参考解析》
HashMap 与 ConcurrentHashMap 的线程安全。HashMap 的线程不安全体现在几处:并发 put 触发扩容时,1.7 的头插法可能让链表成环、导致 get 死循环(1.8 改成尾插法后不再成环,但仍会丢数据);两个线程同时 put 到同一个桶会互相覆盖;size++ 本身也不是原子操作。ConcurrentHashMap 在 1.7 用的是分段锁,把整个表分成若干 Segment、每段一把锁,并发度等于段数;1.8 改成「CAS 插入空桶 + synchronized 锁单个桶头节点」,并发粒度细化到一个桶,同时用 sizeCtl 配合 CounterCell 做并发的元素计数、用 transfer 配合 ForwardingNode 支持多线程协同扩容。要举例子可以说:两个线程同时对同一个 key 做 computeIfAbsent 只会执行一次、而 HashMap 可能执行两次并覆盖;或者一个线程遍历时另一个线程 put,HashMap 会抛 ConcurrentModificationException,ConcurrentHashMap 则用弱一致性的迭代器避免了这一点。追问一般落在「复合操作仍需原子性」上——get 后 put 依然是两步,要用 putIfAbsent / compute 这类原子方法。
GC 收集器与原理。判断对象存活主流用可达性分析,从 GC Roots 出发标记可达对象;回收算法有标记-清除(碎片)、标记-复制(费空间但无碎片)、标记-整理(无碎片但移动对象成本高)。堆按分代组织:新生代用复制算法,Eden 与两块 Survivor 按 8:1:1 划分,对象先分配在 Eden、Minor GC 后存活对象在 Survivor 之间来回复制、年龄到阈值晋升老年代;大对象和长期存活对象直接进老年代。收集器按演进背:Serial(单线程、客户端场景)、ParNew / Parallel Scavenge(多线程、关注吞吐)、CMS(并发标记清除,低停顿但会产生浮动垃圾和碎片,JDK 9 起废弃)、G1(把堆切成 Region,按回收收益优先回收,可预测停顿模型,JDK 9 起的默认)、ZGC / Shenandoah(染色指针 + 读屏障,停顿进入亚毫秒级)。面试官常追问「一次 Full GC 频繁发生该查什么」:先看是不是内存泄漏(dump 后比对对象增长)、再调参数(堆与新生代比例、晋升阈值),最后才是换收集器。
跳表为什么用多层链表。跳表在有序链表之上建索引层:每一层的节点以一定概率(通常 1/2)向上晋升,最上层稀疏、最下层是全量链表。查找时从最高层出发向右走,遇到比目标大的节点就下降一层,于是每次比较都能跳过大量节点,期望时间复杂度 O(log n),和平衡树同级。但它的实现比红黑树简单得多——插入删除只需要改局部指针、不需要旋转和再平衡;范围查询顺着最底层链表顺序遍历即可,天然适合 Redis 的 ZSet 做区间操作。代价是空间开销(期望约 2n 个节点)和随机性带来的最坏情况(概率极低)。Redis 选跳表而不选平衡树,主要理由就是范围查询友好、实现简单、并发/调试成本低。
RDB 与 AOF 的区别与配合。RDB 是某一时刻的全量数据快照,二进制文件小、加载快、适合做备份与灾难恢复,代价是快照间隔内的数据会丢,而 fork 子进程做快照在大内存实例上会带来明显的写时复制和延迟抖动。AOF 追加写命令,按 appendfsync 的 always / everysec / no 三档决定持久化强度,everysec 是默认值,最多丢一秒数据;代价是文件体积大、重启需要重放。4.0 之后引入混合持久化:AOF 重写时前半段写成 RDB 格式的快照、后半段追加增量命令,重启时先加载快照再重放增量,兼顾速度与数据完整度。生产上的共识是两者都开、RDB 做冷备、AOF 保证数据完整度,并且把 Redis 当缓存而不是唯一存储。
一个大 key 怎么删。DEL 是同步操作,在删除一个占用上 G 内存的 key 时,释放内存与回收数据结构都在主线程里完成,会阻塞住整个实例、拖垮所有请求。正确做法是用 UNLINK(4.0+)做异步删除,它只把 key 从键空间摘掉、真正的内存释放在后台线程完成;如果是 Hash、List、Set、ZSet 这类大容器,还可以先按字段批量 HSCAN / SSCAN + HDEL 分批拆着删,把一次长阻塞摊成多次短阻塞。更根本的是别产生大 key:值的大小控制在合理范围、大集合做分片(按业务键散列到多个 key)、Stream 与 List 加长度上限。
Kafka 与 RocketMQ 怎么保证不丢消息。两家的思路一致,都是「三段各管一段」。生产端:Kafka 用 acks=all 加 retries 并开启幂等生产者(enable.idempotence)避免重试造成重复,RocketMQ 用同步发送并检查 SendStatus,失败重试;更可靠的做法是本地消息表或事务消息。Broker 端:Kafka 靠副本机制,min.insync.replicas 与 acks=all 配合才能保证消息至少落到两个副本,同时注意刷盘策略(异步刷盘在宕机时可能丢最后一批);RocketMQ 有同步刷盘与同步复制两个开关,主从同步复制加同步刷盘是最强组合、性能代价也最大。消费端:必须处理完业务再提交位点——Kafka 关掉自动提交、手动 commitSync,RocketMQ 用 ConsumeConcurrentlyStatus.RECONSUME_LATER 返回重试,顺序消费则要保证同一队列串行。三段都做完,「不丢」的下一步必然是「不重」,所以幂等(业务唯一键去重、状态机单向流转)是配套的必答题。
TCP 拥塞控制。核心是把发送速率适配到网络实际容量,四个阶段:慢启动——拥塞窗口 cwnd 从 1 个 MSS 开始,每收到一个 ACK 翻倍(指数增长);拥塞避免——cwnd 超过慢启动阈值 ssthresh 后改为每个 RTT 加一(线性增长);快重传与快恢复——收到三个重复 ACK 就立即重传丢失的报文并进入快恢复,把 ssthresh 减半、cwnd 设为新的 ssthresh,不退回慢启动;超时重传则视为严重拥塞,ssthresh 减半、cwnd 重置为 1。现代内核默认是 CUBIC(用三次函数替代线性增长、更适合高带宽长距离链路),BBR 则改为基于带宽和最小 RTT 建模、不以丢包为拥塞信号,在有一定丢包的链路上表现更好。面试里被追问最多的是「为什么快恢复不退回慢启动」以及「BBR 与 CUBIC 的本质区别」。
k 个一组反转链表。核心是「分段反转 + 前后拼接」:先写一个辅助函数数出剩余节点数,不足 k 个就保持原样返回;够 k 个则把这一段的 k 个节点整体反转(标准三指针翻转),再把上一段的尾接到这一段反转后的新头、这一段的原头成为新的段尾。实现上最好引入哑结点 dummy 简化头部处理,并维护 groupPrev(上一段尾)指针;每轮结束时把 groupPrev 更新为本段反转后的尾节点。复杂度是 O(n) 时间、O(1) 空间,注意边界:k 等于 1 时原样返回、链表长度恰好是 k 的整数倍、以及最后不足 k 个的尾巴必须保持原顺序而不是被反转。