ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java并发八股复习Day14:线程安全与锁机制全解析

Java并发八股复习Day14:线程安全与锁机制全解析

如果你也在准备Java后端的面试,应该对“八股”这个词不陌生。说白了,八股就是面试高频考核的技术点合集——从JVM、集合、并发、Spring到MySQL、Redis,每家公司问来问去就那么几套核心考核内容。但别小看这些“背多分”的东西,正因为考得密、问得深,八股准备得扎实不扎实,往往直接决定了技术面能不能聊到后面更深入的项目细节。

我最近正在坚持一份每天抽两小时整理Java技术八股的打卡计划,今天是Day14。前面两周的基础内容(集合源码、JVM内存区域、垃圾回收算法、类加载机制)已经把地基打得差不多了,所以从今天开始进入并发编程这个大部头。并发可以说是Java八股里知识密度最高、追问最深的一块,也是最考验“理解程度”的一块。

这篇文章把Day14的复习主线完整梳理一遍:线程安全的底层逻辑、锁机制的原理、AQS的设计思路、几道高频面试题的现场问答模拟,还有我实际踩过的坑。如果你是刚开始准备八股的朋友,这篇可以作为并发专题的第一站;如果你已经有基础,里面的一些避坑点可能会让你重新审视自己之前的理解。

1. Day14的内容设计:为什么并发编程放到这个阶段

1.1 前面13天的铺垫逻辑

我的打卡计划没有一上来就啃并发,而是先用了整整几天处理“集合与JVM”这些基础底座。原因很简单:并发里的很多概念,追根溯源都会落到底层基础设施上。volatile为什么能保证可见性?说到底和Java内存模型有关系;锁的膨胀机制为什么能优化性能?底层是对象头的Mark Word在配合。如果没有先弄明白JVM内存区域的划分、对象在堆里的布局,后面学并发大概率是囫囵吞枣。

Day1到Day5我主要过的是集合框架源码,重点看ArrayList和HashMap在JDK不同版本下的差异、扩容机制和线程安全问题。这个阶段让我意识到:HashMap在多线程环境下可能造成死循环或者丢数据,而这正是并发专题要解决的核心矛盾。Day6到Day10重点落在JVM:内存区域、对象创建过程、垃圾回收器、GC日志怎么看,每天都配合一个小实验记录验证。Day11到Day13开始接触类加载机制和反射,搞清楚了类是怎么从.class文件变成元数据、再变成堆里的实例。

到Day14这个节点,集合、JVM、类加载三个基础模块已经形成体系。这时再进并发,理解锁升级才有参照系。比如偏向锁的对象头到底长什么样,如果没看过对象内存布局,光听“Mark Word存线程ID”就是空中楼阁。所以我建议后面学并发的人,无论如何先花五天左右把JVM对象布局、堆内存模型过一遍,否则一定会有断层感。

1.2 并发在整个八股体系里的分量

如果给Java后端面试的知识点排个优先级,并发绝对能和JVM并列第一梯队。我自己见过不少简历项目很花哨的候选人,一旦问到synchronized锁升级、volatile和原子类的区别、AQS为什么用双向队列,立刻就露馅了——因为并发是唯一一个“看似简单、越问越深”的领域。

从面试官的角度想原因也很清楚:并发直接反映候选人对“多线程环境下程序是否可靠”的认知。线上系统出现线程安全问题的概率比教科书里的例子高得多。一个团队如果有人对锁、可见性、原子性的理解有偏差,线上事故就可能在某次发布后的深夜悄悄上演。所以Day14这篇不打算停留在背结论,而是要带着“为什么”去拆解。

另外从学习方法上说,并发也特别适合用“问题驱动”的方式复习。同一个知识点,面试官可以正着问、反着问、嵌套问。比如先问volatile是什么,再问它为什么不保证原子性,再追问原子类底层是怎么保证原子性的,一环扣一环。如果你只记结论而不理解链路,这种追问撑不过三轮。

2. 线程安全的底层逻辑:JMM与可见性

2.1 JMM到底在讲什么

