Javasynchronized 原理拆解:锁升级链路 + JVM 优化 + CAS 与 ABA 问题(完整整合版)

synchronized 原理拆解:锁升级链路 + JVM 优化 + CAS 与 ABA 问题

来看 synchronized 原理这一节,整体讲解方式非常"策略化":不把它当成一把固定形态的锁,而是当成一套会根据竞争强度与持锁时长自动切换的组合策略(以 JDK 1.8 为讨论范围)。


1)基本特点:synchronized 其实是多种锁策略的拼装

这一节先把特性列得很明确(只考虑 JDK 1.8):

  1. 开始是乐观锁 ;如果发现锁冲突频繁,会切换成悲观锁
  2. 开始是轻量级锁实现 ;如果锁被持有时间较长,会转换成重量级锁
  3. 实现轻量级锁时,大概率会用到自旋锁策略
  4. 非公平锁(不保证先来后到)。
  5. 可重入锁(同一线程重复进入不会把自己锁死)。
  6. 不是读写锁

把这 6 条理解成一句话:先用成本低的方式试图"快进快出",不行再逐步升级成更强、更稳但更贵的方式


2)加锁工作过程:无锁 → 偏向锁 → 轻量级锁 → 重量级锁(逐级升级)

这一节给出的主线非常清晰:JVM 会把 synchronized 的锁状态分为 无锁、偏向锁、轻量级锁、重量级锁,并根据竞争情况依次升级。

下面逐个看。


2.1 偏向锁:先"做标记",尽量不做昂贵的加解锁

偏向锁的要点是:它不是真的加锁,而是在对象头里做一个偏向标记,记录这把锁"偏向"哪个线程。

  • 如果后续没有其他线程竞争,就不需要进一步同步操作,避免了加锁/解锁的开销。
  • 如果出现其他线程来竞争,会取消偏向锁状态,进入轻量级锁状态。

一个极简的使用场景可以这样理解(代码本身不体现"偏向",偏向是 JVM 在背后做的优化):

java 复制代码
Object lock = new Object();

void work() {
    synchronized (lock) {
        // 同一个线程反复进出,且几乎没有竞争
        // JVM 倾向走"偏向锁"这条低成本路径
    }
}

这部分的核心结论:偏向锁本质上是在"延迟加锁",能不加就不加,但必须留下标记来判断是否需要真正升级。


2.2 轻量级锁:用 CAS 抢占 + 自适应自旋等待

随着其他线程加入竞争,偏向锁会被消除,进入轻量级锁 ;并且这里明确指出:轻量级锁是通过 CAS 来实现的。

给出的步骤是:

  • 通过 CAS 检查并更新一块内存(示意:null => 该线程引用
  • 更新成功 → 认为加锁成功
  • 更新失败 → 认为锁被占用,继续自旋式等待(不放弃 CPU)

把"自旋锁"的伪代码也写得很直白:

java 复制代码
while (抢锁(lock) == 失败) {
    // 立刻再试,循环直到成功
}

这就是"抢不到先别睡,马上再抢"的策略:第一次失败后的第二次尝试会在极短时间内到来,一旦别人释放锁,就能第一时间拿到。

但自旋的代价也写得很明确:CPU 会空转,浪费资源;因此这里强调轻量级锁的自旋不是无限的,而是到一定时间/重试次数就停止------也就是"自适应"。


2.3 重量级锁:竞争更激烈时膨胀为 OS 的 mutex(内核态/用户态切换)

如果竞争进一步激烈,自旋也无法快速获得锁,就会膨胀成重量级锁 ,这时会用到内核提供的 mutex

给出的过程是一个"内核参与"的流程:

  1. 执行加锁操作,先进入内核态
  2. 内核态判定锁是否被占用
  3. 未占用 → 加锁成功,切回用户态
  4. 已占用 → 加锁失败,线程进入锁的等待队列并挂起,等待 OS 唤醒

这一节对"重量级 vs 轻量级"的定位也很明确:

  • CPU 提供原子指令;OS 基于原子指令实现 mutex;JVM 再基于 mutex 实现 synchronizedReentrantLock 等机制,而且 synchronized 内部还做了很多额外工作,不仅仅是封装 mutex。

并且把开销点点得很直:重量级锁大量依赖内核态/用户态切换,也容易引发线程调度,成本高到可以用"沧海桑田"来形容。


3)从"乐观→悲观"的切换:冲突少先试试,冲突多就谨慎

这一节用一个非常形象的类比来解释:一开始假设"老师很闲"(冲突少),于是直接上;但连续两次发现老师很忙(冲突频繁),下次就先问问再决定(更谨慎的策略)。对应到锁策略就是:初始用乐观锁,竞争频繁时自动切换成悲观锁

