最近不少人在准备Java面试,后台私信问得最多的就是并发编程——这块内容又多又碎,网上题解倒是铺天盖地,但绝大多数都是“背完就忘”的答案集。我断断续续整理了123道并发编程面试题,本来想直接甩出来,但回头看了看,发现纯题目+答案的形式其实帮不了太多人。因为面试官现在普遍会顺着你的回答一路追问,背下来的答案经不住两句“为什么”就会露馅。
所以这篇文章不打算做成传统意义的“123道题逐题罗列”,而是把高频考点按底层逻辑重新揉一遍,重点讲清楚两件事:一是这些题到底在考察什么,二是你该怎么答才能让面试官觉得你是真的理解,而不是背了八股文。
文章适合正在准备Java后端面试的候选人,也适合工作两三年想系统梳理并发知识的开发。如果你时间紧,可以直接跳到自己薄弱的小节看;如果你时间充裕,建议按顺序读一遍,因为这几块内容其实是一条线串下来的。
1. 线程基础与状态流转:为什么“线程有几种状态”一问就卡壳
这道题几乎是Java并发面试的必问题,而且大概率是开场第一题。看着简单,但能在这个问题上让面试官点头的人,百里面试者不到三成。
1.1 六种状态的标准答案与易错边界
Java线程的公开状态有六种,定义在Thread.State枚举里:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。
很多人能背出这六个名字,但问到“运行中的线程是什么状态”就容易踩坑。这里最关键的一点是:RUNNABLE状态下线程不一定是正在被CPU执行,它可能是可运行的、在等待操作系统调度。JVM把操作系统层面的ready和running统一归为RUNNABLE。若干年在面试里,很多人的错误回答是“线程正在运行是RUNNING”,Java里没有这个公开状态。
线程一旦调用start()后进入RUNNABLE,并不是直接处于“执行中”的语义。操作系统时间片轮转下,RUNNABLE随时会被切换出CPU,但它仍然处于RUNNABLE。只有遇到锁、wait()、sleep()、join()等场景,才会切到其他状态。
1.2 wait、sleep、join、yield的底层差异
面试官拿到“线程状态”这道题,必定会顺藤摸瓜问wait()和sleep()的区别。这是个老生常谈的区分题,我建议你抓住三个核心纬度回答:
第一,锁的释放。wait()来自Object,调用后线程会释放对象监视器锁;sleep()来自Thread,它在睡觉期间不会释放已经持有的锁。这个区别在并发场景里的影响非常大,很多人写代码等到超时唤醒,却一直占着锁不放,最后活活把并发拖成串行。
第二,使用场景与位置。wait()必须在synchronized代码块或方法里调用,否则抛IllegalMonitorStateException;sleep()没有这个限制,哪里都可以睡。
第三,唤醒机制。wait()必须靠notify()/notifyAll()或者wait(long timeout)超时来恢复;sleep()到点自动醒来,和锁、通知毫无关系。
join()也经常被拎出来问。它本质上是让当前线程阻塞等待目标线程执行完毕,底层靠wait()实现,所以调用join()的线程在等待期间也是释放锁的,内部逻辑可以理解为“当前线程在目标线程对象上wait,目标线程终止时由JVM自动调用notifyAll”。
至于yield(),它纯粹是“让出CPU时间片”的提示,线程从运行状态退回RUNNABLE,锁不释放,能不能让出去还得看操作系统的调度心情。实际业务代码里用得很少,面试里能说清楚它和sleep()在锁释放上的差异就够了。
1.3 高频追问:start()两次与run()直接调用
很多人在这个环节翻车。start()两次会怎样,答案是抛IllegalThreadStateException。原因在于线程内部维护了一个线程状态,start()执行后状态从NEW变成RUNNABLE,再次start()会校验状态,非NEW状态直接拒绝。
那为什么不能直接调run()?因为run()就是一个普通方法,只会在当前线程串行执行,没有任何新建线程的动作。调用start()才会触发JVM创建操作系统线程并以新线程执行run()。这个问题的考察点其实就是:你是否理解start()和run()的语义分层——一个是线程启动入口,一个是业务执行体。
我建议你在回答这类基础题时,条件允许就拿jstack体验一下现场。你写一段多线程代码,jstack打印线程栈,能清楚看到每个线程处于什么状态、阻塞在哪个锁上。这种经验比背概念要深刻得多。
2. 并发三大特性与volatile的底层真相:一道题串起一整个知识网
从线程状态继续往下问,面试官大概率会抛出一个灵魂拷问:“并发编程要解决哪些问题?”思路清晰的人会直接答三大特性:原子性、可见性、有序性。这三大特性几乎能串起整个Java并发体系,也是后面所有问题的基础。
2.1 三大特性分别解决什么问题
原子性:一个或多个操作要么全部执行成功、不被中断,要么就都不执行。最常见的反例是i++,它看起来是一条语句,实际是“读、改、写”三步,多线程环境下不加同步就会丢数据。可以通过synchronized、Lock、Atomic系列类保证。
可见性:一个线程修改了共享变量后,其他线程能否立刻看到。CPU多核架构下,每个核心都有自己的一级、二级缓存,线程操作的是缓存副本,可能很久都不会同步回主存。volatile、synchronized、final都可以触发可见性保证。
有序性:编译器和CPU为了提高性能会对指令做重排序。在单线程下这没有问题,因为重排序必须遵守as-if-serial语义,但在多线程下,另一个线程可能看到“乱序”的执行结果。volatile和synchronized都能阻止特定场景的指令重排。
2.2 volatile的两层语义
volatile是并发面试里的高频题,它解决的是可见性和有序性。
可见性靠的是底层的内存屏障。JVM在volatile写操作前后插入屏障,强制把当前线程工作内存中对该变量的修改刷回主内存;在读操作上,让当前线程的工作内存中该变量失效,必须从主内存重新加载。换句话说,volatile变量一旦被写入,就不会停留在CPU缓存里不被别人感知。
有序性靠的是编译器屏障,禁止对volatile变量周围的读写操作做重排。这里有个经典代码场景:单例模式的双重检查锁(DCL)。看看这段代码:
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }synchronized保证了原子性,但instance = new Singleton()这行在JVM里会经过三步:分配内存、初始化对象、将引用指向内存。如果不加volatile,第二步和第三步可能被重排,线程A把引用指向了尚未初始化完成的内存,线程B拿到非空但没构造完的instance,直接使用就出问题了。
2.3 为什么volatile不保证原子性
这是围绕volatile最常挖的坑。面试官通常会给出一道经典追问:多线程执行volatile int count的count++,结果是否等于期望值?答案是否定的。
count++在字节码层面是GETFIELD、ICONST_1、IADD、PUTFIELD四条指令的叠加,volatile只保证你每次读到最新值、写入立即可见,但它没法把“读、改、写”这三步做成一个不可拆分的原子操作。线程A读到1,线程B也读到1,各自加1写回,最终值是2而不是期望的3。
更直观的类比是食堂打饭窗口:volatile保证了每个窗口贴了实时价格,但两个人同时刷卡付款,仍然可能因为中间流程不是原子的而超卖。
解决i++原子性通常用三种方案:访问方法加synchronized、使用AtomicInteger这种CAS类、或者加Lock。这三种都能满足原子性,但并发度和适用场景不一样,这正是后面AQS那一块要展开的内容。
2.4 Happens-Before规则:面试加分项
谈起有序性和可见性,就绕不开JMM(Java内存模型),而JMM最核心可执行的部分就是Happens-Before规则。面试时你能主动说出这套规则,是个很大的加分项。
Happens-Before定义的是“前一个操作的结果对后一个操作可见”的内存规则。常见几条:程序次序规则,乱序重拍不能改变单线程的语义;监视器锁规则,解锁对后续加锁可见;volatile变量规则,写对后续读可见;传递性规则,A happens-before B,B happens-before C,则A happens-before C。
这里要提醒你,别把所有并发问题都寄托在synchronized和volatile上。Happens-Before规则只是最基础的逻辑骨架,真正落地的时候要考虑锁粒度、数据结构选择、并发度指标,那就是另一层功夫了。
3. synchronized锁升级全链路:从无锁到重量级锁的完整过程
synchronized在JDK 1.6之后有了锁升级机制,这也是面试中和“底层原理”强绑定的一题。很多人知道锁升级这个名词,但把过程说错、说漏、说乱的占大多数。
3.1 对象头Mark Word与锁状态标识
synchronized锁存在Java对象头里。说到对象头,你先要建立一个概念:Java对象在内存里除了实例数据,还有一部分固定开销叫对象头。HotSpot虚拟机里,对象头包含Mark Word和Klass Pointer,数组类型还有数组长度。其中Mark Word就是用来存放锁状态、哈希码、GC分代年龄等信息的地方。
锁状态的变化,本质上就是Mark Word里的位模式在切换,从无锁状态到偏向锁、轻量级锁、重量级锁,Mark Word存储的内容各不相同:
- 无锁状态:存对象哈希码、分代年龄、偏向锁标记为0。
- 偏向锁:存持有偏向锁的线程ID、偏向时间戳、偏向锁标记为1、锁标志位01。
- 轻量级锁:存指向线程栈中锁记录的指针,锁标志位00。
- 重量级锁:存指向监视器(Monitor)对象的指针,锁标志位10。
这四种状态不是并列的,而是竞争加剧以后的升级路径。锁只能升级、不能降级,这是HotSpot的做法。虽然理论上存在GC周期里的偏向锁批量撤销,但路线仍然是“升上去就回不来”。
3.2 偏向锁与轻量级锁的触发条件
偏向锁的假设是“一个锁在大多数场景下只被同一个线程反复获取”。当线程第一次拿到锁,会在Mark Word里记录线程ID,之后这个线程再次尝试获取锁时只需要比对ID是否一致,一致就无需CAS操作,直接拿到锁。这个设计非常巧妙地迎合了绝大多数单线程访问同一资源的场景。
轻量级锁则应对“锁的竞争较轻”的情况。当第二个线程尝试获取偏向锁,发现偏向的不是自己,就会触发偏向锁撤销,升级到轻量级锁。轻量级锁获取锁的过程是线程在自己的栈帧里创建锁记录,然后通过CAS操作尝试将Mark Word更新为指向自己锁记录的指针。如果CAS成功,拥有锁;如果失败,说明存在竞争,会尝试自旋等待,同时锁可能继续升级。
这里需要强调一个常见误区:轻量级锁并不总是比重量级锁好。自旋会消耗CPU,如果锁竞争激烈且持有时间长,大量线程空转,反而拖垮整体性能。所以JVM会根据情况自动升级到重量级锁,由操作系统管里线程阻塞唤醒,虽然切换成本高,但不再消耗CPU自旋。
3.3 锁消除与锁粗化:不太常见但值得一答
锁升级之外,面试官偶尔也会问JVM有没有做其他锁优化。两个优化点必须知道。
第一个是锁消除。JIT编译器在运行时能分析出某些sychronized代码块实际不存在共享数据竞争,就会把锁直接去掉,典型场景是局部变量加锁。
public String concat(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb.toString(); }StringBuffer的方法加了synchronized,但sb是局部变量,不会被其他线程共享。JIT通过逃逸分析发现sb没有逃出当前线程,直接消除锁。这种现象你写代码时不会感觉到,但了解它能帮你理解“加锁不一定真的有竞争”。
第二个是锁粗化。如果JVM发现你连续对同一个对象进行加锁、解锁、再加锁,它会把锁的范围扩大,把这些加锁解放操作合并成一个更大的临界区,减少重复获取锁的开销。从代码语义上说这没问题,但确实提醒了一点:真正的开发里别把synchronized写得太碎,尤其是循环体内的加锁,既难读又难优化。
3.4 synchronized与ReentrantLock的选择逻辑
既然聊到锁,面试官常常顺带让你对比synchronized和ReentrantLock。这个对比题从JDK 1.6之后变得微妙,因为两者性能差距已经不大。需要抓住的核心点是:
synchronized是JVM层面的关键字,锁的获取和释放由字节码指令自动管理,出了异常也会自动释放锁;ReentrantLock是JDK提供的API级别锁,必须手动lock()和unlock(),且确保finally里释放。ReentrantLock具备高级功能:可中断、可设置公平性、支持多个条件队列(Condition)、支持tryLock()非阻塞获取锁。这些是synchronized没有的。- 锁的灵活性:
ReentrantLock可以在不同方法中获取锁、在另一个方法中释放锁,当然实际工程要尽量避免这种写法。 - JDK层面的演化:目前
synchronized已经引入偏向锁、轻量级锁,并且随着JDK版本的迭代在持续优化,很多场景下优先使用synchronized反而更简洁不易错。
我的建议是:能用synchronized解决的并发控制就别整花活,只有需要超时抢锁、公平锁、多条件队列等明确诉求时再考虑ReentrantLock。面试时把这个选型逻辑说清楚,比单纯背差异表更能体现工程判断力。
4. AQS与JUC并发工具:面试高分区必须讲清的两件事
到了JUC这一块,面试难度明显上升。像ReentrantLock、CountDownLatch、Semaphore这些工具类跟AQS紧密相关,面试官最喜欢的问法是:“你知道AQS吗?ReentrantLock和CountDownLatch底层的同步机制是什么?”
4.1 AQS的核心结构:state与CLH队列
AQS全称AbstractQueuedSynchronizer,是JUC并发工具的基础框架。它内部维护了一个volatile int state变量和一个双向链表队列(通常说是CLH锁的一个变种实现)。
state在不同工具类里有不同含义。在ReentrantLock里,state代表锁被重入的次数;在Semaphore里,它代表剩余的信号量许可;在CountDownLatch里,它代表还没到达的计数个数。state是工具类的“状态核心”,配合内部方法compareAndSetState完成原子操作。
CLH队列则用来存放获取锁失败的线程。线程抢锁失败,会被封装成节点插入队列尾部,然后阻塞;当持有锁的线程释放锁,会从头唤醒队列里等待的线程。这个“抢不到就排队”的机制是所有锁同步工具的基础。
理解AQS有一个比较形象的类比:医院排队挂号。state是剩余的号源,拿号成功的人直接去办业务,拿号失败的人按顺序排队,前面的人办完了叫下一个。整个队列的维护与管理由AQS统一完成,工具类只需要决定什么时候加减state。
4.2 ReentrantLock公平锁与非公平锁的实现差异
ReentrantLock是AQS最典型的实际应用。它的构造方法可以指定是否公平,默认是非公平锁。
非公平锁抢锁时,线程会先尝试直接CAS修改state,成功就直接拿到锁,完全不管队列里有没有人在排队。只有CAS失败,才会走AQS的入队流程。这就产生了“插队”现象。
公平锁则不一样,它获取锁之前先看等待队列里是否有其他节点在排队,有就必须排队,不允许插队。
面试时经常追问:“非公平锁既然有插队问题,为什么默认还是它?”关键在性能。非公平锁的线程1释放锁,线程2刚好来抢,如果此时让线程2直接拿到锁,就能减少一次线程唤醒切换;但如果强制公平,就一定要唤醒队列头部的线程。唤醒的开销远大于一次CAS。高并发下非公平锁的整体吞吐率通常更高,代价是线程饥饿的理论风险。这是一个典型的“性能优先、公平性让路”的取舍。
4.3 CountDownLatch、CyclicBarrier、Semaphore的精确场景
这三个工具是JUC里最高频的工具类考题,但很多人在场景区分上张冠李戴。
CountDownLatch:一个或多个线程等待其他线程完成一系列操作。典型的倒计时门闩,计数递减到0之后门被打开。注意只能用一次,计数到0后不可重用。CyclicBarrier:一组线程互相等待,直到达到一个公共屏障点。它可以循环使用,所有线程到达屏障后自动放行,适合“分阶段并行处理”的场景。Semaphore:控制同时访问某个资源的线程数量。初始化时设定许可证总数,每个线程获取时需要acquire(),用完要release()归还。
我整理了一个场景对照表,方便记忆:
| 工具 | 核心语义 | 是否可复用 | 典型场景 |
|---|---|---|---|
| CountDownLatch | 等待N个事件完成 | 否 | 主线程等待多个子任务拉取数据完成后汇总 |
| CyclicBarrier | N个线程互相等待齐 | 是 | 分批并行计算,每批都等齐后汇总 |
| Semaphore | 限制并发数量 | 是 | 数据库连接池、接口限流 |
注意CountDownLatch和CyclicBarrier最容易混淆。核心区别是方向:前者是“等人干完活”,后者是“大家一起到齐一起走”。面试时举一个自己项目里的实际例子比背定义更有说服力。
4.4 ConcurrentHashMap的锁粒度演进
提到并发容器,面试官十有八九会问ConcurrentHashMap。这是一个可以聊十分钟的大题目,但你需要先把版本差异答透。
JDK 1.7的ConcurrentHashMap采用Segment数组加HashEntry链表的结构。Segment继承自ReentrantLock,默认有16个Segment,每次操作只需要锁定对应的一个Segment,不同Segment之间可以并发读写。这就是分段锁设计,理论上并发度是16。
JDK 1.8抛弃了Segment,直接用Node数组加链表/红黑树,并发控制采用synchronized加CAS。写入数据时,如果对应桶位为空,则用CAS直接插入;如果桶位不为空,则对链表的头节点加synchronized锁。锁粒度从Segment细化到了单个数组桶,并发度大大提升。这里有个小细节:JDK 1.8里synchronized之所以性能不虚,是因为经过锁升级优化后,大部分场景锁都停留在轻量级甚至偏向级。
从1.7到1.8的变化本质是:锁粒度越来越细,并发能力越来越强。这个演进逻辑面试官通常很感兴趣,能讲清楚说明你对并发设计有思考。
5. 线程池“连珠炮”:核心参数、执行流程与拒绝策略
线程池是Java并发面试的另一个重灾区。这个问题几乎是必问的,而且面试官往往会从核心参数一路追问到拒绝策略,再到“怎么设置线程数”。
5.1 七大核心参数逐个拆解
线程池的核心构造参数有七个:核心线程数corePoolSize、最大线程数maximumPoolSize、空闲线程存活时间keepAliveTime、存活时间单位unit、任务队列workQueue、线程工厂threadFactory、拒绝策略handler。
这里最容易被忽视的是corePoolSize和maximumPoolSize的关系。核心线程会一直存活,不会因为空闲被回收,除非设置了allowCoreThreadTimeOut(true);非核心线程超过keepAliveTime就会被回收。线程数的扩张只在任务队列满后才发生,这是一个特别反直觉的流程。
threadFactory这个参数容易被跳过,但它很重要。统一设置线程名前缀,对排查线上问题有巨大帮助。你想象一下,线程名全是“pool-1-thread-1”,日志追踪的时候完全分不清哪个池出了状况;自定义ThreadFactory把前缀改成“order-async-pool-thread-”,问题定位效率能提高不少。
5.2 执行流程:任务进来后走的每一步
画不出流程图没关系,但要能在脑子里把每一步落清楚:
- 提交任务后,先判断核心线程是否已满。如果当前线程数小于
corePoolSize,直接创建核心线程执行任务。 - 核心线程已满,任务进入阻塞队列
workQueue排队等待。 - 队列已满,再看看线程数是否小于
maximumPoolSize。小于则创建非核心线程执行任务。 - 线程数已经达到
maximumPoolSize,队列也满了,只能走拒绝策略。
一个很容易犯的错是认为“先创建线程到最大再进队列”,这是把流程搞反了。线程池的设计初衷是优先用队列缓冲任务,而不是无限建线程,因为线程创建和切换的代价远大于任务排队。
5.3 四种拒绝策略与什么场景选哪种
当线程池已经饱和,RejectedExecutionHandler登场,JDK自带四种实现:
AbortPolicy:直接抛RejectedExecutionException,默认策略。能让系统快速暴露压力。CallerRunsPolicy:让提交任务的线程自己执行该任务。等于把部分压力反馈给生产者,起到天然节流作用。DiscardPolicy:直接丢弃任务,不抛异常。适合允许丢消息的日志类场景。DiscardOldestPolicy:丢弃队列中最老的任务,腾出空间给新任务。
我自己的项目经验是:核心链路尽量不要用DiscardPolicy,数据丢了还没感知;对不那么重要的日志、统计类任务可以用CallerRunsPolicy或DiscardOldestPolicy,但要配套监控告警。
5.4 为什么不建议使用Executors
这道题这几年已经问成热门趋势了。Executors的工厂方法确实很方便,但隐患明显。
newFixedThreadPool用的LinkedBlockingQueue是无界队列,任务不断积压最终会导致内存耗尽。newCachedThreadPool最大线程数是Integer.MAX_VALUE,创建线程没有上限,请求一多直接打爆CPU和内存。newScheduledThreadPool也有类似的无界队列问题。
最稳妥的做法是用ThreadPoolExecutor显式指定参数,把队列大小、拒绝策略、线程名前缀都定下来。这点不是“炫技”,而是生产环境的自我保护。
5.5 线程数设置的工程估算
面试高频追问之一是“核心线程数怎么定”。这里标准思路是把任务分成两种类型:
- CPU密集型任务:线程数接近CPU核心数,一般设为
CPU核数 + 1。加1是为了避免极端缺页或暂停时CPU空闲。 - IO密集型任务:线程数应在CPU核数的基础上乘以一个大于1的系数。公式可以简化为
CPU核数 * (1 + 平均等待时间 / 平均计算时间)。
举个例子,一个任务平均计算0.2秒,IO等待0.8秒,IO时间占比80%,四核机器上线程数估算为4 * (1 + 0.8/0.2) = 20。这个公式不是精确答案,但提供了一个合理的起点,线上再根据QPS、响应时间、CPU利用率逐步调整。
6. 面试官连环追问中的高频陷阱:死锁、ThreadLocal与并发容器细节
到这里,常规八股基本覆盖完了,但面试官通常还留着几个“杀手锏”在最后,专门用来淘汰那些只会背题的人。这三块内容如果答好了,面试评价会往上调一档。
6.1 死锁的四个必要条件与jstack定位
死锁问题是并发编程最经典的实战题。死锁发生的四个必要条件:互斥条件、请求与保持条件、不可剥夺条件、循环等待条件。四者缺一不可,所以破坏任何一条都能避免死锁。
面试时不能只说定义,得举代码例子,然后演示排查方法。写两个线程分别持有锁A等锁B、持有锁B等锁A的典型场景,然后实际演示jstack排查过程。
有次帮同事排查问题,线上告警“任务处理线程全部阻塞”,我直接在服务器执行了jstack <pid> > threaddump.txt,打开文件搜“deadlock”关键字,立刻看到:
Found one Java-level deadlock: "Thread-1": waiting to lock monitor 0x00007f1e1800b800 (object 0x00000007bfd9c2b8, a java.lang.String), which is held by "Thread-0"定位到具体锁对象和线程名之后,顺着代码一找就发现是加锁顺序不一致导致的循环等待。这类排查经验在面试里讲出来,比任何名词解释都更有说服力。
6.2 ThreadLocal原理与内存泄漏的真相
ThreadLocal在面试中出现频率极高,核心考点是两个:它怎么做到线程隔离的,以及为什么会有内存泄漏。
ThreadLocal的机制说起来并不复杂:每个线程内部有一个ThreadLocalMap,Map的key是ThreadLocal对象,value是你放进去的值。每个线程只能访问自己的ThreadLocalMap,所以天然隔离,不需要锁。
但这里有个隐藏雷点:ThreadLocalMap的Entry继承了WeakReference,key是弱引用,value是强引用。什么意思?弱引用在GC时会被自动回收,但ThreadLocal对象key一旦被回收,value仍然被Entry引用链强持有。如果线程长期存活(比如线程池里的核心线程),value就永远无法被回收,造成内存泄漏。
正确姿势是:在使用完ThreadLocal之后,立即调用remove()清理。很多团队代码规范强制这条规则,不是为了好看,是切实踩过生产事故的坑。
6.3 并发容器与同步容器的对比:Vector、Hashtable的遗留问题
说到并发容器,面试官往往还会问一嘴同步容器和并发容器的区别。Vector、Hashtable这类同步容器对每个方法都加了synchronized,操作简单但在高并发下性能非常差,且复合操作(比如先判空再添加)仍需要额外加锁。
ConcurrentHashMap、CopyOnWriteArrayList则是在保证线程安全的基础上尽量缩小锁范围甚至无锁。CopyOnWriteArrayList读操作完全不加锁,写操作复制新数组,适合读多写少场景,代价是写操作内存开销大。
这里的考察逻辑其实是设计权衡:线程安全不是唯一目标,还要看并发负载特征,读多写少和写多读少选择的方向不一样。
6.4 为什么锁能重入:可重入性的底层实现
可重入性也是一个容易被问到但容易含糊的点。synchronized和ReentrantLock都支持重入。所谓重入,就是一个线程已经持有了锁对象,再次遇到需要同一把锁的代码时可以顺利进入。
synchronized的重入靠的是对象监视器里的计数器,重入一次计数加1,退出临界区时计数减1,减到0才释放真正的锁。ReentrantLock依赖AQS的state,重入一次state加1,释放一次state减1,到0才完全释放。
这一点对递归方法、同类不同方法间相互调用都很关键。如果锁本身不可重入,线程调用一个加锁方法,方法内部又调用了同一把锁保护的另一个方法,直接就把自己锁死了。这也是为什么synchronized能隐式重入而底层实现并不简单的原因。
写在最后的几点私货
内容写到这儿,123道题的骨架基本都被打散了重组了一遍。说实话,面试题这种东西,把它当题库背是最低效的学法。你如果把AQS的排队机制、synchronized的锁升级、线程池的执行流程、volatile的底层屏障这一整条线串起来,会发现大部分并发面试题都是同一批知识点在不同层次上的变体。
我个人的体会是:准备并发面试不要贪多,先把synchronized和volatile吃透,再看AQS源码,最后用线程池和JUC工具类去验证理解。这三步走完,基本能覆盖绝大多数Java并发岗位的能力面。最后如果你正卡在哪个问题上,可以留言把问题发出来,我挑典型的再单独拆一篇细讲。