文章目录
2. Java多线程进阶:深入synchronized与CAS
在上一篇Java多线程初阶-线程协作与实战案例笔记中,我们从宏观上探讨了各种锁策略的思想。今天,我们将深入到更底层的实现,去探寻两个在现代并发编程中至关重要的知识内容:synchronized
的锁升级机制 和 无锁编程CAS。
CAS (Compare-and-Swap) 是实现“乐观锁”的原子操作,也是JUC(java.util.concurrent
)包中许多高性能类的幕后英雄。而 synchronized
,这个我们最熟悉的关键字,其内部为了追求极致性能而进行的优化和演进,恰恰就是对CAS等思想的应用。可以说,理解了它们,我们才能真正看懂Java并发性能优化的精髓所在。
一、无锁编程——CAS (Compare-and-Swap)
在实际开发中,我们很少直接使用CAS,但它的思想和应用无处不在,是理解JUC并发包许多工具类的关键。
1. 什么是 CAS?一个原子的“比较并交换”操作
CAS,全称 Compare and Swap,是一个涉及到三个操作数的原子操作:
- 内存中的原数据
V
- 旧的预期值
A
- 需要修改的新值
B
其操作流程是:
- 比较:判断内存中的值
V
是否与旧的预期值A
相等。 - 交换:如果相等,就将新值
B
写入V
。 - 返回操作是否成功。
核心要点: 这整个“比较并交换”的过程是由一条CPU硬件指令完成的,因此它是原子的,不会被其他线程中断。
// CAS伪代码,仅用于理解流程
// 真实的CAS是由一个原子的硬件指令完成的
boolean CAS(address, expectValue, swapValue) {
// --- 以下是不可分割的原子操作 ---
if (内存中address处的值 == expectValue) {
将内存中address处的值更新为swapValue;
return true;
}
return false;
// --- 原子操作结束 ---
}
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但它并不会阻塞其他线程,其他线程只会收到操作失败的信号。因此,CAS 可以视为是一种乐观锁的实现方式。
2. CAS 的应用
1) 实现原子类
Java标准库 java.util.concurrent.atomic
包下的所有原子类,如 AtomicInteger
、AtomicLong
等,都是基于CAS实现的。它们可以在不使用锁的情况下,保证基本数据类型的操作是线程安全的,性能远高于加锁。
像 count++
这样的操作(写操作)本身是线程不安全的,通常需要加锁解决。但加锁效率较低,而使用基于CAS的原子类,可以在保证线程安全的同时,获得更好的性能。
// 使用原子类代替int,实现线程安全的计数器
private static AtomicInteger count = new AtomicInteger(0);
// 通过这个原子类就可以简单的解决我们之前多线程部分count++的线程安全问题
public void threadSafeIncrement() {
count.getAndIncrement(); // 内部通过CAS循环实现原子自增
}
getAndIncrement
的内部实现,就是一个典型的“CAS自旋”循环。让我们通过一个多线程场景,来详细拆解它的执行过程:
// AtomicInteger.getAndIncrement() 伪代码
public final int getAndIncrement() {
int oldValue;
do {
// 步骤1: 读取主内存中的当前值
oldValue = this.get();
} while (!this.compareAndSet(oldValue, oldValue + 1)); // 步骤2: 循环尝试CAS更新
return oldValue;
}
CAS自旋过程的通俗理解:
为了彻底搞懂这个过程,我们来模拟一个经典的“双线程抢占资源”的场景。假设 count
的初始值为 0
,线程A和线程B都想执行 count++
。
各自的“小算盘”:
- 线程A 率先启动,它执行
get()
方法,从主内存中读取到count
的值是0
。它在自己的工作内存中记下:“嗯,当前值是0
,我待会儿要把它变成1
。” - 就在线程A准备提交更新(执行
compareAndSet
)之前,操作系统发生了线程调度,线程A被挂起,线程B 登场。
- 线程A 率先启动,它执行
线程B“抢跑”成功:
- 线程B 也执行
get()
,它从主内存读到的值同样是0
。它也打好了自己的算盘:“当前值是0
,我要把它更新成1
。” - 接着,线程B执行
compareAndSet(0, 1)
。它向CPU申请一个原子操作:“请检查一下主内存里的count
是不是0
?如果是,就把它改成1
。” - 检查通过!主内存中的值确实是
0
。于是,CAS操作成功,count
的值被更新为1
。线程B满意地完成了任务,退出了循环。
- 线程B 也执行
线程A的“意外”与“重试”:
- 现在,线程A被唤醒,继续它未完成的工作。它也准备执行
compareAndSet(0, 1)
。它信心满满地提出原子请求:“请检查主内存的count
是不是0
?如果是就改成1
。” - 然而,此时主内存中的
count
已经是1
了!线程A的预期值0
与主内存的当前值1
不匹配。因此,这次CAS操作失败了。 compareAndSet
返回false
,while
循环的条件!false
变成了true
。线程A意识到:“看来在我发呆的时候,有人已经把值改了。我得重新来过。”
- 现在,线程A被唤醒,继续它未完成的工作。它也准备执行
线程A的第二次尝试:
- 线程A进入下一次循环(这就是“自旋”)。它重新执行
get()
,这次从主内存读到的是最新值1
。 - 它更新了自己的小算盘:“好的,现在值是
1
了,那我的目标就是把它更新成2
。” - 它再次发起
compareAndSet(1, 2)
请求。这一次,没有其他线程来捣乱,它的预期值1
和主内存的值1
完美匹配。 - CAS操作成功,主内存的
count
值被更新为2
。while
循环条件为false
,线程A也成功退出。
- 线程A进入下一次循环(这就是“自旋”)。它重新执行
通过这个小例子,我们可以看到,CAS的核心就是一种“乐观”的尝试。每个线程都乐观地认为自己可以修改成功,如果失败了,也不会立刻阻塞,而是像一个执着的程序员一样,不断地重试(自旋),直到成功为止。这种机制在并发度不高、锁占用时间很短的场景下,性能远超于需要操作系统介入的重量级锁。
2) 实现自旋锁
基于 CAS 也可以实现一个更灵活的自旋锁。
public class SpinLock {
// owner为null表示锁空闲,否则表示被某个线程持有
private volatile Thread owner = null;
public void lock(){
// 如果锁是null(空闲),就尝试通过CAS把它设置为当前线程
// CAS失败说明锁被别人占了,进入循环忙等(自旋)
while(!CAS(this.owner, null, Thread.currentThread())){
// a busy-wait loop
}
}
public void unlock (){
// 直接将owner置为null即可释放锁
this.owner = null;
}
}
3. 深度剖析:CAS 的 ABA 问题及其解决方案
CAS的核心逻辑是:只要内存值等于预期旧值,就认为数据没有被其他线程修改过。但这里存在一个逻辑漏洞:如果数据被其他线程从 A 改为 B,然后又改回 A,会发生什么?
这就是 ABA 问题。对于执行CAS的线程来说,它无法区分数据是“从未变过”,还是“曾经变过又变回来了”。
在大部分情况下,ABA问题可能无害。但某些对数据变化过程敏感的场景,它会引发严重BUG。
一个经典的ABA问题场景:
假设滑稽老哥账户有 100 元存款。他想从 ATM 取 50 块钱。
- 取款线程1 获取到当前存款值为 100, 期望更新为 50。
- 在线程1执行CAS前,CPU切换到另一个高优先级任务:滑稽的朋友正好给他转账 50,账户余额先变成150,然后他又消费了50,账户余额最终又变回 100。
- 轮到线程1 执行了, 它发现当前存款为 100, 和它之前读到的 100 相同, 于是CAS操作成功,执行扣款!
尽管中间发生了多次交易,但线程1的CAS操作依然成功了,这在某些金融场景下是不可接受的。
解决方案:版本号机制
要解决ABA问题,核心思路是引入版本号。在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。
- 每次修改数据时,不仅更新数据,也让版本号
+1
。 - CAS操作时,同时检查
(value, version)
是否都与预期相符。
在 Java 标准库中提供了 AtomicStampedReference<E>
类来解决ABA问题,它内部就维护了一个类似版本号的“时间戳”(stamp)。
二、synchronized
——从偏向锁到重量级锁的演进
理解了CAS和自旋锁,我们就能更好地揭开 synchronized
的神秘面纱。现代JVM为了极致的性能,赋予了 synchronized
多重身份和一套智能的锁升级机制。
1. synchronized
的多重身份回顾
结合上一篇的内容, 我们可以总结出, synchronized
具有以下特性(以现代JDK版本为例):
- 乐观与悲观的结合体:开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁。
- 轻量与重量的动态切换:开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁。
- 自旋锁的应用:实现轻量级锁的时候用到了自旋锁策略。
- 非公平锁。
- 可重入锁。
- 非读写锁。
2. 锁升级之路:偏向锁 -> 轻量级锁 -> 重量级锁
JVM 为了极致的性能,将 synchronized
锁分为 无锁、偏向锁、轻量级锁、重量级锁 四种状态,并会根据竞争情况,进行依次升级,且此过程通常是不可逆的。
1) 偏向锁
当第一个线程尝试加锁时,JVM并不会立刻加锁,而是优先进入偏向锁状态。
偏向锁不是真的 “加锁”, 只是在对象头中做一个 “偏向锁的标记”, 记录下这个锁“偏爱”哪个线程。如果后续没有其他线程来竞争该锁, 那么持有偏向锁的线程在进出同步块时,就无需再进行任何同步操作了,极大地避免了加锁解锁的开销。
偏向锁本质上相当于 “延迟加锁”。能不加锁就不加锁, 尽量来避免不必要的加锁开销。
如果后续有其他线程来竞争该锁, 那就取消原来的偏向锁状态, 升级为轻量级锁状态。
2) 轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态。
此处的轻量级锁就是通过 CAS 来实现的。线程会尝试通过CAS将锁对象的对象头指向自己的线程栈中的记录。
- 如果更新成功, 则认为加锁成功。
- 如果更新失败, 则认为锁已被占用, 线程会进行自旋式的等待(并不放弃 CPU)。
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源。因此此处的自旋不会一直持续进行, 而是达到一定的时间或重试次数后(即所谓的“自适应自旋”),如果仍未获取到锁,就会再次升级。
3) 重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 锁就会“膨胀”为重量级锁。
此处的重量级锁就是指动用操作系统内核提供的 mutex
。
- 执行加锁操作, 线程会从用户态切换到内核态。
- 在内核态判定当前锁是否已经被占用。
- 如果该锁没有占用, 则加锁成功, 并切换回用户态。
- 如果该锁被占用, 则加锁失败。此时线程会进入锁的等待队列, 挂起并放弃CPU, 等待未来被操作系统唤醒。
3. JVM 的智能优化:锁消除与锁粗化
除了锁升级,JVM在即时编译(JIT)阶段还会进行一些智能的锁优化。
锁消除 (Lock Elision)
编译器和JVM足够智能,能够判断出某些代码中的 synchronized
锁是否是多余的。如果发现一个锁不可能存在竞争(例如在单线程中使用 StringBuffer
),就会直接将这个锁消除掉。
// 在单线程环境中,这些append的锁都是不必要的
public void createString() {
StringBuffer sb = new StringBuffer();
sb.append("a"); // append是同步方法,有锁
sb.append("b");
sb.append("c");
}
// JIT编译器会优化为:
public void createString() {
StringBuffer sb = new StringBuffer();
// 锁被消除
sb.append("a");
sb.append("b");
sb.append("c");
}
锁粗化 (Lock Coarsening)
如果一段逻辑中出现对同一个锁对象的多次、连续的加锁和解锁,编译器和JVM会自动将这些锁操作合并成一个更大范围的锁,以减少锁操作的次数。这被称为锁粗化。
一个类比:
领导给下属交代工作任务:
- 方式一 (锁粒度细): 打电话, 交代任务1, 挂电话。再打电话, 交代任务2, 挂电话。再打电话, 交代任务3, 挂电话。
- 方式二 (锁粒度粗): 打电话, 一次性交代完任务1, 任务2, 任务3, 然后挂电话。
显然, 方式二是更高效的方案。JVM的锁粗化就是这个道理。
本篇核心要点总结 (Key Takeaways)
- CAS是无锁编程:CAS(Compare-and-Swap)是一种CPU原子指令,它可以在不使用锁的情况下实现线程安全的操作。它是
AtomicInteger
等原子类和JUC中许多并发工具的实现基础。 synchronized
的智能进化:synchronized
并非一个简单的重量级锁,而是拥有一个从偏向锁 -> 轻量级锁 -> 重量级锁的智能升级过程,旨在尽可能降低无竞争或低竞争场景下的性能开销。- CAS与
synchronized
的内在联系:synchronized
的轻量级锁阶段,就是通过“CAS + 自旋”来实现的,这避免了线程直接进入阻塞状态,提高了性能。 - ABA问题的警惕:在使用CAS时,需要注意ABA问题(值被改回原样),对于需要严格保证操作过程的场景,应使用
AtomicStampedReference
等带有版本号机制的工具来解决。
-> 轻量级锁 -> 重量级锁**的智能升级过程,旨在尽可能降低无竞争或低竞争场景下的性能开销。 - CAS与
synchronized
的内在联系:synchronized
的轻量级锁阶段,就是通过“CAS + 自旋”来实现的,这避免了线程直接进入阻塞状态,提高了性能。 - ABA问题的警惕:在使用CAS时,需要注意ABA问题(值被改回原样),对于需要严格保证操作过程的场景,应使用
AtomicStampedReference
等带有版本号机制的工具来解决。