同样,轻量级与重量级之间的切换也点得非常清楚:synchronized 开始是轻量级锁;如果锁冲突严重,就会变成重量级锁。


4)非公平锁:不保证先来后到

公平/非公平的对比也给了一个明确的结论:OS 的线程调度本身可以视为随机的,如果不加额外结构记录先后顺序,锁就是非公平;而 synchronized 属于非公平锁。

用更直白的话说:同样在等待队列里,先来的线程不一定先拿到锁


5)其他优化:锁消除与锁粗化(JVM 在背后"帮忙省开销")

5.1 锁消除:能证明没必要加锁,就直接去掉

这一节的定义是:编译器 + JVM 判断某些锁是否可消除,如果可以就消除。

给出的典型例子是 StringBufferappend 每次调用都有加锁解锁,但如果只在单线程环境执行,这些加解锁就纯属浪费。示例代码如下:

java 复制代码
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

这一节的落点是:如果确定不是多线程环境,这些 synchronized 开销可以被消除。


5.2 锁粗化:频繁加解锁且没必要,就合并成更大粒度的锁

锁粗化的定义写得很明确:一段逻辑如果出现多次加锁解锁,编译器 + JVM 会自动进行锁粗化。

可以用一段极简"前后对比"表达这种合并:

java 复制代码
// 细粒度(频繁加解锁)
synchronized (lock) { task1(); }
synchronized (lock) { task2(); }
synchronized (lock) { task3(); }

// 粗化后(合并一次加锁解锁)
synchronized (lock) {
    task1();
    task2();
    task3();
}

这一节强调:细粒度锁的初衷是"尽快释放让别人用",但如果实际上没人来抢,频繁申请/释放锁反而是纯开销,于是 JVM 会倾向把锁"粗化"来降低成本。


6)收束:一句话复述"synchronized 原理"该怎么讲

把这一节的叙事逻辑浓缩成面试回答就是:

  • synchronized 在 JDK 1.8 下是一套自适应的锁策略组合:起步更偏乐观、更偏轻量,竞争与持锁变重时逐级升级;锁状态会从无锁到偏向锁,再到用 CAS + 自适应自旋的轻量级锁,冲突严重时膨胀成依赖 OS mutex 的重量级锁;并且它是非公平、可重入、不是读写锁;JVM 还会做锁消除和锁粗化等优化,尽量避免不必要的加解锁成本。

7)追加:把 CAS 讲透,以及 CAS 引发的 ABA 问题(含解决方案)

上面多次提到"轻量级锁通过 CAS 实现""自旋锁通过 CAS 实现",但 CAS 自身也是进阶并发里的核心积木。来看这一节把 CAS 与 ABA 用"可复述"的方式讲清楚。


7.1 CAS 到底是什么:Compare And Swap(比较并交换)

CAS 的定义非常明确:全称 Compare and swap ,字面意思"比较并交换"。假设内存中的原数据是 V ,旧的预期值是 A ,要写入的新值是 B,CAS 的步骤是:

  1. 比较 A 与 V 是否相等(比较)
  2. 如果相等,把 B 写入 V(交换)
  3. 返回操作是否成功

这件事最关键的点在于:CAS 在硬件层面可以用一条原子指令完成"读内存、比较、写内存"这三个步骤,因此是原子的。

用伪代码写出来大概是这种味道(注意:真实 CAS 是硬件原子指令,这里只是为了理解流程):

java 复制代码
// 概念伪代码(用于理解,不是硬件原子指令)
boolean CAS(int[] V, int expectA, int newB) {
    if (V[0] == expectA) {
        V[0] = newB;
        return true;
    }
    return false;
}

7.2 CAS 怎么做"无锁原子类":AtomicInteger 的 getAndIncrement 思路

来看 AtomicInteger.getAndIncrement() 的本质:用循环 + CAS 把 i++ 这种"代码层面非原子"的操作,变成"硬件层面原子提交"的操作。

示意伪代码(思路核心是:失败就重读再 CAS):

java 复制代码
class AtomicInteger {
    private int value;

    public int getAndIncrement() {
        int oldValue = value;
        while (CAS(value, oldValue, oldValue + 1) != true) {
            oldValue = value; // 失败:说明 value 被别人改了,重读再试
        }
        return oldValue;
    }
}

并发两线程调用时的过程也讲得很直白:

  • 两个线程都先读 value 到各自的 oldValue(局部变量在线程栈上)
  • 线程 1 先 CAS 成功并写回
  • 线程 2 第一次 CAS 发现 oldValue 与 value 不一致,进入循环重读,再 CAS 成功