JMM是并发八股里第一个绕不开的概念。它规范了在Java程序中,多线程环境下共享变量的读写行为。最常见的背法是“主内存+工作内存”模型:每个线程有自己的工作内存,线程对共享变量的操作先在工作内存中完成,再同步回主内存,所以会出现线程A改了变量但线程B看不到的情况。

这个说法没有错,但面试官往往接着追问一句:“这个模型和JVM实际的内存结构有什么关系?”这里要特别注意:JMM和JVM运行时内存区域是两回事。JMM里的“主内存”和“工作内存”是一种抽象层面的划分,不是堆、栈、方法区的物理复制。主内存可以类比成共享数据所在的堆,工作内存可以理解为CPU缓存和寄存器的抽象集合。理解了这一层,才算真正弄明白为什么现代CPU架构下会有缓存一致性问题,而JMM就是为了屏蔽不同硬件平台的差异,给Java程序提供统一的内存操作规则。

我在实测中的体会是,学习JMM最好配合一个可见性实验来加深印象。写一个共享boolean变量,子线程在循环中读取它,主线程把它改成true,在没有volatile修饰时,子线程很可能永远读不到最新值,程序一直空转。我在多核机器上实测过,这个现象在循环体很空的时候尤其容易复现,因为CPU缓存被大量命中,几乎没有机会去主内存刷新。做完这个实验,“共享变量在线程间不可见”这件事就再也忘不掉了。

2.2 volatile的完整语义

提到可见性,volatile是必然登场的关键字。我把它拆成三层来记:

第一层是可见性。volatile修饰的变量在写完之后,会立即对其它线程可见。它的内部逻辑是:写入时强制把当前线程工作内存中的值刷新到主内存,同时让其它线程中持有的该变量缓存失效,下次读取必须从主内存重新加载。这一点用上面的实验就能直观验证。

第二层是禁止指令重排序。这是volatile经常被忽略的半条命。JVM和CPU在编译期和执行期都可能为了性能对指令做重排,但如果一个字段被volatile修饰,它前后的读写操作会按照内存屏障的规则来约束顺序。最经典的例子就是单例模式的双重检查锁:instance在new的时候分了三步,分配内存、初始化对象、把引用赋给变量。如果没有volatile,某个线程可能拿到一个尚未初始化完成的对象引用,导致后续调用出现诡异行为。这一点在后面的代码复盘里专门展开。

第三层是volatile不保证原子性。这一点面试必考,而且非常容易踩坑。volatile能保证每次读写都从主内存拿最新值,但无法保证“读-改-写”这个复合操作的原子性。比如多个线程同时对一个volatile int执行i++,因为i++本身就是三步操作——读取i、加1、写回i——并不是原子的。volatile只保证了每一步的可见性,却没法阻止两个线程在中间交错执行,所以最终值可能小于期望值。想保证复合操作的原子性,应该用synchronized或原子类,比如AtomicInteger的CAS机制。我给别人讲这里时常用一个生活化类比:volatile相当于墙上的最新通知,任何人都能随时看到最新内容,但它没有规定“能不能同时去改”,改的过程依然需要锁来约束。

2.3 happens-before规则速查

volatile讲完之后,面试官很喜欢用happens-before来验证你对内存可见性整体框架的掌握。happens-before规则是判断两个操作是否对彼此可见的依据。重点记住几条常用的:

  • 程序顺序规则:单线程内,写在前的操作happens-before写在后的操作。
  • volatile变量规则:对一个volatile变量的写操作happens-before后续对这个变量的读操作。
  • 传递性:A happens-before B,B happens-before C,则A happens-before C。
  • 监视器锁规则:对一个锁的解锁happens-before后续对这个锁的加锁。
  • 线程启动规则:线程启动前主线程的操作对该线程可见。
  • 线程终止规则:线程终止后,该线程的所有操作对主线程可见。

我把这些规则看成“哪些情况下不需要额外加锁也能安全读取”的依据。很多并发问题排查到最后,就是看某个变量的读写有没有形成happens-before关系。比如你问“两个线程分别写两个不同的变量,能不能不加同步互相读取?”答案通常是不能,因为在没有任何同步关系的前提下,这两个写操作之间没有明确的先后关系,自然也没法保证可见性。

