并发编程面试回答
本文按「一句话结论(30s)→ 核心原理(2min)→ 底层深入(5-10min)」三层伸缩展开:先抛结论留钩子,追问哪层就展开哪层,每句结论后都带「因为」。
① 锁机制(synchronized 升级 / AQS)
一句话结论(30s)
synchronized 不是「重锁」,它按竞争程度自动升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,因为绝大多数锁根本不存在竞争,JDK 6 之后用对象头 Mark Word 的低 3 bit 编码锁状态,让无竞争场景几乎零开销;而 ReentrantLock、Semaphore 这类锁的共同底座是 AQS,它把「阻塞、排队、唤醒」这些所有同步器都要的脏活抽到一个抽象类里,子类只定义 tryAcquire/tryRelease 语义。
核心原理(2min)
先停下来想 5 秒:同样是「加锁」,JDK 为什么要把 synchronized 做成「升级」而不是一步到位用重量级锁?——因为绝大多数锁根本不存在竞争,若每个锁都直接分配 Monitor、进内核,无竞争场景也要白付昂贵的开销。升级的本质是「按需付费」:竞争越激烈,才越动用昂贵的机制。
- synchronized 锁升级:Mark Word 低 3 bit 是锁标志位。偏向锁存线程 ID(54bit),轻量级锁存 Lock Record 指针(62bit),重量级锁存 Monitor 对象指针(62bit)。想想为什么三种锁状态存的字段完全不同? 因为 Mark Word 就 64 bit,不同状态复用同一批 bit 表达不同含义——这是「位复用」的思路。
- 偏向锁:一次 CAS 把线程 ID 写进 Mark Word,同一线程反复获取零开销;第二个线程 CAS 失败触发撤销(STW)升级轻量级锁。追问:为什么第二个线程 CAS 失败就要撤销? 因为偏向锁的假设「只有线程 A 在用」已被打破,继续偏向 A 只会让 B 每次空转,不如升级让双方公平竞争。
- 轻量级锁:在线程栈帧分配 Lock Record,CAS 把 Mark Word 换成指向 Lock Record 的指针,完全用户态、不进内核;CAS 失败且非重入才膨胀为重量级锁。注意这个「且」字:CAS 失败就立刻膨胀吗? 不,失败先自旋重试,只有确认「不是重入且持续竞争」才膨胀,避免一争就进内核。
- 重量级锁:分配 ObjectMonitor(_owner / _EntryList / _WaitSet),竞争线程挂到 EntryList 用 pthread_cond_wait 阻塞。锁升级不可逆——反复升降来回切换比一直保持重量级锁还贵。为什么不可逆? 撤销要 STW、膨胀要分配 Monitor,来回折腾的成本远大于「干脆停在重量级」。
- AQS:
volatile int state+ CLH 变体双向队列。state 是多义词——ReentrantLock 里 0=无锁、1=持锁、>1=重入次数;Semaphore 里是剩余许可;CountDownLatch 里是剩余 countDown 次数。AQS 负责队列管理和 park/unpark,子类只定义 tryAcquire/tryRelease。想深一层:为什么 AQS 只把 tryAcquire/tryRelease 留给子类? 因为「排队、阻塞、唤醒」这套脏活对所有同步器都一样,真正可变的是「什么叫拿到锁」,抽出来后 AQS 才能同时服务锁、信号量、闭锁三类语义。
底层深入(5-10min)
Mark Word 64 bit 全布局(HotSpot 64 位对象头):
无锁态(001): [ unused:25 | hashCode:31 | unused:1 | age:4 | biased_lock:0 | 01 ]
偏向锁(101): [ thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 ]
轻量级锁(00): [ ptr_to_lock_record:62 | 00 ]
重量级锁(10): [ ptr_to_monitor:62 | 10 ]
看这张表时自己推一遍:给你 64 bit,你要怎么同时装下「线程 ID、hashCode、指针」三样东西?答案是复用——不同锁状态把同一批 bit 让给不同字段,无锁态存 hashCode、偏向态存线程 ID、轻量/重量态存指针。想通「bit 按状态复用」,就理解了为什么 hashCode 和偏向锁会打架。
轻量级锁的重入:不靠计数器,靠 Lock Record 链表。加锁时 Mark Word 已指向本线程的 Lock Record 就视为重入,displaced_header 置 null 标记重入层数;判断「是否本线程」就是检查 Mark Word 里的指针是否落在本线程栈空间。想想:没有计数器,重入次数记在哪? 记在 Lock Record 链表的长度上,每重入一次就多压一个 Lock Record。
锁膨胀触发时机:线程 B CAS 失败 → 判断非重入 → 调 ObjectSynchronizer::inflate() → 分配 ObjectMonitor,把 A 的 Lock Record 链转换成 Monitor 的 owner/recursions,B 挂 EntryList,改 Mark Word 为重量级标志 10。
两个细节:
- 调用
hashCode()后不能用偏向锁——偏向锁 Mark Word 里 ThreadID 占 54 bit,没有位置存 31 bit 的 hashCode,一旦调用 hashCode 就必须撤销偏向退回无锁态。自己验证:翻回上面的布局表,偏向态 64 bit 里确实没有 hashCode 的位置。 - 偏向锁撤销是 STW 操作(JVM 需在安全点暂停所有线程)。所以 JDK 15 默认禁用偏向锁、JDK 18 完全移除——现代硬件 CAS 已经够快,偏向锁的 STW 撤销成本大于 CAS 省下的开销。批量重偏向阈值 20 次、批量撤销阈值 40 次(BiasedLockingBulkRebiasThreshold / BulkRevokeThreshold)。反问一句:既然偏向锁有 STW 撤销成本,当年为什么还要引入? 因为当年多核不普及、单线程高频锁场景收益大;如今硬件 CAS 变快,这笔账反过来,才被移除——技术决策从来是「时代相关的账」。
JIT 锁优化:锁消除(逃逸分析证明对象不逃逸,直接去掉 synchronized)、锁粗化(连续对同一对象加锁合并为一次)。想想:编译器凭什么敢把 synchronized 直接删掉? 逃逸分析证明对象只在当前线程内、根本没被别的线程碰过,锁保护的对象压根不会竞争,锁自然多余。
AQS 公平 vs 非公平:非公平锁 lock() 先直接 compareAndSetState(0,1) 抢锁,抢不到才排队;公平锁 tryAcquire 先查 hasQueuedPredecessors(),队列有人就乖乖排队。非公平吞吐更高的原因:锁刚释放时队头线程被 unpark 后还要等 OS 调度恢复(微秒级延迟),这窗口里新来的线程直接 CAS 抢锁成功开始执行,减少了 CPU 空转。先自己推理「为什么非公平吞吐更高」再看后文: 关键在「队头线程被唤醒到真正运行」之间有个调度空窗,非公平锁让新线程填上这个空窗。
AQS 为什么用 park/unpark 而非 wait/notify:① wait 必须持 Monitor 才能调,入队时通常没锁;② notify 在 wait 之前调用会丢信号;③ notifyAll 惊群。LockSupport.unpark(thread) 可在 park 之前调用、许可累积,下一次 park 直接消费许可不阻塞,实现精准唤醒不惊群。想想 unpark 凭什么能「先调」: 许可会累积,提前 unpark 发的许可先存着,等 park 时直接消费,所以不存在「notify 先于 wait 丢信号」的问题。
AQS 源码:acquire 入口 + CLH 队列(AbstractQueuedSynchronizer.java):
public final void acquire(int arg) {
if (!tryAcquire(arg))
acquire(null, arg, false, false, false, 0L);
}
acquire 是模板方法的入口——先尝试一次 tryAcquire(留给子类定义「什么叫拿到锁」),失败才进入排队逻辑;后面那串 acquire(null, arg, false, false, false, 0L) 会走进内部自旋循环:先判断自己是不是队首、是则再 try,否则挂到 CLH 队尾、park 阻塞、被唤醒后再重试。想深一层:为什么 acquire 用 final 修饰? 因为「先抢、失败排队、阻塞重试」这套骨架对 ReentrantLock、Semaphore 都一样,骨架不让子类改,只有 tryAcquire 是留给子类的可变点。
队列本身是 CLH 变体,源码类注释里画得很清楚:
The wait queue is a variant of a "CLH" (Craig, Landin, and
Hagersten) lock queue. CLH locks are normally used for
spinlocks. We instead use them for blocking synchronizers by
including explicit ("prev" and "next") links plus a "status"
field that allow nodes to signal successors when releasing
locks, and handle cancellation due to interrupts and timeouts.
To enqueue into a CLH lock, you atomically splice it in as new
tail. To dequeue, you set the head field, so the next eligible
waiter becomes first.
+------+ prev +-------+ +------+
| head | <---- | first | <---- | tail |
+------+ +-------+ +------+
入队只需对 tail 做一次原子 CAS「拼接到队尾」,出队只是把 head 前移,节点之间靠 prev/next + status 互相通知。对比原生 CLH: 原生 CLH 是纯自旋锁,AQS 加了 prev/next/status 三个字段把它改成「阻塞式」——status 记录「前驱释放后要不要 unpark 我」,这正是 park/unpark 精准唤醒落地的位置。
② 并发底层(volatile / CAS)
一句话结论(30s)
volatile 通过内存屏障同时解决可见性和有序性——写之后插 StoreLoad 屏障强制刷新写缓冲保证可见,插 StoreStore 屏障禁止关键重排保证有序;CAS 是 CPU 级原子指令(LOCK CMPXCHG),「比较 + 交换」一步完成、硬件保证不可中断,所以 JUC 里所有无锁结构(AtomicXxx、AQS、ConcurrentHashMap 的空桶写)都建立在它之上。
核心原理(2min)
先问自己一句:可见性和有序性,到底是「谁」看不见、「谁」在乱序?——不是你的代码,是 CPU 的 Store Buffer / Invalidate Queue 让写不广播、失效不处理,是 JIT 和 CPU 的重排让指令乱序。volatile 就是冲着这两个硬件/编译器行为去的。
- 可见性:硬件层面,CPU A 的写在 Store Buffer 里没广播、CPU B 的失效消息在 Invalidate Queue 里没处理,B 读到旧值。volatile 写之后插 StoreLoad 屏障强制 drain Store Buffer,让所有 CPU 看到最新值。顺着场景走一遍:为什么 CPU A 写了值、CPU B 还读到旧的? A 的写还停在 Store Buffer 没广播,B 的失效消息还压在 Invalidate Queue 没处理,两边一脱节,B 自然看到旧值。
- 有序性:DCL 里
instance = new Singleton()字节码是三步——分配内存、调构造器、赋引用,JIT 可能重排成「分配 → 赋引用 → 构造」,另一线程看到非 null 却拿到「半成品对象」。volatile 的 StoreStore 屏障禁止「赋引用」跑到「构造」之前。模拟一下重排的坑: 线程 A 赋了引用但构造器没跑完,线程 B 恰好在外层 if 看到 instance != null,于是拿到一个字段全是默认值的「半成品」——这就是 DCL 必须加 volatile 的原因。 - CAS:
compareAndSet(old, new)底层是Unsafe.compareAndSwapInt,最终映射为一条LOCK CMPXCHG指令,原子完成「比较 + 交换」,失败就自旋重试。想想「比较 + 交换」凭什么能原子? 因为它被编译成一条 CPU 指令,一条指令要么全做要么不做,硬件保证执行期间不可中断。
底层深入(5-10min)
四大内存屏障:
| 屏障 | 效果 |
|---|---|
| LoadLoad | 确保 Load1 在 Load2 之前 |
| StoreStore | 确保 Store1 在 Store2 之前 |
| LoadStore | 确保 Load1 在 Store2 之前 |
| StoreLoad | 确保 Store1 在 Load2 之前且对 Load2 可见 |
StoreLoad 是最贵屏障——同时 drain StoreBuffer + 处理 Invalidate Queue,相当于 CPU 与缓存的一次全量同步,x86 上编译为 MFENCE(或 LOCK ADDL)。volatile 写比普通写慢几十倍的原因就在这里,所以 volatile 只适合「一写多读的标志位」场景,不适合频繁写。为什么它最贵? 四种屏障里只有它要同时「刷出写缓冲 + 处理失效队列」,等于 CPU 和缓存之间做一次全量对账,自然最慢。
volatile 写/读的屏障插入:volatile 写前后各插 StoreStore / StoreLoad;volatile 读后插 LoadLoad / LoadStore。
CAS 的 ABA 问题:值从 X 变 Z 又变回 X,CAS 只比较值所以误判「没变」。对数值运算通常无害(余额 100→90→100 结果仍正确),但对链表/栈这类引用结构致命——指针指向的节点可能已被回收重新分配。AtomicStampedReference 用「引用 + 版本号」双重 CAS 解决:两者都相等才算成功。先自己想:值从 X→Z→X,CAS 判断「没变」到底错在哪? 它只比了「当前值相等」,没比「中间是否被动过」;对引用结构,节点可能已经被回收又重新分配,值看着一样,指向的东西早换了。加版本号,就是把「值」扩展成「值 + 变更次数」。
LongAdder 对 CAS 的优化(高并发计数):AtomicLong 是 100 个线程争 1 个 value,CAS 失败率 (N-1)/N,大量自旋空转;LongAdder 内部用 Cell[] 数组分槽 + base,竞争激烈时线程哈希到不同 Cell 各 CAS 自己的 value,失败率大幅下降。@Contended 在 Cell 前后填充 128 字节防伪共享(否则相邻 Cell 落在同一 Cache Line,写一个会通过 MESI 使另一个失效)。代价是 sum() 非原子——用「最终一致」换「写入高性能」,所以 LongAdder 适合统计计数、不适合需要精确快照的场景。先算一笔账:100 个线程争 1 个 value,失败率多少? (N-1)/N,几乎人人失败、疯狂自旋。那怎么破? 让每个线程 CAS 自己的槽——这就是 LongAdder 分槽的思路,用「分而治之」把争用摊薄。
LongAdder 源码:add 的分槽逻辑(LongAdder.java):
public void add(long x) {
Cell[] cs; long b, v; int m; Cell c;
if ((cs = cells) != null || !casBase(b = base, b + x)) {
int index = getProbe();
boolean uncontended = true;
if (cs == null || (m = cs.length - 1) < 0 ||
(c = cs[index & m]) == null ||
!(uncontended = c.cas(v = c.value, v + x)))
longAccumulate(x, null, uncontended, index);
}
}
第一行先 CAS base(无竞争时的快路径),失败说明有人竞争,才进分槽逻辑;getProbe() 拿到线程探针值,index & m 把它哈希到某个 Cell 上再对该 Cell 单独 CAS。拆开看这个 if 链: cs == null(槽还没建)→ 去 longAccumulate 建槽;cs[index & m] 这个格子是 null(还没占)→ 也去占格;格子有但 CAS 失败(和人撞了)→ 重哈希换格子。三层判断把「建槽、占格、撞格换槽」三种情况全兜住。
槽本身是 Striped64.Cell,带 @Contended 防伪共享:
@jdk.internal.vm.annotation.Contended static final class Cell {
volatile long value;
Cell(long x) { value = x; }
final boolean cas(long cmp, long val) {
return VALUE.weakCompareAndSetRelease(this, cmp, val);
}
final void reset() {
VALUE.setVolatile(this, 0L);
}
final void reset(long identity) {
VALUE.setVolatile(this, identity);
}
final long getAndSet(long val) {
return (long)VALUE.getAndSet(this, val);
}
// VarHandle mechanics
private static final VarHandle VALUE = MhUtil.findVarHandle(
MethodHandles.lookup(), "value", long.class);
}
@Contended 让 JVM 在每个 Cell 前后塞满缓存行填充,相邻 Cell 不落同一 Cache Line,一个核写自己的 Cell 不会通过 MESI 失效别人的。代价在哪? 空间换时间——每个 Cell 多占一段填充,所以 Cell 数组长度上限是 CPU 核数(NCPU),槽不会无限膨胀;而 sum() 只是遍历所有 Cell 相加、不保证原子快照,这正是「最终一致」的来源。
③ 线程池(7 参数 / 拒绝策略)
一句话结论(30s)
线程池用「核心线程 → 队列 → 非核心线程 → 拒绝」的四级扩容策略复用线程,因为创建线程要分配栈内存 + 内核资源、销毁要回收,复用能大幅降低创建开销和上下文切换成本;它靠 7 个参数精确控制线程数与队列行为,队满且线程满时走拒绝策略兜底。
核心原理(2min)
先想:线程池「复用」的到底是什么?——不是任务,是线程对象,以及它背后已分配好的栈内存和内核资源。创建线程要分配栈、销毁要回收,这份开销才是它想省的。
7 个参数:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime(非核心线程空闲存活时间)、unit(时间单位)、workQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。
一个任务提交后经过的步骤(execute 流程):
1. 当前线程数 < corePoolSize → 创建核心线程执行
2. 核心线程已满、队列未满 → 任务入队
3. 队列已满、线程数 < maxPoolSize → 创建非核心线程执行
4. 队列满且线程也满 → 走拒绝策略
关键反直觉点:非核心线程只在队列满时才创建,不是「线程满到核心数就直接建非核心」。拿一个具体数字自测一下: 核心 2、队列 10、最大 4,一次性提交 20 个任务,会建几个非核心线程?——只有 2 个(第 3 步的「队列已满」是前提),最多 4 个线程在跑,剩下 16 个要么在队列里排队、要么被拒绝。想不通就回看第 3 步的触发条件。
4 种拒绝策略:
| 策略 | 行为 |
|---|---|
| AbortPolicy(默认) | 抛 RejectedExecutionException |
| CallerRunsPolicy | 调用者线程自己执行任务(天然背压) |
| DiscardPolicy | 静默丢弃 |
| DiscardOldestPolicy | 丢弃队列最旧任务,重试提交 |
先凭直觉选一个:哪种策略能防 OOM? CallerRunsPolicy——提交线程自己执行任务,提交速度自然被拖慢,任务不会无限积压到内存爆掉。这正是「背压」的含义。
底层深入(5-10min)
ctl 的位运算:一个 AtomicInteger ctl 同时存两个量——高 3 bit 存线程池状态(RUNNING / SHUTDOWN / STOP / TIDYING / TERMINATED),低 29 bit 存 worker 数量,一次 CAS 原子更新。想想为什么要把「状态 + 数量」塞进同一个 int? 为了能一次 CAS 原子地同时改两个值,避免分开两个字段改时出现「状态已变、数量没跟上」的中间态——这正是并发编程里「用位打包换取原子性」的经典手法。
ctl 源码(ThreadPoolExecutor.java):
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;
// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;
// Packing and unpacking ctl
private static int runStateOf(int c) { return c & ~COUNT_MASK; }
private static int workerCountOf(int c) { return c & COUNT_MASK; }
private static int ctlOf(int rs, int wc) { return rs | wc; }
COUNT_BITS = Integer.SIZE - 3 = 29,所以低 29 bit 归 worker 数量、高 3 bit 归状态;RUNNING = -1 << 29 是负数(高 3 bit 全 1),所以「运行中」是五个状态里唯一小于 0 的,一行 c < SHUTDOWN 就能判断是否在运行。顺着位运算自己推一遍: runStateOf 用 c & ~COUNT_MASK 取高 3 位、workerCountOf 用 c & COUNT_MASK 取低 29 位、ctlOf 把两者 | 拼回去——三个函数就是「打包 / 拆包」的完整实现,一次 CAS 改 ctl 等于原子地同时改了状态和数量。
execute 源码:三级判断 + 入队后二次检查(ThreadPoolExecutor.java):
public void execute(Runnable command) {
Objects.requireNonNull(command, "command");
/*
* Proceed in 3 steps:
*
* 1. If fewer than corePoolSize threads are running, try to
* start a new thread with the given command as its first
* task. The call to addWorker atomically checks runState and
* workerCount, and so prevents false alarms that would add
* threads when it shouldn't, by returning false.
*
* 2. If a task can be successfully queued, then we still need
* to double-check whether we should have added a thread
* (because existing ones died since last checking) or that
* the pool shut down since entry into this method. So we
* recheck state and if necessary roll back the enqueuing if
* stopped, or start a new thread if there are none.
*
* 3. If we cannot queue task, then we try to add a new
* thread. If it fails, we know we are shut down or saturated
* and so reject the task.
*/
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}
三段正好对应四级扩容的前三步:核心未满建核心线程、能入队就入队、入不了队建非核心线程,全不行才 reject。注意 recheck 的两个分支顺序: 先查「线程池是不是刚被 shutdown」——是则把刚入队的任务 remove 出来拒绝,否则查「worker 数是不是 0」——是则补一个 addWorker(null, false) 去消费队列。因为入队和「worker 超时退出 / shutdown」之间存在竞态,不二次检查,任务会「既不被执行、也不被拒绝」,永远悬空。先问自己:任务入队成功,就万事大吉了吗? 不——从「入队」到「被 worker 取走」之间,最后一个 worker 可能正好超时退出(没人消费),线程池也可能刚好被 shutdown(任务悬空),二次检查就是补这两道竞态。
CallerRunsPolicy 的背压效果:提交线程自己执行任务 → 提交速度自然降低 → 任务不会无限积压导致 OOM。适合「允许短暂降速、但不能丢任务」的场景。
线程数设计公式:CPU 密集型 ≈ CPU 核数 + 1;IO 密集型 ≈ CPU 核数 × (1 + IO 等待时间 / CPU 计算时间)。注意这是平台线程的公式,Java 21 虚拟线程下 IO 密集型直接用 newVirtualThreadPerTaskExecutor() 不用算。想想为什么 IO 密集型要乘 (1 + IO/CPU): IO 时 CPU 是闲着的,线程越多 CPU 越有活干,所以按「IO 占比」放大线程数;CPU 密集型线程再多元济于事,核数 + 1 就好。
④ ThreadLocal(原理 / 泄漏)
一句话结论(30s)
ThreadLocal 本身不存数据,它只是一个 key,数据存在每个线程自己的 Thread.threadLocals(ThreadLocalMap)里,Entry 的 key 是弱引用、value 是强引用;所以线程池线程长期存活时,即使 key 被 GC 回收了,value 也因强引用链无法释放,造成内存泄漏——唯一可靠解是 finally 里 remove()。
核心原理(2min)
先纠正一个直觉:ThreadLocal 到底存不存数据?——不存,它只是 key,数据挂在每个线程自己的 Thread 对象上。想通「数据在线程侧、不在 ThreadLocal 侧」,后面的泄漏问题就迎刃而解。
结构链:
Thread 对象 (GC Root)
→ threadLocals (ThreadLocalMap)
→ Entry[] table(开放地址法)
→ Entry.key = WeakReference<ThreadLocal> (弱引用)
→ Entry.value = 业务数据 (强引用)
泄漏链路:方法里 ThreadLocal<User> tl = new ThreadLocal<>(); tl.set(user); 方法结束后 tl 局部变量出栈,ThreadLocal 对象的强引用消失 → key 被 GC 回收变 null → 但 Thread → threadLocals → Entry[] → value 这条强引用链还在,value 永远无法回收。只要线程活着(线程池线程长期存活),value 就一直悬着。 顺着引用链自己走一遍:局部变量 tl 出栈后,谁还拽着 value 不放? Thread(GC Root)→ threadLocals → Entry[] → value。key 虽然被 GC 了,value 这条强引用链断不了,所以漏。
底层深入(5-10min)
弱引用 key 为什么没解决泄漏:Entry extends WeakReference<ThreadLocal<?>> 的 key 弱引用的设计意图是「让 ThreadLocal 对象本身可以被 GC(不被 Map 强持有)」,而不是「防止 value 泄漏」。value 是强引用,和 key 的弱引用无关——线程不结束、Map 不销毁,value 就永远悬在那里。想想:key 已经用了弱引用,为什么 value 还漏? 因为弱引用只保护「key 这个 ThreadLocal 对象」不被 Map 强持有,value 是强引用、跟 key 的弱引用毫无关系,该漏还是漏。
Entry 源码:key 弱引用、value 强引用(ThreadLocal.java):
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
Entry 继承 WeakReference<ThreadLocal<?>>,super(k) 把 key 交给弱引用管理,value = v 却是一个普通强引用字段。关键就在这两句的不对称: key 走 WeakReference 的 referent,GC 不受 Map 引用影响也能回收;value 走普通字段,只要 Thread → threadLocals → Entry[] 这条强引用链在,它就永远可达。所以弱引用只解决了「ThreadLocal 对象本身别被 Map 强拽住」,value 的泄漏它管不着。
expungeStaleEntry 惰性清理:ThreadLocalMap 的 get/set/remove 会顺带清理沿途遇到的 key=null 的 stale entry,但只在「路过」时顺手清——某个 stale entry 所在的槽位一直没被访问到,就一直不清理。所以惰性清理不可靠。 反问:既然有清理机制,为什么还说不可靠? 清理是「路过才清」,从没被访问到的槽位永远轮不到,等于没清。
为什么清理后要 rehash:ThreadLocalMap 用线性探测解决冲突。Entry A 理想槽 5 被 B 占据,A 放到 6;B 被清理后 slot 5 变 null,下次 get(A) 从理想槽 5 线性扫描遇到 null 就误判「A 不存在」。expungeStaleEntry 清理 stale entry 的同时对后续有效 Entry 做 rehash,填回 null 槽,维护探测链正确性。想想清理后为什么还要 rehash: 线性探测靠「null 槽」当搜索终点,槽被清成 null 后,后面被挤过去的有效 entry 会被误判「不存在」,所以要把它们挪回来填上空槽。
唯一金标准:
ThreadLocal<User> tl = new ThreadLocal<>();
try {
tl.set(currentUser);
// 业务逻辑
} finally {
tl.remove(); // value=null + table[idx]=null,整条强引用链断掉
}
Spring 事务管理的 TransactionSynchronizationManager 也是同样模式。虚拟线程 + ThreadLocal 更要谨慎——虚拟线程数量远超平台线程,每个都是泄漏点。
⑤ JUC 工具类(ConcurrentHashMap 等)
一句话结论(30s)
ConcurrentHashMap 在 JDK 8 用「桶头节点 synchronized + 空桶 CAS」取代了 JDK 7 固定 16 个 Segment 的分段锁,因为锁粒度从固定分段细化到单个桶后,并发度从 16 提升到桶数量(无上限);读操作全程无锁(volatile 读),扩容还支持多线程协助。
核心原理(2min)
先想:锁粒度大,到底「差」在哪?——两个写不同桶的线程,只要落进同一个 Segment 就得串行,白等一场。粒度越小,能并行的读写就越多,并发度才上得去。
- JDK 7:
Segment[] segments默认 16 个,每个 Segment 是 ReentrantLock 子类、内部一个小 HashMap。put 先定位 Segment 再对 Segment 加锁,并发度固定 = 16——两个线程写不同桶但落在同一 Segment 就串行。 - JDK 8:完全重构,直接用普通
Node[] table。put 时空桶用 CAS 直接放桶头(无锁),桶头已有数据才synchronized(f)锁单个桶头节点(极细粒度),并发度 = 桶数量。想想为什么「空桶用 CAS、非空桶才 synchronized」? 空桶没有竞争对象可锁,CAS 一步把桶头放上去就行;已有数据的桶要保护「插入/覆盖」这个复合操作,才需要锁桶头节点。
底层深入(5-10min)
多线程协助扩容:扩容不是单线程迁徙。transferIndex(volatile int)从 table.length 递减,每个线程 CAS 认领一段区间 stride(最小 16)去迁移。已迁移的桶放 ForwardingNode(hash == MOVED,-1)。先问:为什么扩容要「多线程协助」而不是单线程一口气搬完? 大表单线程搬太慢,且搬迁期间读写在旧表上会持续受影响;用 transferIndex + CAS 让每个线程认领一段分头搬,谁路过谁帮忙,扩容更快完成。
ForwardingNode 让扩容对读透明:
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable; // 指向新表
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null); // hash = MOVED
nextTable = tab;
}
}
任何操作看到桶头 hash == MOVED 就知道这个桶正在迁移,get 直接去 nextTable 查(读不阻塞);put 则调 helpTransfer() 主动认领一段帮扩容,再在新表执行自己的写。想想 put 看到 hash == MOVED 该怎么办: 不能傻等扩容结束,而是主动 helpTransfer 认领一段一起搬,再把自己的写落到新表——「协助」这个词就是这么来的。
put 源码:空桶 CAS、非空桶才锁桶头(ConcurrentHashMap.java):
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // check first node without acquiring lock
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
定位到桶 i 后有几条路:桶空 → casTabAt 一把把新节点 CAS 进桶头,注释明写「空桶不加锁」;桶头是 MOVED → 正在扩容,helpTransfer 帮完再回循环;onlyIfAbsent 且桶头 key 命中 → 直接返回旧值(连锁都不加);否则才 synchronized (f) 锁住桶头节点。想想为什么锁块第一句还要 tabAt(tab, i) == f 再校验一次: 等锁的期间桶头可能被别的线程换掉(扩容或删除),拿到锁后必须确认 f 还是桶头,否则就锁了个已经不在链表上的节点、白锁。
JDK 7 vs JDK 8:
| JDK 7 (Segment) | JDK 8 (桶级锁) | |
|---|---|---|
| 锁粒度 | Segment(默认 16) | 单个桶头节点 |
| 最大并发度 | 16(固定) | table.length(无上限) |
| 扩容 | 单线程 | 多线程协助 |
| 读操作 | 无锁 volatile 读 | 无锁 volatile 读 + ForwardingNode 重定向 |
| size | 三次不加锁统计 + 一次加锁兜底 | CounterCell 数组分散计数 |
size 用 CounterCell 分散计数(和 LongAdder 同思路),避免所有线程争一个计数字段。想想为什么统计 size 也要「分槽」: 和 LongAdder 一个道理——所有线程都去 CAS 同一个 count 字段,争用太大;每个线程写自己的 Cell,统计时再加总,用「最终一致」换写入性能。
⑥ Unsafe 类
一句话结论(30s)
Unsafe 之所以叫 Unsafe,是因为它绕过 Java 所有安全检查直接操作内存,用错会直接导致 JVM 崩溃;它提供三类底层能力——CAS 原子操作、线程 park/unpark、堆外内存访问,JUC 里所有无锁结构(AtomicXxx、AQS、ConcurrentHashMap 的 casTabAt)都建立在它之上。
核心原理(2min)
先想:Java 号称「安全」,靠的是什么?——靠各种安全检查:数组越界、类型、可见性、final 约束。Unsafe 把这些全绕过,所以「不安全」,也正因为绕过才快。
三类能力:
- CAS 原子操作:
Unsafe.compareAndSwapInt(this, valueOffset, expect, update)→ 最终映射为一条 CPU 指令LOCK CMPXCHG [addr], newVal,LOCK前缀锁总线/缓存行保证多核原子性,单条指令完成「比较 + 交换」。想想 LOCK 前缀锁的是什么: 锁总线或缓存行,保证这条指令在多核环境下执行时是原子的、别的核插不进来。 - park/unpark:
Unsafe.park / unpark是LockSupport.park/unpark的底层封装,unpark 可在 park 之前调用(许可累积),精准唤醒指定线程。想想 unpark 为什么能先于 park 调用: 许可会累积,先发的许可存着,等 park 时直接消费,所以不会像 notify 那样丢信号。 - 内存操作:
allocateMemory分配堆外内存、objectFieldOffset获取字段偏移、putObjectVolatile按「对象指针 + 偏移」直接写(比反射快得多,无安全检查)。
底层深入(5-10min)
为什么危险:objectFieldOffset + putObject 组合能绕过 private/final 限制修改任意对象字段;allocateMemory 分配的堆外内存不受 GC 管理,漏 freeMemory 就泄漏;CAS 用错偏移量直接写坏内存。所以 JDK 把它的访问限制在 sun.misc / jdk.internal 模块内,反射调用都要关权限。试着自己推理「objectFieldOffset + putObject 能干什么坏事」: 拿到字段偏移后按「对象指针 + 偏移」直接写,private、final 全拦不住,Java 的封装形同虚设——这就是它危险的根源。
Unsafe 源码:native + @IntrinsicCandidate(jdk.internal.misc.Unsafe.java):
@IntrinsicCandidate
public final native boolean compareAndSetInt(Object o, long offset,
int expected,
int x);
@IntrinsicCandidate
public native void unpark(Object thread);
@IntrinsicCandidate
public native void park(boolean isAbsolute, long time);
这三个方法全是 native,真正的实现不在 Java 里、在 HotSpot 的 C++/汇编里;@IntrinsicCandidate 告诉 JIT:别老老实实走 native 调用,遇到就直接内联成对应的机器指令。sun.misc.Unsafe.compareAndSwapInt 内部就是委托到这里的 compareAndSetInt。想想 compareAndSetInt 最终变成什么: 在 x86 上就是一条带 LOCK 前缀的 CMPXCHG——这正是 CAS 为什么是「CPU 级原子指令」的源码证据,native 声明 + 内联 intrinsic 两步把它接到了硬件上。park/unpark 同理,直接接到操作系统的线程阻塞原语,所以才能实现「可提前调用、许可累积」的精准唤醒。
VarHandle(JDK 9+):Unsafe 的类型安全替代:
MethodHandles.Lookup lookup = MethodHandles.lookup();
VarHandle handle = lookup.findVarHandle(MyClass.class, "value", int.class);
handle.compareAndSet(obj, expect, update); // CAS
handle.getVolatile(obj); // volatile 读
handle.setRelease(obj, newVal); // release 语义写
VarHandle 提供 Plain / Opaque / Release-Acquire / Volatile 四种访问语义,比 Unsafe 单一口径更精细,且类型安全。业务代码不用直接碰 Unsafe,只用于框架/JUC 源码级开发。想想 Unsafe 的单一口径有什么问题: 一个 compareAndSet 说不清是 plain 还是 volatile 语义,写的人容易搞混;VarHandle 把四种内存语义显式分开,类型安全、语义精细,所以成了官方替代。
追问清单(10 个追问点)
1. synchronized 的锁升级过程(偏向锁 → 轻量级锁 → 重量级锁)
一句话结论:锁按竞争程度从无锁 → 偏向锁 → 轻量级锁 → 重量级锁自动升级、且不可逆,因为 JVM 用 Mark Word 低 3 bit 编码锁状态、让无竞争场景零开销。
展开:偏向锁用一次 CAS 把线程 ID 写进 Mark Word,单线程反复获取零开销;第二个线程 CAS 失败触发撤销(STW 操作)升级轻量级锁,靠栈帧 Lock Record + CAS 自旋、不进内核;真正竞争(CAS 失败且非重入)才膨胀为重量级锁,分配 ObjectMonitor 挂起线程。补充:JDK 15 起默认禁用偏向锁、18 完全移除,因为现代硬件 CAS 够快,偏向锁 STW 撤销成本大于收益。
2. AQS 的原理?CountDownLatch 和 CyclicBarrier 的区别?
一句话结论:AQS 是 JUC 所有同步器的共同底座,用一个 volatile int state + CLH 变体队列统一处理阻塞、排队、唤醒,子类只定义 tryAcquire/tryRelease 语义。
展开:state 在多子类含义不同——ReentrantLock 是持锁/重入次数、Semaphore 是许可数、CountDownLatch 是剩余计数。它用 park/unpark 而非 wait/notify,因为不需要持锁、unpark 可提前调用不丢信号、精准唤醒不惊群。区别:CountDownLatch 是「计数减到 0 一次性释放、不可复用、可多个线程等一个」;CyclicBarrier 是「所有线程互相等待到齐后一起出发、可循环复用」。
3. volatile 的可见性和有序性怎么保证?内存屏障是什么?
一句话结论:volatile 靠内存屏障保证——写后插 StoreLoad 屏障强制刷新写缓冲保证可见,插 StoreStore 屏障禁止重排保证有序。
展开:可见性问题来自 CPU 的 Store Buffer 和 Invalidate Queue,写之后插 StoreLoad 屏障 drain 写缓冲让其他核可见;有序性典型是 DCL 的 instance = new Singleton() 被重排成「分配 → 赋引用 → 初始化」,另一线程拿到半成品,volatile 的 StoreStore 禁止「赋引用」跑到「初始化」前。StoreLoad 是最贵屏障,x86 上编译为 MFENCE / LOCK ADDL,这就是 volatile 写慢几十倍、只适合一写多读标志位的原因。
4. CAS 的 ABA 问题怎么解决?AtomicStampedReference 原理?
一句话结论:ABA 指值从 A 变 B 又变回 A,CAS 只比较值所以误判「没变」,AtomicStampedReference 用「引用 + 版本号」双重 CAS 解决。
展开:对数值运算 ABA 通常无害(余额 100→90→100 结果仍对),但对链表/栈引用结构致命——节点可能已被回收复用。AtomicStampedReference 内部维护 (引用, 版本号) 对,CAS 时两者都相等才算成功;另有 AtomicMarkableReference 只维护一个布尔标记。
5. 线程池的 7 个参数?任务提交后经过哪些步骤?
一句话结论:7 参数是 corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler,任务提交按「核心线程 → 入队 → 非核心线程 → 拒绝」四级走。
展开:核心线程未满创建核心线程;满了先入队(队未满);队满了才创建非核心线程;非核心也满才走拒绝策略。关键反直觉点:非核心线程只在队列满时才创建。底层用 ctl 高 3 位存状态、低 29 位存 worker 数,入队后还要二次检查(防无线程消费 + shutdown 悬空)。
6. 线程池拒绝策略有哪些?各自适用什么场景?
一句话结论:四种是 AbortPolicy(抛异常,默认)、CallerRunsPolicy(调用者执行)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢最旧重试),其中 CallerRunsPolicy 在业务里最常用。
展开:因为 CallerRunsPolicy 有天然背压效果——提交线程自己执行任务会降低提交速度,避免任务无限积压导致 OOM,最适合「允许短暂降速但不能丢任务」的业务。AbortPolicy 会让调用方立刻收到异常,适合任务必须成功、失败要立刻告警的场景。
7. ThreadLocal 的原理?为什么内存泄漏?怎么解决?
一句话结论:ThreadLocal 只是 key,数据存在每个线程自己的 ThreadLocalMap 里,Entry 的 key 是弱引用、value 是强引用,线程池线程长期存活时 value 无法回收造成泄漏,唯一可靠解是 finally remove()。
展开:key 弱引用被 GC 后变 null,但 Thread → threadLocals → Entry[] → value 强引用链还在。get/set/remove 会惰性清理沿途 stale entry,但没被访问到的槽位永远不清理、不可靠。必须 try-finally 里 remove(),把 value 置 null、断掉整条强引用链。
8. ConcurrentHashMap 1.7 和 1.8 的并发安全实现有什么区别?
一句话结论:1.7 用固定 16 个 Segment 分段锁(每个是 ReentrantLock),1.8 改成桶头节点 synchronized + 空桶 CAS,锁粒度从固定 16 细化到单桶,并发度无上限。
展开:1.8 空桶用 CAS 直接放桶头(无锁),桶头已有数据才 synchronized 锁单桶;扩容改成多线程协助(transferIndex CAS 认领区间),读操作遇到 ForwardingNode(hash=MOVED)自动跳新表、全程无锁。size 统计也从「三次不加锁 + 一次加锁兜底」改成 CounterCell 分散计数。
9. Unsafe 为什么叫 Unsafe?哪些场景会用到?
一句话结论:叫 Unsafe 因为它绕过 Java 所有安全检查直接操作内存,用错会直接导致 JVM 崩溃;它提供 CAS、park/unpark、堆外内存三类能力。
展开:Unsafe 主要在框架/JUC 源码层面使用,三类典型场景:AtomicInteger 底层 compareAndSwapInt、LockSupport 的 park/unpark、以及直接内存 allocateMemory。JDK 9 起官方推荐 VarHandle 作为类型安全的替代。业务代码里一般不直接用 Unsafe——绕过安全检查、漏 freeMemory 就泄漏、CAS 用错偏移直接写坏内存。
10. 并发编程有哪些典型坑?怎么排查解决?
一句话结论:一个典型坑是线程池里共享 SimpleDateFormat 并发格式化导致日期错乱/异常,根因是它内部 Calendar 非线程安全。
展开:定位到是共享的 static SimpleDateFormat 被多线程同时 parse/format。解决三选一:换 ThreadLocal 隔离、或换 JDK 8 的 DateTimeFormatter(不可变、线程安全)、或每次 new;最终用 DateTimeFormatter + 局部变量,简单且无泄漏风险。复盘:并发坑的本质是「可变共享状态没加保护」,排查顺序永远是「先定位共享的可变状态 → 再选锁/无锁/隔离手段」。