面灵AI

OPPO 后端秋招一面凉经:ConcurrentHashMap、AQS 与虚拟线程

轮次
一面
结果
凉经
时间
2026-09
来源
牛客网

《面试题目》

  1. 能否先做一下自我介绍?
  2. HashMap 中的 equals 和 hashCode 为什么必须同时重写?
  3. ConcurrentHashMap 的 put、get 和扩容是怎样协作的?
  4. CAS 为什么存在 ABA 问题?只加版本号就一定安全吗?
  5. AQS 中线程入队后,为什么不直接让前驱节点唤醒自己?
  6. ReentrantLock 的公平锁为什么吞吐量通常低于非公平锁?
  7. Java 内存模型中,volatile 为什么不能保证复合操作的原子性?
  8. Java 虚拟线程解决了什么问题?什么情况下会发生 Pinning?
  9. 线程池中使用无界队列会带来什么隐患?
  10. MySQL 的可重复读是如何避免普通快照读出现幻读的?

《参考解析》

equals 与 hashCode 是同一份契约

HashMap 先用 hashCode() 定位桶,再用 equals() 判断桶内是否已有同一个 key。两个对象 equals 相等却 hashCode 不同,会被放进不同的桶,于是同一个逻辑 key 在 map 里出现两份,或者用新对象取不回旧值;反过来 hashCode 相等不代表相等,冲突之后仍要靠 equals 兜底。Java 的约定是:equals 相等则 hashCode 必须相等,hashCode 相等则 equals 不一定相等。还有一条容易忽略——参与这两个方法计算的字段,在对象充当 key 期间不能发生变化,否则会按新哈希值去别的桶里找,原来的映射就再也取不到。

ConcurrentHashMap 用 CAS 加桶级 synchronized

JDK 8 去掉了 Segment 分段锁,改成数组、链表加红黑树。get() 基本不加锁,靠 volatile 读和安全发布保证可见性;put() 时目标桶为空就 CAS 写入,桶不为空才对桶头节点加 synchronized,在桶内完成链表插入、红黑树插入或覆盖。扩容时旧桶逐个迁移,迁完的桶放一个 ForwardingNode,其他线程访问到它就协助扩容或转到新数组继续查;多个线程分区间一起搬,避免单线程长时间停顿。它的迭代器是弱一致的:遍历期间允许其他线程修改,不抛 ConcurrentModificationException,但也不保证看到遍历期间发生的全部修改。

ABA 的问题不是值相同,而是中间状态被抹掉了

CAS 只比较”当前值是否等于预期值”。值从 A 改成 B 又改回 A,CAS 照样成功,但调用方无法发现中间发生过变化。在无锁栈里这会让栈顶指针指向一个已经被弹出、甚至已经不属于当前栈的节点。AtomicStampedReference 把引用和版本号一起比较,能挡住大部分场景。但版本号本身也不是银弹:位数有限可能溢出绕回旧值;在有手动内存回收的语言里,节点内存被回收复用还涉及对象生命周期,需要 Hazard Pointer、Epoch Reclamation 之类的机制。Java 有 GC,不会有悬空指针,但逻辑上的 ABA 仍然要处理。

AQS 入队与唤醒之间有一个竞态窗口

线程没法”唤醒自己”,它只能检查获取条件再主动抢资源。AQS 用的是按 CLH 思想改造的双向同步队列:节点入队后先看前驱是不是头节点,是就再试一次获取——资源可能刚好已经释放;失败就把前驱状态置为 SIGNAL,表示前驱释放或取消时负责唤醒自己。之所以不能入队后立即 park(),是因为会出现丢失唤醒:当前线程判断资源不可用,持锁线程随即释放并尝试唤醒等待者,但当前线程还没真正进入等待状态,等它 park() 之后就没有线程再来叫它了。AQS 靠状态检查、CAS 修改节点状态和循环重试关掉这个窗口;LockSupport.unpark() 的许可机制也允许 unpark 先于 park 发生,后到的 park 直接消费许可、不会永久阻塞。

公平锁贵在检查队列,非公平锁赢在 CPU 热度

公平锁即使锁当前空闲,也要先检查同步队列里有没有等待更久的线程,前面有人就不许插队。非公平锁在释放后允许新到达的线程直接 CAS 抢锁,如果这个线程恰好正在 CPU 上运行,它可能不必进入阻塞态就完成临界区;队列中的线程则要经历唤醒、调度和上下文切换。所以非公平锁吞吐通常更高,代价是排队线程理论上存在饥饿风险。可重入性由 AQS 的 state 和独占线程共同实现:首次加锁把 state 从 0 改成 1,同一线程再次加锁就递增,每次解锁递减,减到 0 才真正释放。

volatile 管可见性和有序性,不管原子性

对 volatile 变量的写 happens-before 后续线程对它的读,编译器和处理器还会在相应位置建立内存屏障限制特定指令重排。但 count++ 至少是读取、加一、写回三步,两个线程可能同时读到旧值、各自加一、再写回相同结果,更新就丢了。要原子递增用 AtomicInteger;高竞争计数用 LongAdder,它把竞争分散到多个 Cell,读总值时再聚合,写吞吐更高,但 sum() 并不提供与 AtomicLong.get() 等价的强一致快照语义。

虚拟线程降的是等待成本,不是计算成本

平台线程与操作系统线程基本一一对应,创建、栈内存和上下文切换都不便宜。虚拟线程由 JVM 调度,大量虚拟线程复用少量载体线程,遇到支持挂起的阻塞操作就把虚拟线程卸载、让载体线程去干别的,既保留了同步代码的直观写法,又能拿到接近异步模型的并发度。但它不能让纯计算任务变快——开十万个虚拟线程做计算,最后还是卡在 CPU 核数上。Pinning 指虚拟线程阻塞时无法从载体线程卸载,把载体线程一起占住:典型场景是持有 synchronized 监视器时执行阻塞操作(取决于 JDK 版本),以及走本地方法等不受支持的阻塞调用。用了虚拟线程仍然要用信号量、连接池容量和限流器控制下游并发,不能靠固定线程数做背压。

无界队列会让 maximumPoolSize 形同虚设

核心线程占满后,新任务不断进入队列,队列不会拒绝,线程池就基本扩不到核心线程数以上。提交速度长期高于消费速度时,队列持续堆积对象,先是请求耗时越来越长,接着频繁 Full GC,最后可能 OOM——而出事之前往往没有任何报错。生产环境应该用有界队列,结合任务类型设定线程数、队列容量和拒绝策略。CallerRunsPolicy 会让提交任务的线程自己执行,能形成一定反压,但如果提交方是 Netty EventLoop、Dubbo I/O 线程这类关键线程,反而会把故障扩散出去,所以拒绝策略要跟着调用链选,不能机械套用。

可重复读用 MVCC 兜住快照读

事务第一次执行快照读时建立 Read View,后续快照读都按同一个视图判断记录版本是否可见,遍历过程中新插入的行自然看不到,这就是 RR 下快照读不出现幻读的原因。当前读是另一套语义:它读最新版本并按需加锁,靠 Next-Key Lock(记录锁加间隙锁)挡住扫描区间内的插入。原帖在这里被截断,完整的结论还差一句:MVCC 只负责快照读,当前读和写操作中的幻读仍然要靠锁来解决。