2.4 现场回答“synchronized和volatile的区别”

我被问到最多的开场题是“synchronized和volatile有什么区别?”这个问题看似基础,但回答得好的候选人真的不多。我的标准回答框架是:先说各自的核心机制,再说对比点,最后落到使用场景。

volatile是轻量级的同步机制,作用于变量级别,解决可见性和有序性,不解决原子性,不会阻塞线程。synchronized可以修饰方法或代码块,解决原子性、可见性、有序性,是互斥同步机制,会让线程阻塞和唤醒。使用方向上,volatile适合“一写多读”且状态不依赖旧值的场景,比如状态开关;synchronized适合需要多线程互斥访问临界区的场景。这样回答既有层次又不容易被带偏。面试官如果继续追问“都保证可见性,为什么不都用volatile”,你就能把原子性这点反复强调,形成完整闭环。

3. 锁机制深度拆解:synchronized与Lock体系

3.1 锁升级的完整链路

先抛一个常见误区:很多人以为synchronized天生就是重量级的,性能天生不好。实际上,HotSpot虚拟机经过大量优化之后,synchronized的获取过程是一套渐进的升级链路:无锁、偏向锁、轻量级锁、重量级锁。锁只能随着竞争激烈程度向上膨胀,不能降级。

无锁状态好理解,就是一个对象刚new出来,没人抢它。当第一个线程来访问同步块时,如果发现锁偏向模式开启,就会尝试用CAS把自己的线程ID写入对象头的Mark Word,这就是偏向锁。偏向锁的核心思想是:如果同一个线程反复获取这把锁,连CAS都不用再做了,直接判断线程ID是否一致即可,能极大减少无竞争时的开销。

一旦出现第二个线程来竞争,偏向锁就可能撤销并升级为轻量级锁。这个阶段线程不会直接阻塞,而是先在栈帧中创建锁记录,通过CAS把Mark Word替换成指向锁记录的指针。如果替换成功,说明拿到了锁;如果失败,说明存在竞争,会进行自旋等待,反复尝试获取。自旋的目的是避免线程状态切换带来的上下文切换开销,适合锁被持有的时间很短、竞争不激烈的场景。但如果自旋多次依然拿不到锁,锁就会膨胀为重量级锁,未抢到锁的线程进入Monitor监视器模型,被放到阻塞队列,涉及操作系统内核态的锁机制,这个开销明显增大。

回答这套流程时,建议强调一个结论:synchronized的性能已经被JVM优化过,选择它不能一概而论,而要看竞争激烈程度和临界区执行时间。面试官问你“偏向锁有没有坏处”时,能说出“偏向锁撤销需要STW”“如果竞争频繁,偏向锁反而增加额外开销”,会显得很内行。

3.2 ReentrantLock和synchronized的真实差异

第二个高频点是“ReentrantLock和synchronized选择哪个”。我见过好多只记得ReentrantLock功能更丰富、但说不出具体哪里丰富的候选人。我总结了一张实用对比表,面试时直接展开讲:

对比维度synchronizedReentrantLock
实现层面JVM关键字,基于monitorenter/monitorexit字节码指令JDK层基于AQS实现的类
公平性非公平支持公平和非公平
中断响应不支持支持lockInterruptibly
超时获取不支持支持tryLock带超时
条件队列单一监视器条件(wait/notify)可创建多个Condition
锁释放自动释放必须手动unlock

只看性能的话,在JDK 6之后两者在大多数竞争场景下差距已经不明显。我的实际选择习惯是:能用synchronized就用synchronized,只有当需要“尝试非阻塞获取锁、公平锁、可中断、多条件队列”这些场景时,才显式选用ReentrantLock。理由是自己写的代码越简单越不容易错。ReentrantLock必须手动unlock,一旦忘记在finally中释放,线上就可能出现锁长时间无人释放的隐患。

3.3 AQS到底是怎么设计的

