一、前置:通用锁策略基础
锁策略不是 Java 独有,是所有并发编程的通用概念。synchronized内部的锁升级,本质就是在不同场景下自动切换这些策略。
1.1 乐观锁 vs 悲观锁
- 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
- 乐观锁:假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
1.2 重量级锁 vs 轻量级锁
锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的.
- CPU 提供了 "原子操作指令".
- 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
- JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类
注意, synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其
他的工作
- 重量级锁:加锁机制重度依赖了 OS 提供了 mutex。
- 轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex。
理解用户态 vs 内核态
想象去银行办业务。
在窗口外, 自己做, 这是用户态。用户态的时间成本是比较可控的。
在窗口内, 工作人员做, 这是内核态。内核态的时间成本是不太可控的。
如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的。
1.3 自旋锁 vs 挂起等待锁
- 自旋锁:抢锁失败不挂起,循环不断重试抢锁,锁释放后第一时间就能拿到。 优点:不放弃 CPU,避免线程调度开销;缺点:锁持有时间长时,空转浪费 CPU。
- 挂起等待锁:抢锁失败直接放弃 CPU 被挂起,等锁释放后再被唤醒。 优点:不消耗 CPU;缺点:线程唤醒、调度开销大。
1.4 公平锁 vs 非公平锁
- 公平锁:严格先来后到,先排队的线程先拿锁,不会出现线程饥饿。
- 非公平锁:锁释放后,新线程和排队线程一起抢,不保证顺序。 优点:吞吐量更高;缺点:极端场景下部分线程长期抢不到锁。
synchronized是非公平锁;ReentrantLock默认非公平,可手动开启公平模式。
1.5 可重入锁 vs 不可重入锁
- 可重入锁:同一个线程可以多次获取同一把锁,不会自己把自己锁死。 内部维护计数器:加锁 + 1,解锁 - 1,归零才真正释放锁。
- 不可重入锁:同一个线程第二次获取同一把锁会直接阻塞,造成死锁。
synchronized、ReentrantLock都是可重入锁。
二、CAS:乐观锁的核心实现
偏向锁、轻量级锁的底层,都依赖 CAS 硬件指令。
2.1 CAS 是什么
CAS 全称Compare And Swap(比较并交换),是一条 CPU 硬件原子指令,全程原子执行,不需要操作系统加锁。
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1.比较A与V是否相等。(比较)2. 如果比较相等,将B写入V。(交换)3. 返回操作是否成功。
CAS 伪代码(假代码,不能编译运行,表示逻辑)
下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解 CAS的工作流程.
boolean CAS(address, expectValue, swapValue) { if (&address == expectedValue) { &address = swapValue; return true; } return false; }举例:
- 比较:读取内存中
&count的值,当前是 0,和预期旧值 0 相等,说明期间没有被其他线程修改。 - 交换:把内存中的值更新为 1。
- 返回:操作成功,返回
true。
2.2 CAS 是怎么实现的
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:
• java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
• unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
• Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子
性。
简而言之,是因为硬件予以了支持,软件层面才能做到。
java.util.concurrent.atomic包下的原子类,全部基于 CAS 实现,最常用的是AtomicInteger。
2.3 典型应用
01 原子类
package thread; import java.util.concurrent.atomic.AtomicInteger; public class AtomicDemo { private static AtomicInteger count = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { // 相当于 i++,底层 CAS 自旋重试 count.getAndIncrement(); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count.getAndIncrement(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("count = " + count.get()); // 结果恒为 100000 } }02 实现自旋锁
基于 CAS 实现更灵活的锁, 获取到更多的控制权
伪代码:
public class SpinLock { private Thread owner = null; public void lock(){ // 通过 CAS 看当前锁是否被某个线程持有. // 如果这个锁已经被别的线程持有, 那么就自旋等待. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. while(!CAS(this.owner, null, Thread.currentThread())){ } } public void unlock (){ this.owner = null; } }2.4 CAS 的 ABA 问题
CAS 的核心逻辑是:比较「内存当前值」和「我预期的旧值」是否相等,相等就认为没被改过,执行更新。 ABA 问题就出在这里:值看起来没变,但中间其实被修改过,最后又改回了原值,CAS 无法感知这个变化过程。
完整流程拆解:
- 线程 T1 读取共享变量,得到当前值 A,记录为自己的预期旧值;
- T1 执行业务逻辑,准备通过 CAS 将 A 更新为目标新值;
- 在此期间,线程 T2 介入,先将变量从 A 修改为 B,经过一系列操作后,又把变量改回了 A;
- T1 最终执行 CAS 时,发现内存当前值仍然是 A,误以为数据从未被改动,更新成功。
从 CAS 指令的视角看,操作完全合法且成功;但从业务视角看,数据已经经历了一轮「改出又改回」的过程,中间的所有变化都被彻底掩盖了。
🧩 通俗类比:翻新手机 你收到一台手机,外观、系统显示都是全新状态 A。你以为它是从未拆封的新机,但其实它被人使用过一段时间(状态 B),又被商家翻新回了全新外观(状态 A)。只看表面值你完全分辨不出来,但它中间已经被改动过。
2.4.2 ABA 一定会引发故障吗
并不是所有场景下 ABA 都会导致业务错误,需要区分场景对待:
- 无影响场景:如果业务只关心「最终值」,不关心数据的变化过程,比如简单的计数器自增,ABA 问题不会对结果造成任何影响,无需额外处理。
- 高风险场景:如果业务依赖数据的「状态流转过程」,比如金额变动、订单状态切换、无锁链表 / 节点操作,ABA 可能会引发严重的逻辑 BUG。
2.4.3 经典业务案例:ATM 重复扣款
我们以最经典的 ATM 取款场景为例,直观感受 ABA 问题带来的实际业务损失:
- 初始条件:用户账户余额 100 元,取款机出现并发异常,同时发起两个扣款 50 元的线程;
- 预期结果:一个线程扣款成功,另一个线程扣款失败,最终余额 50 元。
正常流程(无 ABA 干扰)
- 线程 1、线程 2 都读取到余额 100 元,作为各自的预期旧值;
- 线程 1 先执行 CAS 扣款成功,余额更新为 50 元;
- 线程 2 再执行 CAS,发现当前余额 50 ≠ 预期 100,扣款失败。
- 结果正确,最终余额 50 元。
异常流程(ABA 导致重复扣款)
- 线程 1、线程 2 都读取到余额 100 元;
- 线程 1 先执行 CAS 扣款成功,余额变为 50 元;此时线程 2 因 CPU 调度延迟,还未执行 CAS;
- 中途有第三方向账户转账 50 元,余额从 50 元又变回了 100 元,完成了「100 → 50 → 100」的 ABA 循环;
- 线程 2 终于执行 CAS,发现当前余额 100 与自己的预期旧值一致,扣款成功,余额再次变为 50 元。
最终结果:用户原本只有 100 元,经历一次取款、一次等额转账后,余额只剩下 50 元,平白损失了 50 元。问题的根源就在于,CAS 只校验了金额数值,没有校验「金额是否被修改过」。
2.4.4 标准解决方案:版本号机制
解决 ABA 问题的核心思路非常朴素:不只比较值,还要比较数据的版本号。
具体规则:
- 给每个共享数据搭配一个版本号,每次修改数据时,版本号自动 +1;
- 执行 CAS 时,必须同时满足「值相等 + 版本号相等」两个条件,才允许更新;
- 哪怕值完全相同,只要版本号变大了,就说明数据被修改过,操作直接失败。
对应到上面的取款场景:
- 初始:余额 100,版本号 1
- 线程 1 扣款成功:余额 50,版本号 2
- 转账入账:余额 100,版本号 3
- 线程 2 用旧版本号 1 执行 CAS,版本不匹配,扣款失败
三、synchronized基本特点
synchronized是Java 内置的互斥同步关键字,属于 JVM 层面实现的锁,用来解决多线程并发访问共享资源的线程安全问题;它可以保证同一时刻只有一个线程执行被锁住的代码,保证共享变量操作的原子性、可见性、有序性。
- 锁的是对象,不是代码
- 自动加锁、自动释放锁
3.1 原子性
被 synchronized 包裹的代码是一个不可分割的整体,要么全部执行完,要么不执行;同一时刻只有一个线程能进入同步块,保证共享变量操作不可分割。
例:
i++本身不是原子操作,加上 synchronized 之后,整个自增变成原子操作。
3.2 可见性
JVM 内存语义规定:
- 解锁时:把线程工作内存的修改全部刷新回主内存
- 加锁时:清空工作内存缓存,强制从主内存读取最新值 因此 synchronized 天然保证可见性,不需要额外搭配 volatile。
3.3 有序性
禁止指令重排序。编译器、CPU 不会随意打乱同步代码块内部代码的执行顺序,保证多线程下逻辑符合预期。
3.4 互斥性(排他性)
同一把锁,同一时间只能有一个线程拿到并进入同步代码;其他竞争线程会阻塞等待,这是锁最核心的能力。
3.5 可重入性
同一个线程,已经拿到锁之后,可以再次获取同一把锁,不会自己把自己阻塞死。
package thread; public class ReentrantDemo { // 锁对象:this public synchronized void methodA(){ System.out.println("进入方法A,当前线程"+Thread.currentThread().getName()); // 在A里面调用同样加了synchronized的方法B methodB(); System.out.println("退出方法A,当前线程"+Thread.currentThread().getName()); } public synchronized void methodB(){ System.out.println("进入方法B,当前线程"+Thread.currentThread().getName()); System.out.println("退出方法B,当前线程"+Thread.currentThread().getName()); } public static void main(String[] args) { ReentrantDemo demo = new ReentrantDemo(); new Thread(demo::methodA, "线程1").start(); } }运行结果:
进入方法A,当前线程线程1 进入方法B,当前线程线程1 退出方法B,当前线程线程1 退出方法A,当前线程线程1反推:如果 synchronized 不可重入,线程 1 拿到 this 锁进入 methodA,调用 methodB 时会再次尝试抢 this 锁,自己阻塞自己,直接死锁。
底层原理:锁对象内部维护「持有线程标识 + 锁计数器」,每重入一次计数器 + 1,退出同步代码计数器 - 1,计数器归零才真正释放锁。
四、synchronized 的三种使用方式
核心一句话:锁是加在对象上的,不是加在代码上,所有用法本质都是锁定某个对象。
4.1 修饰实例方法|锁对象:当前实例 this
public synchronized void test(){ // 代码 }- 锁:this,当前对象实例
- 规则:同一个对象调用该方法互斥;不同对象调用,互不影响。
4.2 修饰静态方法|锁对象:类的 Class 对象
public static synchronized void test2(){ // 代码 }- 锁:当前类的 Class 对象(全局唯一)
- 规则:所有该类的实例共用同一把锁,只要调用静态同步方法就会互斥。
⚠️ 高频面试坑:实例同步方法(锁 this)和静态同步方法(锁 class)是两把不同的锁,互不干扰,不会互斥。
4.3 修饰同步代码块|手动指定锁对象(最灵活)
synchronized (锁对象){ // 代码 }锁对象可以是:任意 Object 对象、this、类名.class。 推荐写法:只把需要保护的共享操作包进去,缩小锁范围,提高并发效率。
记忆口诀:实例方法锁 this,静态方法锁 class,代码块锁你括号里写的对象。
五、synchronized 底层锁升级机制
JDK1.6 对 synchronized 做了重大优化,锁会根据竞争程度单向升级,只能升不能降,全程状态记录在对象头 Mark Word 中。
升级路径:无锁 → 偏向锁 → 轻量级锁(自适应自旋) → 重量级锁
5.1 偏向锁(无竞争场景,乐观锁)
- 适用:只有一个线程反复获取锁,几乎没有竞争
- 原理:第一个加锁的线程,用 CAS 把自己的线程 ID 写入 Mark Word;后续该线程再次进入同步块,只对比线程 ID,不需要 CAS,开销极低。
- 触发升级:出现第二个线程竞争锁时,偏向锁撤销,升级为轻量级锁。
- 本质:延迟加锁,无竞争就不做真正的加锁操作。
5.2 轻量级锁(少量竞争,自旋 CAS,乐观锁)
- 适用:多线程交替使用锁,竞争少、锁持有时间短
- 原理:线程在栈帧创建锁记录,用 CAS 尝试把对象头指向自己的锁记录;CAS 失败就进入自适应自旋,循环重试抢锁。
- 触发升级:自旋多次仍失败、多个线程同时竞争 → 膨胀为重量级锁。
- 优点:用户态操作,线程不挂起,开销小;缺点:锁持有时间长时,自旋空耗 CPU。
5.3 重量级锁(大量竞争,悲观锁)
- 适用:竞争激烈、锁持有时间长
- 原理:底层依赖操作系统
mutex互斥锁;抢锁失败的线程不再自旋,直接被操作系统挂起,进入阻塞队列,让出 CPU;锁释放后唤醒等待线程。 - 优点:不消耗 CPU 自旋;缺点:用户态内核态切换、线程调度开销大。
5.4 JVM 额外优化
- 锁消除:逃逸分析发现锁对象只在单线程内使用,不存在并发,直接把加锁代码删掉。
- 锁粗化:连续多次加锁解锁(比如循环里加锁),JVM 自动合并成一次加锁一次解锁,减少频繁操作开销。
六、全文总结
6.1 synchronized 锁策略全景对应
结合开篇讲的通用锁策略,回头再看 synchronized,它是一套自适应的锁方案,不同阶段对应不同锁策略:
乐观锁 → 悲观锁:开始默认低冲突,采用乐观锁策略;锁冲突频繁后,自动切换为悲观锁。
轻量级锁 → 重量级锁:初始是轻量级锁;锁持有时间变长、竞争加剧,单向膨胀为重量级锁。
轻量级锁基于自旋锁:轻量级锁阶段采用自适应自旋策略,抢锁失败先循环重试,不阻塞线程。
非公平锁:不保证先来后到,锁释放后新线程和排队线程一起竞争,吞吐量更高。
可重入锁:同一个线程可重复获取同一把锁,靠计数器实现,不会自己锁死自己。
普通独占互斥锁,不是读写锁:不区分读写场景,所有操作都互斥;读多写少场景推荐使用专门的读写锁。
6.2 核心结论
- 三大并发特性:原子性、可见性、有序性;附加特性:互斥、可重入、自动释放锁。
- 三种使用方式:实例方法锁 this、静态方法锁 class、同步代码块锁指定对象。
- 锁升级单向不可逆:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,低竞争靠 CAS 优化,高竞争切内核态互斥锁。
- 选型建议:简单同步优先用
synchronized;需要超时、公平锁、精准唤醒选ReentrantLock;简单计数优先用 CAS 原子类。