Skip to content
Go back

并发编程面试回答——锁、线程池、ThreadLocal 与 JUC

并发编程面试回答

本文按「一句话结论(30s)→ 核心原理(2min)→ 底层深入(5-10min)」三层伸缩展开:先抛结论留钩子,追问哪层就展开哪层,每句结论后都带「因为」。


① 锁机制(synchronized 升级 / AQS)

一句话结论(30s)

synchronized 不是「重锁」,它按竞争程度自动升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,因为绝大多数锁根本不存在竞争,JDK 6 之后用对象头 Mark Word 的低 3 bit 编码锁状态,让无竞争场景几乎零开销;而 ReentrantLock、Semaphore 这类锁的共同底座是 AQS,它把「阻塞、排队、唤醒」这些所有同步器都要的脏活抽到一个抽象类里,子类只定义 tryAcquire/tryRelease 语义。

核心原理(2min)

先停下来想 5 秒:同样是「加锁」,JDK 为什么要把 synchronized 做成「升级」而不是一步到位用重量级锁?——因为绝大多数锁根本不存在竞争,若每个锁都直接分配 Monitor、进内核,无竞争场景也要白付昂贵的开销。升级的本质是「按需付费」:竞争越激烈,才越动用昂贵的机制。

底层深入(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。

两个细节

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 就是冲着这两个硬件/编译器行为去的。

底层深入(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 就能判断是否在运行。顺着位运算自己推一遍: runStateOfc & ~COUNT_MASK 取高 3 位、workerCountOfc & 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 也因强引用链无法释放,造成内存泄漏——唯一可靠解是 finallyremove()

核心原理(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 就得串行,白等一场。粒度越小,能并行的读写就越多,并发度才上得去。

底层深入(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 把这些全绕过,所以「不安全」,也正因为绕过才快。

三类能力

  1. CAS 原子操作Unsafe.compareAndSwapInt(this, valueOffset, expect, update) → 最终映射为一条 CPU 指令 LOCK CMPXCHG [addr], newValLOCK 前缀锁总线/缓存行保证多核原子性,单条指令完成「比较 + 交换」。想想 LOCK 前缀锁的是什么: 锁总线或缓存行,保证这条指令在多核环境下执行时是原子的、别的核插不进来。
  2. park/unparkUnsafe.park / unparkLockSupport.park/unpark 的底层封装,unpark 可在 park 之前调用(许可累积),精准唤醒指定线程。想想 unpark 为什么能先于 park 调用: 许可会累积,先发的许可存着,等 park 时直接消费,所以不会像 notify 那样丢信号。
  3. 内存操作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 + @IntrinsicCandidatejdk.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 + 局部变量,简单且无泄漏风险。复盘:并发坑的本质是「可变共享状态没加保护」,排查顺序永远是「先定位共享的可变状态 → 再选锁/无锁/隔离手段」。


Share this post on:

Previous Post
设计模式面试回答——单例、工厂、策略、责任链与代理
Next Post
JVM 面试回答——内存结构、GC、G1/ZGC 与类加载