讲到ReentrantLock,就绕不开AQS,这几乎是并发八股里最深的一层,也是最容易暴露水平的点。AQS全称是AbstractQueuedSynchronizer,可以理解成一个“同步器底层框架”,用一个volatile int类型的state来表示同步状态,配合一个FIFO双向队列来管理等待线程。

state字段的含义由子类自己定义。对ReentrantLock来说,0表示没有线程持有锁,大于0则表示某个线程重入的次数;对Semaphore来说,state表示剩余许可证数量;对CountDownLatch来说,state表示还需要等待的计数。

拿到锁的过程最核心的是CAS操作。AQS通过compareAndSetState方法尝试把state从0改为1,如果成功则独占锁,并把当前线程设置为独占线程。如果失败,当前线程会被包装成等待节点加入队列尾部。这里有个值得展开的细节:入队的操作本身也要保证线程安全,所以AQS里用了尾节点CAS,CAS失败就不断重试。释放锁的过程则是把state减到0,唤醒队列中的下一个线程。

公平锁和非公平锁的差别就在入队之前:非公平锁在线程进来后会先“插队”尝试一次CAS拿锁,如果正好锁被释放就直接成功;公平锁则严格判断队列中是否有前驱节点,如果有,哪怕锁刚好空闲也必须到队尾排队。我在学AQS时最受益的做法是:不要死记代码,而是把自己想象成排队办事的人。state就是服务窗口的状态,等待队列就是排队的人群,CAS就是“窗口空闲瞬间能不能抢到”的一次操作。这样整个流程很顺。

3.4 悲观锁、乐观锁与实际工程取舍

锁机制还绕不开悲观锁和乐观锁的对比。悲观锁假设并发冲突一定发生,所以每次操作都先加锁,典型代表是synchronized、ReentrantLock和数据库的行锁;乐观锁假设冲突很少发生,操作先执行,提交或更新时再校验有没有被别人改过,典型代表是CAS和版本号机制。

CAS的全称是Compare And Swap,执行时有三个参数:内存地址、期望的旧值、要写的新值。只有当内存地址的当前值和期望值相等时,才把值更新为新值,否则就失败。CPU层面这是一个原子指令,天然解决了“检查并更新”这个复合操作的原子性问题。

CAS不完美,典型的问题有三个:ABA问题、自旋消耗CPU、只能对一个共享变量保证原子性。ABA问题指的是某个变量从A变成B又变回A,另一个线程执行CAS时发现值还是A,会误以为没有人改过。解决思路是用带版本号的原子引用类。实际工程中,频繁CAS导致的自旋开销同样不可小觑。所以乐观锁不是万能解,它的前提是冲突概率低、重试成本可以接受;如果热点冲突非常高,乐观锁反而会导致大量失败重试,性能不如直接加锁。

4. 从理论到实战:模拟面试场景与代码复核

4.1 双重检查锁单例的完整复盘

并发专题最经典的代码题之一就是手写线程安全的单例模式。我强烈建议每个准备八股的人都把双重检查锁版本写到能脱手默写的程度,并且能在面试中把每一行改动的原因解释清楚。

先看最朴素的懒加载版:

public class Singleton { private static Singleton instance; public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }

这个版本线程安全,但锁粒度太大,每次调用都有加锁开销。于是有人改成先判断instance == null再进同步块,但这样如果两个线程同时通过初始判断,各自new出对象,返回的就不是同一个实例了。正确做法是双重判断:

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; } }

最关键的一点是instance必须用volatile修饰。原因是new对象不是原子操作,可能先分配内存空间,然后让引用指向这个空间,最后才执行构造函数初始化。如果instance没有volatile,在极端情况下,其它线程可能拿到一个“引用已经指向内存、但对象尚未完成初始化”的实例。加volatile之后,禁止了初始化和引用赋值之间的重排序,保证了拿到的对象一定已经完全构造完毕。

我在面试别人时会追问一句“可不可以用枚举实现单例”,如果候选人能答出枚举天然防止反射和序列化破坏,我会加分。单例模式话题看似简单,但可以一直深挖到类加载机制和并发语义的交叉点。

