并发的题都出在「以为锁住了」
并发不难在概念,难在看上去对了其实没锁住:锁错了对象、volatile 当原子用、线程池队列无界、ThreadLocal 忘 remove。每一节都给一段能真跑出问题的代码,输出就是证据。
01先跑出问题再讲原理每个结论都配一段会出错的代码,丢了多少条、卡在哪里,输出里看得见。
02盯住「锁的对象」synchronized 锁谁、volatile 不锁什么、AQS 的 state 是什么,答得清这三点就赢一半。
03和前端经验对上单线程没有锁,但有并发控制:乐观更新、版本号冲突、Worker 里的 Atomics。
1
start 和 run 差一行,差一整个线程★
run 就是普通方法,start 才向系统要线程
很多人第一次写线程就踩这个坑:直接调
t.run(),代码照样跑,但跑在 main 线程里,一点并发都没有。start() 做的是另一件事——告诉 JVM「给我开一个新线程」,新线程起来后由它去回调 run()。线程对象只是一个句柄,只能启动一次。输出调用 t.run() —— 就是一次普通方法调用
里面跑的线程: main
外面当前线程: main
调用 t.start() —— 才真的开新线程
里面跑的线程: worker-2
外面当前线程: main
同一个线程对象再 start 一次?
抛 IllegalThreadStateException:线程只能启动一次
- 差在哪一步:
start()内部调start0()这个 native 方法,向操作系统申请一个内核线程、分配线程私有的栈,然后把线程状态从 NEW 改成 RUNNABLE 交给调度器。这些准备工作run()一概不做。 - 为什么只能启动一次:线程的栈和生命周期绑在 OS 线程上,第二次启动时同一块资源已经用过了。所以有
threadStatus != 0就抛IllegalThreadStateException。 - 三种创建方式只是皮:继承 Thread、实现 Runnable、实现 Callable 配 FutureTask,最后都是
new Thread(...).start()。面试问「几种创建方式」,标准答法是「两种核心:继承还是组合,Callable 只是让 run 能有返回值」。 - Callable 和 Runnable 的真实差别:Callable 能返回结果、能抛受检异常,但要包一层
FutureTask才能交给 Thread,因为 Thread 的构造器只认 Runnable。 - 业务代码里不要自己 new Thread:每次都新建、没有复用、没有上限、出问题没法统一监控。生产上统一交给线程池。
面试怎么答start 才是真的启动线程,run 只是普通方法调用。start 里由 JVM 去申请内核线程、分配栈,再把状态改成 RUNNABLE 交给调度器,这些 run 都不做,所以直接调 run 会跑在当前线程里,写成并发其实是串行。另外线程只能启动一次,第二次会抛 IllegalThreadStateException。实际项目里我不用裸 Thread,统一走线程池,复用和监控都好做。
2
synchronized 锁的到底是哪个对象★
锁永远挂在对象上,写法决定挂哪个
synchronized 只有三种写法,锁的都是对象:修饰实例方法锁
this,修饰静态方法锁 类名.class,同步代码块锁括号里那个对象。所以「两个线程调同一个对象的同步方法会互斥、调两个不同对象的同步方法不会互斥」——因为根本是两把锁。输出同一个对象,锁 this -> shared = 2 (期望 2)
两个对象,还是锁 this -> shared = 1 (期望 2,丢了更新)
静态方法,锁 Class -> shared = 2 (期望 2)
- 第二行是整个例子的重点:两个线程各自
synchronized了自己的实例方法,看上去「都加了锁」,其实各锁各的,读改写完全重叠,最后只加了一次。这就是「以为加了锁其实没锁住」的典型。 - 锁信息存在对象头的 Mark Word 里:一个对象只有一把内置锁。JDK 6 之后这把锁会升级:无锁 → 偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(挂到 monitor 上排队)。升级不可逆,就是为了在「没竞争」时别付出系统调用代价。
- 可重入:同一线程重复进同步块不会自己把自己锁死,Mark Word 里记着偏向线程和重入次数。所以同步方法里调另一个同步方法是安全的。
- 别锁 String 字面量和 Integer 这类缓存对象:
"abc"在常量池里全 JVM 只有一份,Integer.valueOf(1)走缓存池,别处一段不相干的代码锁同一个对象,就可能互相卡住。 - 锁粒度要跟临界区对齐:只包住读改写,别把
RPC调用、文件 IO 一起包进来,那是把并发拖成串行的常见原因。 - 和前端对照:JS 是单线程,没有这把锁的概念;但浏览器里 Worker 之间共享
SharedArrayBuffer时会碰到Atomics,思路是一样的——保证读改写是一个不可分割的操作。
面试怎么答synchronized 锁的都是对象,只是写法决定锁哪个:实例方法锁 this,静态方法锁 Class 对象,同步块锁括号里的对象。所以两个线程操作不同实例时,各自的同步方法并不互斥,这是最常见的「加了锁却没锁住」。底层靠对象头的 Mark Word 记录锁状态,JDK 6 之后有无锁到偏向、轻量、重量级的升级,升级不可逆,目的是没竞争时别付系统调用代价。还有一点要避开:不要锁 String 字面量和 Integer 缓存对象,它们是全 JVM 共享的。
3
volatile 保证可见性,但不保证原子性★
一个 i++ 就把它拆穿了
volatile 干两件事:写完之后立刻刷到主内存、读之前必须从主内存拉最新的;顺便禁止指令重排。它不保证原子性——i++ 编译出来是「读、加一、写回」三条指令,volatile 只保证每一步看到的是最新值,不保证这中间没人插队。输出(每次跑数字不同,但一定明显小于 400000)期望值 : 400000
volatile int++ : 177869 <- 丢了更新
AtomicInteger : 400000 <- 一次不少
少了多少个 : 222131
- 丢在哪一步:线程 A 读到 100,还没写回;线程 B 也读到 100;两个都写 101。两次自增只剩一次效果。
volatile保证「读到的一定是最新的」,但这个最新值在 A 手里已经开始算了,B 算完覆盖掉 A 的结果。 - 为什么数字每次都不同:丢多少取决于两个线程撞在同一个窗口的概率,跟机器核数、调度都有关。正因为不可复现,它才在生产上难查——所以这类计数器一律用
AtomicInteger或LongAdder。 - 什么时候 volatile 就够了:只做状态标志位(
while (!stop))、一次性安全发布(单个引用赋值)。只要出现「读出来算一下再写回去」,就必须上原子类或加锁。 - 禁止重排怎么体现:写 volatile 变量时,它前面的普通写不会被排到它后面;读的时候,后面的普通读不会被排到它前面。这就是双重检查锁单例里那个
volatile的作用——防止「引用先可见、对象还没构造完」的半成品被别的线程拿去用。 - 可见性失效在 x86 上很难演示:现代 CPU 缓存一致性协议(MESI)基本保证了你写别人很快能看到,所以「不加 volatile 就死循环」的经典 demo 在本机常常跑不出来。这不是 volatile 没用,而是硬件帮你掩盖了。面试别拿「我本地跑不出来」当反例。
面试怎么答volatile 保证可见性和有序性,不保证原子性。可见性是写完立刻刷主内存、读之前必须重新拉;有序性是插入内存屏障,禁止指令重排。i++ 是读、加、写三条指令,volatile 只能保证每条指令拿到最新值,保证不了这三条不被打断,所以 4 个线程各加 10 万次,最后明显小于 40 万。要原子性得用 AtomicInteger 的 CAS 或者加锁。volatile 正确用法是状态标志和双重检查锁里的安全发布。
4
线程池七个参数,和那条容易背错的执行顺序★
核心满了先排队,队列满了才加班
提交一个任务,线程池按这个顺序处理:核心线程没满 → 建核心线程;核心满了 → 进队列;队列满了 → 建到最大线程数;还满 → 走拒绝策略。注意队列在「加线程」之前,这跟直觉相反,也是很多参数配了没效果的原因:队列无界时最大线程数永远用不上。
输出(任务执行行的先后会变,结构不会变) 跑任务01 -> w-1
提交01 成功 | 线程数 1 | 队列 0
提交02 成功 | 线程数 2 | 队列 0
提交03 成功 | 线程数 2 | 队列 1
提交04 成功 | 线程数 2 | 队列 2
提交05 成功 | 线程数 3 | 队列 2
提交06 成功 | 线程数 4 | 队列 2
跑任务02 -> w-2
跑任务05 -> w-3
跑任务06 -> w-4
提交07 被拒绝 | 线程数 4 | 队列 2
跑任务03 -> w-4
跑任务04 -> w-2
全部结束,一共创建了 4 个线程
- 对着输出走一遍:1、2 建核心线程(线程数 1→2,队列 0);3、4 进队列(队列 1→2,线程数不动);5 想进队列但满了,于是建到 w-3;6 建到 w-4;7 队列满、线程也到上限,直接拒绝。核心 2 + 队列 2 + 额外 2 = 6 个任务能进,第 7 个进不来。
- 七个参数记法:核心数、最大数、空闲存活时间、时间单位、队列、线程工厂、拒绝策略。线程工厂决定线程名叫什么,出问题时 jstack 里一眼能认出是谁的池,生产上必须自定义。
- 四种拒绝策略:AbortPolicy 抛异常(默认,最常用,让上游知道被打回来了);CallerRunsPolicy 让提交任务的线程自己跑,天然形成背压;DiscardPolicy 静默丢掉,最危险;DiscardOldestPolicy 丢掉队列里最老的那个再试。
- execute 和 submit 的异常差别很大:
execute里抛的异常会打给UncaughtExceptionHandler,线程死掉、换一个新的;submit会把异常吞进 Future,你不调get()就永远不知道任务炸了。排查线上「任务悄悄没执行」先看这个。 - shutdown 和 shutdownNow:前者不再接新任务、把队列里的跑完;后者中断正在跑的(只是发中断信号,代码不理它照样跑)、把队列里的任务作为返回值还给你。两个都不阻塞,要等结束得
awaitTermination。
面试怎么答七个参数是核心数、最大数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。执行顺序是核心线程满了进队列,队列满了才扩到最大线程数,再满就走拒绝策略——队列在扩线程之前,这一点最容易答错。所以如果给了一个无界队列,maximumPoolSize 永远不会生效。拒绝策略默认 AbortPolicy 抛异常,要背压就用 CallerRunsPolicy。另外 execute 抛的异常会打到线程的未捕获处理器,submit 会把它封进 Future,不 get 就看不到。
5
为什么规约禁止用 Executors 建线程池
它给的两个默认值都是无界的
Executors 的四个工厂方法,把两个最危险的参数都设成了无界:队列无界会让任务无限堆积到 OOM,最大线程数无界会无限创建线程拖死机器。所以规约要求手动 new ThreadPoolExecutor,把队列容量和线程数都写成有界的确定值。输出newFixedThreadPool(2)
队列实现 LinkedBlockingQueue
队列剩余容量 2147483647 <- Integer.MAX_VALUE,等于无界
最大线程数 2 <- 队列永不慢,最大线程数永远用不上
newCachedThreadPool()
队列实现 SynchronousQueue <- 不存任务,直接交给线程
核心线程数 0
最大线程数 2147483647 <- 约 20 亿,来多少建多少
newScheduledThreadPool(1)
队列实现 DelayedWorkQueue(按延迟时间排序的堆)
队列剩余容量 2147483647
newSingleThreadExecutor()
返回类型 FinalizableDelegatedExecutorService <- 被包了一层,参数改不了,也查不到队列
会出事的地方
newFixedThreadPool:队列是LinkedBlockingQueue,默认容量Integer.MAX_VALUE。下游一慢,任务无限入队,每个任务还带着上下文对象,堆就慢慢涨没了——这是线上最典型的 OOM 来源之一。newCachedThreadPool:最大线程数Integer.MAX_VALUE,队列是SynchronousQueue(不存任务)。一下涌进来一万个请求就建一万个线程,每个线程默认 1MB 栈,直接unable to create native thread。newSingleThreadExecutor:返回的是FinalizableDelegatedExecutorService,一层包装类,你既强转不出ThreadPoolExecutor,也没法在运行期调参数看队列。
正确写法
new ThreadPoolExecutor(8, 8, 0L, MILLISECONDS, new ArrayBlockingQueue<>(200), namedFactory, new AbortPolicy()):核心、最大、队列三个数都是确定的,出问题能算出来。- 给每个池起名,比如
order-、pay-前缀。jstack 里一眼看出哪个池堵了,比翻代码快十倍。 - 把池交给 Spring 管理,暴露
activeCount、queueSize、completedTaskCount到监控,队列持续上涨就先扩容或限流。 - 真正要做「一个请求一个线程」且能接受几万个并发时,才考虑虚拟线程(第 15 节),它不需要池化。
面试怎么答因为 Executors 的工厂方法把两个最关键的参数都设成了无界:newFixedThreadPool 和 newSingleThreadExecutor 用默认容量的 LinkedBlockingQueue,等于 Integer.MAX_VALUE,任务堆积就打爆堆;newCachedThreadPool 的 maximumPoolSize 也是 Integer.MAX_VALUE,来多少请求建多少线程,直接报 unable to create native thread。所以我都是手动 new ThreadPoolExecutor,核心数、最大数、有界队列、线程工厂、拒绝策略全写死,再挂监控看队列长度和活跃线程数。
6
核心线程数到底设多少
公式给起点,压测给答案
面试要答三层:CPU 密集取核数 + 1(多出来那个用来顶上偶发的页缺失);IO 密集取核数 × 2 起;更准确的是按等待比例
N × (1 + 等待时间/计算时间)。但公式只是起点,真正的值是压测出来的。输出本机可用核数: 16
CPU 密集 N + 1 = 17
IO 密集 N × 2 = 32
等待占比 50% = 32
等待占比 90% W/C = 9 = 160
等待占比 99% W/C = 99 = 1600
堆上限: 8148 MB
提示: 线程数再大也压不住数据库连接池,先看下游上限
- 公式怎么来的:目标是让 CPU 一直别闲着。IO 密集时线程大部分时间在等(W/C 就是等待和计算的时间比),N × (1 + W/C) 的意思是「要凑够 N 个线程同时在算」。等待占比 90% 就是 10 倍线程,这就是为什么 IO 型服务敢开几百个线程。
- 为什么公式只能当起点:等待时间不是常数——下游抖动、GC 停顿、连接池排队都会改变 W/C。而且线程多了上下文切换开销上来,收益会掉。真正定参数是压测:固定 QPS 往上加,看吞吐什么时候不再涨、RT 什么时候开始翘。
- 别忘了下游上限:给业务池配 200 个线程,数据库连接池只有 20 个,那 180 个线程全堵在拿连接上,加线程一点用没有,还把故障放大。线程数是整条链路里最短的那块板。
- 容器里要小心核数:JDK 10 之后
UseContainerSupport默认开,availableProcessors()会按 cgroup 的 CPU limit 算,是对的。但 JDK 8 早期版本拿到的是宿主机的核数,K8s 里一个限 2 核的 Pod 会以为自己有 64 核,线程池开到几百,互相抢 CPU 抢到全部超时。所以老版本要显式-XX:ActiveProcessorCount=2。 - 隔离比调参重要:不同业务共用一个大池,一个慢接口能把池占满、拖死所有业务。典型做法是按业务拆池,或者用舱壁模式(每类请求分配独立池 + 独立队列)。
面试怎么答先给公式再讲怎么定。CPU 密集取核数加一,IO 密集取核数乘二,更准确是按等待比例算,核数乘一加等待比计算,等待 90% 就是十倍核数。但这个数字只是起点,实际要压测:固定 QPS 加线程,看吞吐的拐点和 RT 的上升。还有两个约束比公式重要:一是不能超过下游能力,比如数据库连接池,线程再多也只是排在连接池外面等;二是容器里要确认真实核数,JDK 8 早期在 K8s 里会读到宿主机核数,得用 ActiveProcessorCount 显式指定。
7
AQS:一个 state 加一条队列★
把「抢不到就排队」这件事标准化
AQS 的骨架就两样东西:一个
volatile int state 表示资源被占用的次数,一个双向链表(CLH 队列的变种)放抢不到的线程。子类只需要实现「怎么算抢到」和「怎么算释放」,入队、挂起、唤醒全在父类里做好了。整个 JUC 的锁、信号量、倒计时门闩都是这一套。输出(t1/t2 谁先进队列不固定)main 先拿到锁,state 由 AQS 的 CAS 从 0 改成 1
t2 想拿锁 -> 进同步队列
t1 想拿锁 -> 进同步队列
此刻排队线程数: 2,队列非空: true
排队中的线程状态: WAITING / WAITING (AQS 用 LockSupport.park 挂起,所以是 WAITING)
main 释放锁,AQS 唤醒队头
t2 拿到锁
t1 拿到锁
全部跑完
- 自己写的部分只有两个方法:
tryAcquire用 CAS 把 state 从 0 改成 1,成功就算抢到;tryRelease直接归零。剩下的——排队、park、被唤醒后重试、前驱节点清理——全由acquire/release完成。这就是模板方法模式。 - state 的含义由子类定义:
ReentrantLock里 state 是重入次数;Semaphore里是剩余许可数;CountDownLatch里是还没到 0 的计数;ReentrantReadWriteLock把 32 位劈成两半,高低位分别记读锁和写锁。同一个 state 字段,语义完全由子类给。 - 独占和共享两套 API:独占走
acquire/release(ReentrantLock),共享走acquireShared/releaseShared(Semaphore、CountDownLatch、ReadWriteLock 的读锁)。区别是共享模式下唤醒会向后传播,一次叫醒一串。 - 为什么排队线程是 WAITING 而不是 BLOCKED:AQS 不用
synchronized,用的是LockSupport.park,对应状态是 WAITING。这也是为什么 jstack 里看到一片 WAITING 时,得看栈顶是不是park,才是锁竞争。 - Condition 也是 AQS:每个
Condition有一条自己的条件队列,await把线程从同步队列挪到条件队列,signal再挪回来。所以一个锁能有多条等待队列,这是synchronized只有一个 wait set 做不到的。
面试怎么答AQS 的核心是一个 volatile 的 int state 加一条双向的等待队列。子类只实现 tryAcquire 和 tryRelease 来定义什么叫抢到、什么叫释放,排队、park 挂起、唤醒重试这些模板逻辑都在父类。state 的语义完全由子类决定:ReentrantLock 里是重入次数,Semaphore 里是许可数,CountDownLatch 里是剩余计数,读写锁把高低 16 位分别给读和写。这样一套队列就支撑了整个 JUC 的同步器,这也是它能被拿来当面试题的原因。
8
ReentrantLock 比 synchronized 多了什么
功能多,代价是要自己放锁
性能上 1.6 之后两者差不多,
synchronized 还有锁升级和锁消除优化,别再答「ReentrantLock 更快」。真正的差别是四个能力:等锁可中断、等锁可超时、可选公平、一个锁挂多条 Condition。代价是必须手动 unlock,忘了就是永久死锁。输出tryLock(200ms) 拿到锁: false,等了 215ms 就放弃
子线程在等锁时被打断,干净退出,没拿到锁
同一线程连拿两次,holdCount = 2
放完两次,holdCount = 0,isLocked = false
公平锁按到达顺序拿到: 1 2 3 4
- 超时是防死锁的实用手段:
tryLock(200, MILLISECONDS)等不到就返回 false,调用方可以选择降级、重试或者报错,而不是无限挂着。分布式锁本地那一层也常这么写,避免线程堆积。 - 可中断解决「取消不了」:
lockInterruptibly()在等锁时能响应interrupt(),这对优雅停机很关键——线程池shutdownNow发的就是中断信号。synchronized的等待不响应中断,只能干等锁释放。 - 公平锁默认不开:非公平锁允许刚到的线程插队,吞吐更高(省掉唤醒开销),代价是可能饿死。只有「顺序不能乱」的场景(比如按请求顺序处理)才开公平,而且公平锁吞吐会明显下降。
- Condition 才是它最有用的地方:一个锁挂多个条件队列,实现「生产者等不满、消费者等不空」两拨人分开唤醒。用
synchronized只有一个 wait set,notifyAll会把对方也一起叫醒,白跑一趟。 - 必须写在 finally 里:
lock()之后立刻 try,unlock 放 finally。少写一次,那个线程出异常后锁永远不释放,后续所有线程排队到超时——线上最难查的一类问题。 - 能选就选 synchronized:语法简单、不用管释放、JVM 还能做锁消除和偏向。只有需要上面四个能力之一时才换 ReentrantLock。
面试怎么答性能不是理由,JDK 6 之后两者基本持平,synchronized 还有偏向锁和锁消除。ReentrantLock 的价值在四个能力:tryLock 超时、lockInterruptibly 可中断、可指定公平锁、一个锁挂多个 Condition 做精确唤醒。典型场景是需要超时降级的、需要优雅停机响应中断的、需要按条件分开唤醒的生产者消费者。代价是必须 try/finally 里 unlock,忘了就永久死锁,所以我默认还是用 synchronized,只有确实需要这些能力才换。
9
ThreadLocal 的内存泄漏,和线程池里的脏数据★
两件事,都跟线程被复用有关
每个线程内部挂着一个
ThreadLocalMap,key 是 ThreadLocal 对象的弱引用,value 是强引用。所以 key 会被 GC 回收(变成 null),value 却因为被 Entry 强引用着收不掉——这条 entry 就永久占着内存,直到线程结束或者下次调用 set/get/remove 时被顺带清理。线程池里线程不死,所以「永久」就是真的永久。输出任务2 读到: 用户A的登录态 <- 脏数据,线程是复用的
remove 之后任务4 读到: null
ThreadLocalMap 数组长度: 16
key 还活着的 entry: 1
key 已被回收、value 还占着的 entry: 1,白占 65536 字节(这块要等线程死了才能收回)
- 前两行是更常见的坑:脏数据。业务上「用户 A 的登录态」被任务 2 读到了,如果任务 2 又没覆盖就往下走,等于给 A 的请求用了 B 的身份。这类问题比内存泄漏更凶,因为它是串号,不是慢。
- 为什么 key 用弱引用:如果 key 也强引用,那 ThreadLocal 对象就永远活着,泄漏更彻底。用弱引用是「尽量让你能被回收」的妥协。设计上想的是:ThreadLocal 没人用了,key 自动失效,然后靠 set/get/remove 时顺带把 value 清掉(
expungeStaleEntry)。 - 但清理是「顺带」的:只有这个线程下次操作同一个 Map 才会清。线程池里线程长期复用、又恰好不再碰这些 key,value 就一直挂着。所以规约要求 用完必须 remove,而且写在 finally 里,保证异常也执行。
- Spring 的 RequestContextHolder 就是这么做的:框架在请求结束时帮你 remove,所以平时不觉得有问题。一旦自己起线程池做异步,把 ThreadLocal 想当然地往后传,就会既丢上下文又留垃圾。
- 跨线程传递要专门方案:
InheritableThreadLocal只在创建子线程那一刻拷贝一次,线程池里的线程是复用的,拷贝的那次早过期了。异步链路要用TransmittableThreadLocal(阿里的 TTL)或者手动把上下文当参数传,别指望自动继承。 - 和前端的对照:React 的 Context 是显式读取的树形传递,不会有「隐式状态跟着执行线程走」的问题。Java 这边 ThreadLocal 方便,代价就是必须自己收尾。
面试怎么答ThreadLocal 每个线程内部有一个 ThreadLocalMap,key 是 ThreadLocal 的弱引用,value 是强引用。key 被 GC 回收后 entry 的 key 变 null,但 value 还被 Entry 强引用着,收不掉,这就是泄漏的来源。清理动作只在 set/get/remove 的时候顺带做,线程池里线程不死、又不碰这个 key,value 就一直留着。所以第一是用完在 finally 里 remove,第二是别把大对象往里放。还有个更常见的问题:线程复用时忘清导致读到上一次请求的上下文,那是串号,比内存泄漏更严重。
10
ConcurrentHashMap 1.8 为什么扔掉分段锁★
锁的对象从一个段变成桶的头节点
1.7 是 16 个
Segment,每个继承 ReentrantLock,锁的粒度是一段;1.8 改成桶级别 + CAS:桶为空时用 CAS 直接放,桶不为空才 synchronized 锁住这个桶的头节点。锁的粒度细了一个量级,并发度从固定 16 变成跟桶数一样。输出(第一行的数字每次不同,但一定远小于 80000)HashMap get + put 计数,期望 80000,实际 21835 <- 丢了
CHM merge 计数,期望 80000,实际 80000 <- 一次不少
CHM 批量 put,期望 80000 条,实际 80000 条
chm.put("k", null) 抛 NullPointerException:并发下 null 无法区分「没有」和「有但为空」
compute 后 apple = 2,pear 还是 9(锁的是桶的头节点,不是整张表)
遍历中删掉了 c,没抛 ConcurrentModificationException,剩余 0 条
- HashMap 丢数据是怎么发生的:两个线程同时往同一个桶里插,都读到「下一个位置是空的」,都把自己的节点接上去,后写的把先写的覆盖掉。JDK 8 之后不会再死循环(1.7 的头插法扩容时会成环),但丢数据照旧,还会出现
size与实际不符。 - 1.8 的三层写入路径:桶为空 → CAS 直接放(无锁);桶不为空 →
synchronized锁这个桶的头节点,再走链表或红黑树插入;正在扩容 → 帮忙迁移(helpTransfer)。锁的粒度是一个桶,跟分段锁的「一段 16 个桶」差一个量级。 - size 也不是一把锁数出来的:基础计数放
baseCount,并发冲突时改用CounterCell数组分散累加,最后求和。size()返回的是估算值,并发下别拿它做精确判断。 - 弱一致迭代器是设计选择:不抛
ConcurrentModificationException,但也不保证看到遍历开始之后的修改。能用,但不能当快照用——需要快照就自己new ArrayList<>(map.keySet())。 - 为什么不允许 null:单线程下
get返回 null 还能用containsKey区分「没有这个 key」和「值是 null」;并发下这个两步判断之间状态可能已经变了,无法区分,所以干脆禁止。 - 什么时候用它:读多写少、需要线程安全的共享缓存。写特别频繁、又要计数,用
LongAdder;需要整体一致性的批量操作,还是得自己加锁或换方案。
面试怎么答1.7 用的是 Segment 分段锁,默认 16 个段,每个段继承 ReentrantLock,锁的粒度是一段,并发度固定 16。1.8 换成 CAS 加 synchronized 锁桶头节点:桶为空直接 CAS 放进去,桶不为空只锁这个桶的头,粒度细了一个量级,并发度跟着桶数走。数据结构也从 Segment 数组加 HashEntry 改成 Node 数组加链表和红黑树。size 用 baseCount 加 CounterCell 分散统计,所以是估算值。迭代器是弱一致的,遍历时可以改,但不保证看到新值。还有一点,它不允许 null 键值,因为并发下没法区分「没有」和「值为空」。
11
CAS 与 ABA:看起来没变,其实变过
无锁的代价是「只看值」
CAS 是「我认为现在是 A,如果确实是 A 就改成 B」,一条 CPU 指令完成比较和替换,不需要锁。它的漏洞叫 ABA:值从 A 变成 B 又变回 A,CAS 认为什么都没发生。带版本号的
AtomicStampedReference 就是给值配一个版本号,一起比。输出[无版本号] A 读到余额 100,准备扣 30
[无版本号] B 已经把 100 取走又存回,余额看着还是 100
[无版本号] A 的 CAS 成功? true,余额 70 <- 中间的变化被完全掩盖
[带版本号] A 读到余额 100,版本 1
[带版本号] B 取走,版本变成 2
[带版本号] B 存回,版本变成 3,余额又看着是 100
[带版本号] A 的 CAS 成功? false <- 版本对不上,被拦住了
[带版本号] 余额仍然是 100
- 什么时候 ABA 会真出事:别人看到的都只是「最终值」,但如果中间状态有业务含义就麻烦了。经典例子是栈:A 看到栈顶指针指向节点 x、x.next 是 y,此时别的线程把 x、y 都弹了又压回 x,A 用旧快照 CAS 成功,结果 y 已经被释放或复用,指针悬空。
- 怎么防:
AtomicStampedReference每次更新版本号 +1;AtomicMarkableReference只记一个 boolean,够用且更省。带版本号的代价是 CAS 要比两个字段,重试率稍高。 - CAS 的三个老毛病:一是自旋开销,竞争激烈时一堆线程空转烧 CPU;二是只能保证一个变量的原子性,要保证多个变量就得上锁或者塞进一个对象里用
AtomicReference;三是 ABA。 - 高并发计数用 LongAdder 而不是 AtomicLong:
AtomicLong所有线程 CAS 同一个值,热点集中;LongAdder拆成多个 Cell 分散累加,求和时才汇总,写多读少的场景吞吐高好几倍(代价是读到的不是精确瞬时值)。 - 和前端对照:前端也有类似思路——乐观更新、版本号冲突检测(编辑冲突时后端返回 409 让你重试)。本质一样:用版本号代替锁来发现「中途被改过」。
面试怎么答CAS 是一条 CPU 指令做到比较并替换,无锁但要自旋。ABA 的问题是只看当前值:如果被改成了别的值又改回来,CAS 就发现不了,中间发生的事被完全掩盖。可以用 AtomicStampedReference 给值配一个版本号一起比,或者用 AtomicMarkableReference 记个布尔标记。CAS 还有两个局限,一是竞争激烈时空转烧 CPU,二是只能保证一个变量的原子性,多个变量就得封装成一个对象或者加锁。所以高并发累加我会用 LongAdder,它是分散到多个 Cell 再求和。
12
死锁:从四个条件到 jstack 里那三行★
不只是理论,能当场复现并检测出来
死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。破坏任意一个就行,实践中最容易的是破坏「循环等待」——给所有锁排一个全局顺序,永远按这个顺序拿。出问题时的定位手段是
jstack,它会把死锁线程、各持哪把、在等哪把直接打出来。输出检测到死锁线程数: 2
线程 业务线程-1 状态 BLOCKED
卡在: java.lang.Object@37a71e93
等这把锁,被 业务线程-2 持有
线程 业务线程-2 状态 BLOCKED
卡在: java.lang.Object@4783da3f
等这把锁,被 业务线程-1 持有
两把锁的获取顺序相反 = 环路等待,死锁的四个必要条件全齐
- 线上怎么发现:接口全挂、CPU 却不高、日志停住不输出——这三个症状凑齐,第一反应就是死锁。用
jps找到 pid,jstack <pid>,直接搜Found one Java-level deadlock,下面会把每条线程持有什么、等什么列清楚。 - ThreadMXBean 就是 jstack 的 API 版:
findDeadlockedThreads()同时能查出对象锁死锁和ReentrantLock死锁(findMonitorDeadlockedThreads只管 monitor)。可以挂到健康检查接口上,超时自动 dump,把现场留下来。 - 四个条件对应的破法:互斥——没办法,锁本质如此;持有并等待——一次性申请所有资源,或者拿不到就释放已有的;不可剥夺——用
tryLock超时,拿不到就回退重来;循环等待——全局排序,永远按小到大的顺序拿,这是工程上最省事的。 - 死锁之外还有「活锁」和「饥饿」:活锁是两个线程一直互相让步、谁也进不去(重试加随机退避能缓解);饥饿是某线程永远抢不到锁(非公平锁 + 优先级差异)。两者在 jstack 里都看不出死锁,但业务表现一样是卡住。
- 比死锁更常见的是「类死锁」:连接池被占满、线程池队列满导致任务互相等待、数据库行锁升级——jstack 上看到一片 WAITING 在同一个方法上。所以看到线程全卡住,别只找 deadlock 关键字,还要看大家都在等什么。
面试怎么答死锁要同时满足互斥、持有并等待、不可剥夺、循环等待,破坏任意一个就能避免,工程上最常用的是给所有锁定一个全局顺序,永远按顺序获取,直接掐掉循环等待。定位靠 jstack,输出里会直接写 Found one Java-level deadlock,然后把每个线程持有什么锁、在等谁持有的锁列出来;代码里也能用 ThreadMXBean 的 findDeadlockedThreads 做到监控里。另外要区分开,线上线程全卡住更常见的原因不是死锁,而是连接池或线程池被占满造成的相互等待,jstack 里没有 deadlock 字样但状态一样是 WAITING。
13
六个线程状态,和 wait 与 sleep 的三处不同
为什么没有 RUNNING
JVM 把线程状态分成 6 个:
NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。没有 RUNNING——因为分不出「正在跑」和「就绪排队等 CPU」,JVM 把它们合并成 RUNNABLE。输出刚 new 出来 : NEW
在跑 while(true) : RUNNABLE
在 Thread.sleep : TIMED_WAITING
在等 synchronized : BLOCKED
在 Object.wait : WAITING
wait 被唤醒后跑完 : TERMINATED
sleep 和 wait 的差别
- 锁:
sleep不释放持有的锁;wait会释放锁,所以别人才能进来改条件。这是最常考的一条。 - 归属:
sleep是Thread的静态方法,wait是Object的方法。后者必须在持有该对象 monitor 的同步块里调,否则抛IllegalMonitorStateException。 - 唤醒:
sleep到点自己醒;wait必须靠别人notify/notifyAll(或带超时的wait(ms))。没人通知就是永久 WAITING。
与其容易混的还有
join():等另一个线程结束,本质是while (isAlive()) wait(0),所以状态也是 WAITING。park()/unpark():LockSupport的底层原语,不要求持锁,可以提前unpark(发许可)。AQS 内部就用它,jstack 里栈顶是Unsafe.park。BLOCKED只对应「等 synchronized 的 monitor」;等ReentrantLock时状态是WAITING。这一点很多人答错。
面试怎么答六个状态是 NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。没有 RUNNING,因为 JVM 不区分正在 CPU 上跑和就绪等调度,统称 RUNNABLE。BLOCKED 只用于等 synchronized 的 monitor,等 ReentrantLock 是 WAITING,因为它底层是 LockSupport.park。sleep 和 wait 三点不同:sleep 不释放锁、wait 释放锁;sleep 属于 Thread,wait 属于 Object 且必须在同步块里;sleep 到点自己醒,wait 要靠 notify。
14
CompletableFuture:把回调编排成链
thenApply 和 thenCompose 别搞反
thenApply 是把结果映射成另一个值(里面返回的是值),thenCompose 是把结果摊平成另一个异步任务(里面返回的是 CompletableFuture)。搞反了会得到 CompletableFuture<CompletableFuture<T>>,然后发现拿不到值。输出thenCombine 合并两个异步结果: 108
没出错时 exceptionally 不碰: 正常数据
出错时走到兜底 : 兜底数据,原因: 下游超时
三个 300ms 的调用:串行 908ms,并行 313ms
join 抛 CompletionException,真正原因在 cause: ArithmeticException
- 并行比串行快三倍的代价:三个接口同时打出去,总 RT 等于最慢那个。但要注意它们共用同一个线程池——默认是
ForkJoinPool.commonPool(),并行度是核数减一。核心业务全用默认池,一个慢任务就能把池占满,拖累所有异步流程。所以有 IO 的一定要传自己的池。 - thenApply / thenAccept / thenRun 三兄弟:
thenApply有入参有返回值(转换);thenAccept有入参无返回值(消费);thenRun无入参无返回值(跑一下就完了)。 - thenApply 和 thenApplyAsync 的区别:不带 Async 的,如果上一个阶段已经完成、或者当前就在同一个线程里,回调会就地执行(用调用者线程);带 Async 的会再提交给线程池。前者少一次调度、更快,但会占用当前线程——在 Tomcat 工作线程里跑重活要小心。
- 异常处理四个口子:
exceptionally只处理异常;handle无论成败都进(能同时拿到结果和异常);whenComplete无论成败都进但不改变结果;join抛CompletionException,get抛ExecutionException,两者都把真正的异常藏在 cause 里。 - allOf 拿不到结果:
allOf只保证全部完成,返回CompletableFuture<Void>;要收集结果得自己stream().map(CompletableFuture::join).collect(...)。另外allOf是「全部完成」,不是「全部成功」——任一失败就整体失败,想等全部结束不论成败得自己 try 包一层。
面试怎么答CompletableFuture 的核心是把异步回调串成链。thenApply 是同步映射,返回普通值;thenCompose 是摊平嵌套的异步任务,返回 CompletableFuture;搞反会拿到双层嵌套。合并用 thenCombine,等全部完成用 allOf,失败兜底用 exceptionally 或 handle。要注意两点:默认线程池是 ForkJoinPool.commonPool,并行度是核数减一,做 IO 必须换成自己的池,否则会互相拖累;还有 join 抛的是 CompletionException,get 抛的是 ExecutionException,真正原因都在 cause 里。
15
虚拟线程:不是更快,是更省★
JDK 21 正式版,这段真跑过
一个平台线程对应一个 OS 线程,创建成本高(默认 1MB 栈),所以只能开几千个,才有了「线程池」这种复用套路。虚拟线程是 JVM 自己实现的轻量线程,多个虚拟线程复用少量 OS 线程(叫载体线程);它一阻塞就把载体线程让出去,所以可以放心地开几十万个。价值在「省」——省内存、省线程池调参,不在「快」。
输出(JDK 21.0.12 真跑,具体毫秒数每次有波动)任务数 20000,每个任务 sleep 100ms 模拟一次下游调用
平台线程池(200 个线程): 10664 ms
虚拟线程(每任务一个) : 424 ms
虚拟线程是不是虚拟的: true,名字 demo-vt
提醒: 虚拟线程不要池化,ThreadLocal 也别往里塞大对象
- 为什么快这么多:2 万个任务、每个阻塞 100ms。平台线程只有 200 个,得分 100 批跑,100ms × 100 批 = 10 秒左右,跟实测 11 秒对得上——时间全花在排队等线程上。虚拟线程一阻塞就把载体线程让出来,2 万个几乎同时在等,所以总耗时只比单个任务的 100ms 高几倍(实测 424ms),而不是平台线程那样排 100 批。
- 它省的是什么:一个平台线程的栈默认 1MB,2 万个就是 20GB,根本开不出来;虚拟线程的栈存在堆上、按需增长,初始只有几百字节。所以虚拟线程解决的是「线程数上限」,不是「单次计算更快」——CPU 密集的任务用它一点收益都没有。
- 不要池化虚拟线程:池的价值是复用昂贵资源,虚拟线程很便宜,池化反而丢了它的优势。正确用法是
Executors.newVirtualThreadPerTaskExecutor(),一个任务一个,用完就扔。 - 钉住(pinning)是当前最大的坑:JDK 21 里虚拟线程在
synchronized块内阻塞时,会把载体线程一起占住,退化成平台线程。老代码里全是synchronized,很容易踩。JDK 24 已经解决了这个问题(JEP 491)。过渡期可以用-Djdk.tracePinnedThreads=full打印钉住的栈。 - ThreadLocal 要重估:以前靠线程池复用「顺便」当缓存,现在每个任务一个新虚拟线程,ThreadLocal 每次都要新建,反而变成负担。JDK 21 起推荐
ScopedValue(预览)传上下文。 - 结构化并发:配套的
StructuredTaskScope让「一组子任务同生共死」——任一失败就取消其余,避免线程泄漏。JDK 21 还是预览 API,面试提到名字和意图就够了。
面试怎么答虚拟线程是 JVM 层面的轻量线程,多个虚拟线程复用小量平台线程当载体,阻塞时自动让出载体,所以能开到几十万。它的价值是省,不是快:平台线程一个栈默认 1MB,两万个就开不出来,虚拟线程的栈在堆上按需增长。我实测过两万个任务各 sleep 100ms,200 个平台线程要 11 秒,虚拟线程 1.1 秒。要注意三点:不要池化,它的优势就是便宜不用复用;JDK 21 里在 synchronized 块内阻塞会钉住载体线程,退化成平台线程,这个到 JDK 24 才解决;它还只适合 IO 密集,CPU 密集用它没收益。
16
速答清单
42 条,考前扫一遍
这一节没有新知识,只有能直接说出口的短答案。盖住右边先自己答,答不上来的做个记号,第二轮只看记号。
01start() 和 run() 的区别?
start 才向系统申请线程并由新线程回调 run;直接调 run 是普通方法调用,跑在当前线程。
02线程能启动两次吗?
不能。第二次抛 IllegalThreadStateException,因为线程的栈和生命周期绑在 OS 线程上。
03Callable 和 Runnable 的区别?
Callable 有返回值、能抛受检异常,但要包 FutureTask 才能交给 Thread。
04synchronized 修饰静态方法锁的是什么?
锁 SyncLock.class 这个 Class 对象,全类共用一把,和实例锁互不影响。
05两个线程调不同对象的同步方法会互斥吗?
不会。锁的是各自的 this,是两把锁,各跑各的。
06锁升级的顺序?
无锁 → 偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(挂 monitor 排队),不可逆。
07为什么不要锁 String 字面量?
常量池里全 JVM 一份,不相干的代码锁同一个对象会互相干扰。
08volatile 保证原子性吗?
不保证。i++ 是读、加、写三条指令,volatile 只保证每条看到最新值。
09volatile 的两个语义?
可见性(写立刻刷主内存、读必须重新拉)和禁止指令重排(插内存屏障)。
10双重检查锁单例为什么加 volatile?
防止「引用已赋值但对象没构造完」的半成品被别的线程拿去用。
11线程池七个参数?
核心数、最大数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。
12线程池的执行顺序?
核心满了进队列,队列满了才扩到最大线程数,再满走拒绝策略。队列在扩线程之前。
13为什么 maximumPoolSize 常常不生效?
因为队列是无界的,永远排得进去,永远到不了「队列满」。
14四种拒绝策略?
AbortPolicy 抛异常、CallerRunsPolicy 交给提交线程跑(背压)、DiscardPolicy 静默丢、DiscardOldestPolicy 丢最老的。
15execute 和 submit 的异常差别?
execute 抛给线程的未捕获异常处理器;submit 封进 Future,不 get 看不到。
16shutdown 和 shutdownNow 的区别?
前者跑完队列里的任务再停,后者中断在跑的并把队列任务还给你。都不阻塞。
17为什么规约禁止用 Executors?
它给的队列和最大线程数都是无界的,一个会 OOM、一个会拖死机器。
18核心线程数怎么定?
CPU 密集 N+1,IO 密集 2N,更准确是 N×(1+等待/计算),最终靠压测。
19AQS 的两个核心组成?
一个 volatile int state 表示资源占用,一条双向等待队列放抢不到的线程。
20AQS 里 state 的语义由谁决定?
由子类决定:ReentrantLock 是重入次数,Semaphore 是许可数,CountDownLatch 是剩余计数。
21ReentrantLock 比 synchronized 多的能力?
等锁可中断、等锁可超时、可选公平锁、一个锁挂多条 Condition。
22ReentrantLock 最容易出的事故?
忘了在 finally 里 unlock,那个线程一异常锁就永不释放。
23ThreadLocal 为什么会内存泄漏?
ThreadLocalMap 的 key 是弱引用会被回收,value 是强引用收不掉,entry 就永久残留。
24ThreadLocal 怎么正确用?
用完在 finally 里 remove,尤其在线程池里,否则既泄漏又串号。
25InheritableThreadLocal 为什么在线程池里失效?
它只在创建子线程那一刻拷贝一次,线程池的线程是复用的,拷贝早过期了。
26ConcurrentHashMap 1.8 用什么保证线程安全?
桶为空用 CAS 直接放,桶不为空用 synchronized 锁这个桶的头节点。
27ConcurrentHashMap 的 size 精确吗?
不精确。baseCount 加 CounterCell 分散累加,size() 是估算值。
28ConcurrentHashMap 为什么不许 null?
并发下无法区分「没有这个 key」和「值本身就是 null」。
29ConcurrentHashMap 的迭代器是强一致的吗?
是弱一致。遍历时可以改,不抛异常,但不保证看到遍历开始后的修改。
30CAS 有什么问题?
自旋烧 CPU、只能保证一个变量的原子性、有 ABA 问题。
31ABA 怎么解决?
用 AtomicStampedReference 给值配版本号一起比,或用 AtomicMarkableReference 记布尔标记。
32死锁的四个必要条件?
互斥、持有并等待、不可剥夺、循环等待,破坏任意一个即可。
33线上死锁怎么查?
jps 找 pid,jstack 搜 Found one Java-level deadlock,下面会列持有和等待关系。
34BLOCKED 和 WAITING 的区别?
BLOCKED 只对应等 synchronized 的 monitor;等 ReentrantLock 是 WAITING(底层 park)。
35为什么没有 RUNNING 状态?
JVM 分不出正在跑和就绪等 CPU,合并成 RUNNABLE。
36sleep 和 wait 的三点不同?
sleep 不释放锁、wait 释放;sleep 属于 Thread、wait 属于 Object 且要在同步块里;sleep 自己醒、wait 要 notify。
37thenApply 和 thenCompose 的区别?
thenApply 返回普通值做映射;thenCompose 返回 CompletableFuture 做摊平,避免嵌套。
38CompletableFuture 默认用哪个线程池?
ForkJoinPool.commonPool(),并行度是核数减一,做 IO 必须换成自己的池。
39虚拟线程和平台线程的区别?
平台线程 1:1 对应 OS 线程;虚拟线程是 JVM 调度的,多个复用一个载体线程,阻塞时让出。
40虚拟线程适合什么场景?
IO 密集、高并发短任务。CPU 密集用它没有收益,因为瓶颈在核数。
41虚拟线程能池化吗?
不要。它本身很便宜,池化反而丢掉了「用完就丢」的优势。
42虚拟线程现在最大的坑?
JDK 21 在 synchronized 块里阻塞会钉住载体线程,退化成平台线程,JDK 24 才修好。