这就是 CAS 的典型风格:不加重量级锁、不阻塞线程,用"失败重试"换吞吐与低开销


7.3 CAS 的 ABA 问题:值看起来没变,但历史已经变了

ABA 的问题描述非常经典:有两个线程 t1、t2,共享变量 num 初始值为 A。t1 想用 CAS 把 A 改成 Z,于是会先读 num 记录为 oldNum,再 CAS 判断 num 是否仍为 A,是的话就改成 Z。问题在于:在 t1 "读 oldNum" 与 "CAS 提交"之间,t2 可能把 num 从 A 改成 B,再从 B 改回 A。

这样 t1 会看到"当前还是 A",但它无法分辨:

  • num 始终没变(真 A)
  • num 发生过变化但又回来了(A→B→A,假 A)

文中的比喻也很形象:买手机时只看"外观像新机"不够,因为可能是旧机翻新过------外观回到 A,但历史已经变了。


7.4 ABA 真的会出 bug:扣款被执行两次

很多情况下 A→B→A 对业务没影响,但并不总是这样。来看这段 ATM 取款例子:希望两个线程并发执行 -50 时,只允许一个成功,另一个失败;但 ABA 会导致"扣款被执行两次"。

把这个过程翻译成"代码感"的版本(核心是展示 ABA 风险点):

java 复制代码
// 伪代码:展示 ABA 风险(不是完整可运行实现)
int num = 100;

// 线程 t1
int old = num;               // old=100
// ... t2 在这期间把 num: 100 -> 50 -> 100(ABA) ...
CAS(num, old, 50);           // 看起来 old==num==100,于是成功扣款

// 线程 t2 也可能再次成功扣款,导致执行两次

结论就一句:CAS 只比较"当前值是否等于期望值",不关心"中间是否被改过又改回来",这就是 ABA 的根源。


7.5 ABA 怎么解决:引入版本号(stamp),比较值的同时比较版本

解决方案写得非常明确:给要修改的数据引入版本号,在比较数据当前值和旧值的同时,也比较版本号是否符合预期。

  • 读取旧值时也读取版本号

  • 真正修改时

    • 若当前版本号 == 读到的版本号:允许修改,并版本号 +1
    • 若当前版本号 > 读到的版本号:认为操作失败(数据被改过)

再落回 ATM 例子:哪怕余额回到 100,只要版本号已从 1 增到 3,t2 也会因为版本不匹配而失败,从而避免"扣款两次"。

Java 标准库也给出对应工具:AtomicStampedReference<E>,用于把"值 + 版本号"绑定在一起进行 CAS。

一个最小使用示例(展示思路:比较值与 stamp,同时更新):

java 复制代码
import java.util.concurrent.atomic.AtomicStampedReference;

public class StampedDemo {
    // 初始值 100,版本号 1
    private static final AtomicStampedReference<Integer> ref =
            new AtomicStampedReference<>(100, 1);

    public static void main(String[] args) {
        int[] stampHolder = new int[1];
        Integer oldV = ref.get(stampHolder);
        int oldStamp = stampHolder[0];

        Integer newV = oldV - 50;
        boolean ok = ref.compareAndSet(oldV, newV, oldStamp, oldStamp + 1);

        System.out.println(ok); // true 表示值与版本都匹配,更新成功
    }
}

总结:把 synchronized 与 CAS 放到同一张图里

  • synchronized 的轻量级阶段,会大量依赖 CAS + 自旋来争抢锁;竞争严重或持锁变长,就膨胀到重量级 mutex 路径。
  • CAS 是硬件原子指令支撑的"比较并交换",能构建原子类与自旋锁;但 CAS 也会带来 ABA 这种"值回来了、历史不在了"的问题,需要版本号/AtomicStampedReference 这种机制修正。
相关推荐
疯狂的喵4 小时前
C++编译期多态实现
开发语言·c++·算法
2301_765703144 小时前
C++中的协程编程
开发语言·c++·算法
m0_748708054 小时前
实时数据压缩库
开发语言·c++·算法
Hgfdsaqwr4 小时前
Python在2024年的主要趋势与发展方向
jvm·数据库·python
lly2024064 小时前
jQuery Mobile 表格
开发语言
惊讶的猫5 小时前
探究StringBuilder和StringBuffer的线程安全问题
java·开发语言
jmxwzy5 小时前
Spring全家桶
java·spring·rpc
Halo_tjn5 小时前
基于封装的专项 知识点
java·前端·python·算法
Hgfdsaqwr5 小时前
掌握Python魔法方法(Magic Methods)
jvm·数据库·python
m0_748233175 小时前
30秒掌握C++核心精髓
开发语言·c++