4.2 一道典型面试追问的完整走位

Day14的复习里,我给自己设计了一个面试官连环问脚本。第一问:“怎么让多个线程按固定顺序执行?”一看就是考线程协作工具。我会说可以用CountDownLatch或者CyclicBarrier,并解释区别。CountDownLatch是让一个或多个线程等待一组事件完成,计数只能减不能加,用完之后就作废;CyclicBarrier是让一组线程互相等待到齐后同时执行,计数可以循环使用,还支持一个可选的Runnable在到达屏障时先执行。

面试官继续追问:“如果其中某个线程在执行中抛异常了,这两个工具表现一样吗?”这个问题我栽过跟头。CountDownLatch在线程抛出异常时,只要代码里没有catch,countDown就没被调用,其它线程会一直阻塞下去,最终可能造成超时;CyclicBarrier在参与者线程抛出异常时会进入破坏状态,其它等待线程可能直接抛出BrokenBarrierException。所以使用这两个工具时都要配合超时机制兜底,避免线程池里默默死掉一个线程导致整个流程卡死。

再往深了问:“Semaphore和ReentrantLock的acquire/release和lock/unlock有什么区别?”这里要答清楚:Semaphore管理的是许可数,允许多个线程同时通过,更偏向限流;ReentrantLock是互斥的,同一时刻只有一个线程能进入。如果把Semaphore初始化为1,它确实能当互斥锁用,但语义上仍有区别,因为锁通常强调持有者身份,而Semaphore没有持有者概念,任何线程都可以释放许可。我在线上排障时还遇到过一种坑:在finally里调用release方法释放Semaphore,但前面的acquire可能抛了异常,finally只要执行就多释放一次许可,把许可数放多了,限流效果被悄悄打破。

4.3 并发工具类速查表

为了应对选择题式的面试,我把常用并发工具类做成一个速查表,每次复习都先刷新一遍:

工具类核心机制适用场景
ConcurrentHashMapCAS + 局部锁(或分段思想)高并发读写Map
CopyOnWriteArrayList写时复制,读无锁读多写少
ArrayBlockingQueue有界阻塞队列线程池任务队列
LinkedBlockingQueue可选有界或无界任务缓冲
CountDownLatch一次性计数减到0放行等待多个任务完成
CyclicBarrier循环屏障,等待到齐多线程分阶段任务
Semaphore许可数控制限流、控制并发数
AtomicIntegerCAS自旋原子计数
LongAdder分段累加超高并发计数

速查表里我还会加一列“面试官爱挖的坑”,比如ConcurrentHashMap在JDK 8之后用CAS + synchronized来保证put线程安全,扩容时支持多线程协助迁移;LinkedBlockingQueue如果无界,任务太多时队列会无限膨胀,内存可能被耗尽。这些细节是区分内行和外行的分界线,值得反复打磨。

4.4 手写一个简单的生产者消费者模型

顺手分享一个我每次复习都会重写一遍的例子,生产者消费者模型。它能串起线程通信、锁、阻塞队列等多个知识点。最推荐的版本直接用阻塞队列:

public class ProducerConsumerDemo { private static final int CAPACITY = 10; private static final BlockingQueue<Integer> QUEUE = new ArrayBlockingQueue<>(CAPACITY); static class Producer implements Runnable { @Override public void run() { for (int i = 0; i < 100; i++) { try { QUEUE.put(i); System.out.println(Thread.currentThread().getName() + " produced: " + i); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } static class Consumer implements Runnable { @Override public void run() { while (true) { try { int value = QUEUE.take(); System.out.println(Thread.currentThread().getName() + " consumed: " + value); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } } public static void main(String[] args) { new Thread(new Producer(), "producer1").start(); new Thread(new Consumer(), "consumer1").start(); new Thread(new Consumer(), "consumer2").start(); } }

这个例子看起来简单,但把put和take的阻塞语义、队列边界、线程中断恢复正常运行的细节都包含了。我之前写的时候犯过一个错误:catch到InterruptedException后就什么都不做,导致线程池里的线程中断标志被吃掉,后面的任务调度出现奇怪问题。所以这里要强调,捕获中断异常后一定要重新设置中断标志,或者根据场景直接退出。

5. 常见问题与避坑点实录

5.1 volatile与复合操作的那点事

就算理解了volatile语义,实际代码里还是容易犯错。最常见的错误是拿volatile去修饰一个计数器,然后多个线程直接i++,最后统计出来的总和不对。有人问:“都volatile了为什么还会错?”因为volatile只保证每次读是最新值,不保证读与写之间的连续操作不被其它线程打断。

我建议遇到这类统计需求先问三个问题:是否需要强一致?如果不是强一致,能否接受近似结果?如果是强一致,能不能用AtomicLong或LongAdder。LongAdder在并发量很高的场景下因为分段计数,吞吐量比AtomicLong好,代价是计数精度有轻微模糊,适合不需要精确总和的统计场景。我在压测里面跑过,8个线程同时累加一百万次,LongAdder优势确实明显。但业务中如果对总和要求精确,就不要为了性能牺牲正确性。

5.2 生产环境死锁怎么排查

死锁是八股必须会答的问题,但更重要的是会排查。死锁发生的条件四缺一不可:互斥、持有并等待、不可剥夺、循环等待。我遇到过的最典型场景是两个线程分别持有一个锁资源,然后又互相申请对方的资源,结果双双等待,表现为线程池任务卡死、接口超时。

在JVM层面,JDK自带的jstack工具是排障首选。线上服务出现卡顿或者线程池满时,我会先拿到进程号,执行jstack输出线程堆栈,重点搜索死锁相关关键信息。如果发现线程状态大量集中在BLOCKED或者WAITING,并且存在两个线程各自持有对方需要的锁,那基本可以确认死锁了。排查时还要注意:不要急着重启服务,先保留现场,dump下来的堆栈是后续复盘的重要依据。

工程上预防死锁比解决死锁更值得花时间。我给自己定的规则是:尽量降低锁的持有时间,避免在持锁期间去获取其它锁;如果多个锁必须嵌套,确保所有线程按同一顺序加锁。数据库层面则可以选择合适的事务隔离级别、缩小事务范围、利用索引避免行锁升级,这些都是从源头降低死锁概率的思路。

5.3 一道容易被忽视的“可见性”陷阱

再补充一个我实际写代码时遇到的坑。有一个状态开关类:

public class Switch { private boolean enabled; public void enable() { enabled = true; } public boolean isEnabled() { return enabled; } }

主线程调用enable(),工作线程不断检查isEnabled(),在没有volatile的情况下,工作线程可能永远看不到状态变化。这个坑比计数器更隐蔽,因为这里根本没有“复合操作”,它的确只是一个简单的变量赋值和读取,但依然存在可见性问题。我的解决办法是把enabled声明为volatile,或者对enable方法和isEnabled方法都加synchronized。从这个案例可以总结出一个结论:只要变量会被多个线程读写,且你不依赖锁来建立happens-before关系,就应该考虑volatile。

5.4 八股背诵和理解的平衡

最后聊点自己的体会。并发相关的八股,纯背非常容易背串,因为名词太多、层级太深。我的个人看法是,八股不能只记答案,至少要能复现一两个演示实验,哪怕只是写个几十行的Demo跑一遍。可见性问题跑一遍、死锁问题跑一遍、超时获取锁跑一遍,这些实验的试错成本都不高,但对记忆的牢固程度帮助极大。

我还会在每天学习结束时,把当天的问题列成清单,自己充当面试官,大声自问自答一遍。很多卡壳的地方,往往不是没背过,而是组织语言不熟练。多练习几轮之后,临场回答的流利度和自信度会明显不同。

Day14的并发专题只是入门,后面我打算继续深入线程池的参数组合和实际调优,再往后还会把并发和项目里遇到的真实问题结合起来复盘。八股这东西,单纯背容易忘,带着实验和场景去记才稳。今天这篇记录就到这里,希望对正在啃并发八股的你有帮助。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进