知识点 5.5:并发编程基石 —— AQS 与 Volatile
在深入了解各种锁和同步工具之前,必须先理解 JUC 框架的两个核心基石:volatile 关键字和 AQS 框架。
1. volatile 关键字:并发编程的“信号旗”
volatile 是一个 Java 变量修饰符,它非常轻量,但提供了两个至关重要的保证。
1.1 保证可见性 (Visibility)
-
问题所在: 在多核 CPU 架构下,每个 CPU 都有自己的高速缓存(Cache)。当一个线程(运行在 CPU-1 上)修改了一个共享变量的值,它可能只是更新了自己的缓存,而没有立刻写回主内存。此时,另一个线程(运行在 CPU-2 上)读取这个变量时,可能会从自己的缓存里读到一个过时的、旧的值,导致数据不一致。
-
volatile的作用: 当一个变量被声明为volatile后,JVM 会保证:- 写操作:对这个变量的修改会立即被刷新到主内存中。
- 读操作:每次读取这个变量,都会强制从主内存中重新加载,而不是使用线程本地的缓存。
-
生活比喻:
volatile变量就像一个公共的、唯一的电子公告牌。当一个线程更新了公告牌上的内容,其他所有线程都能立刻看到最新的内容,而不是看自己手里抄录的、可能已经过时的小纸条。
1.2 禁止指令重排序 (Instruction Reordering)
-
问题所在: 为了提高性能,编译器和 CPU 可能会在不影响单线程最终结果的前提下,打乱代码的执行顺序。但在多线程环境下,这种重排序可能会导致意想不到的错误(例如著名的“双重检查锁定”单例模式失效问题)。
-
volatile的作用:volatile关键字会作为一个“内存屏障”(Memory Barrier)。当程序执行到volatile变量的读或写操作时,它前面的所有操作都必须已经完成,且结果对后续操作可见;它后面的所有操作也必须在它之后才能开始。这有效地阻止了指令重排序跨越这个屏障。
1.3 volatile 不能保证原子性
这是 volatile 最重要的一个局限性,也是面试高频陷阱。
-
原子性: 指一个操作是不可分割、不可中断的。要么完全执行成功,要么完全不执行。
-
count++的问题: 这个操作看似一步,实际包含三步:“1. 读取count的值;2. 将值加 1;3. 将新值写回count”。volatile无法保证这三步作为一个整体的原子性。
演示代码:VolatileNoAtomicDemo.java
以下代码清晰地演示了 volatile 无法保证复合操作(如 ++)的原子性。期望结果是 100,000,但实际运行结果通常会小于此值,证明有自增操作丢失。
package com.study.concurrency;import java.util.concurrent.atomic.AtomicInteger; // 引入 AtomicInteger/*** 演示 volatile 不保证原子性的经典例子:多线程计数器* 运行结果:最终 count < 期望结果,证明 volatile 无法保证“++”操作的原子性*/
public class VolatileNoAtomicDemo {// 原始问题:使用 volatile 修饰共享变量,但 ++ 操作非原子private static volatile int count = 0; // 此处用 volatile 无法解决原子性问题// 用于解决原子性问题的方案一:使用 AtomicInteger// private static AtomicInteger atomicCount = new AtomicInteger(0);// 用于解决原子性问题的方案二:使用 synchronized 块// private static final Object lock = new Object();private static final int THREADS = 10;private static final int INCREMENTS_PER_THREAD = 10_000;public static void main(String[] args) throws InterruptedException {Thread[] threads = new Thread[THREADS];for (int i = 0; i < THREADS; i++) {threads[i] = new Thread(() -> {for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {// 原始问题代码:count++; // 非原子操作:读-改-写三步// 方案一修正:使用 AtomicInteger// atomicCount.incrementAndGet(); // 原子操作// 方案二修正:使用 synchronized// synchronized (lock) { // 或者 synchronized (VolatileNoAtomicDemo.class)// count++;// }}});threads[i].start();}for (Thread t : threads) {t.join();}System.out.println("期望结果 = " + (THREADS * INCREMENTS_PER_THREAD));// System.out.println("实际结果 (volatile) = " + count); // 如果使用 volatile int countSystem.out.println("实际结果 = " + count); // 或者使用 atomicCount.get()}
}
问题分析:为什么 volatile 会失效?
-
volatile只能保证可见性和有序性,即保证读取到count的值是最新的,并且阻止了指令重排序。 -
但是,
count++并非一个单一的原子操作。当多个线程同时进行“读取-修改-写入”这三个步骤时,即使它们都读取到了最新的值,也可能在修改和写入的间隙被其他线程抢占 CPU,导致更新丢失。例如:- 线程A 读取
count(值为100)。 - 线程B 读取
count(此时count仍是100,因为线程A还没写回)。 - 线程A 将
count计算为101,并写入主内存。 - 线程B 将
count计算为101,并写入主内存。
- 结果:
count最终是101,而不是预期的102。一个自增操作丢失。
- 线程A 读取
如何修正这个问题来保证线程安全?
要保证 count++ 这样的复合操作的原子性,必须使用更强大的同步机制:
-
使用
synchronized关键字:- 将
count++操作放入synchronized代码块中。synchronized保证了同一时间只有一个线程可以进入该代码块,从而使整个“读-改-写”序列变为原子操作。
// 示例: // synchronized (VolatileNoAtomicDemo.class) { // 或 synchronized (任意共享对象) // count++; // }synchronized能够保证原子性、可见性、有序性。但它是一个重量级锁,在高并发竞争时性能开销较大。
- 将
-
使用
java.util.concurrent.atomic包下的原子类 (推荐):- 对于简单的原子操作(如整数自增、布尔值设置等),Java 提供了
AtomicInteger,AtomicLong,AtomicBoolean等原子类。 - 这些类内部通过CAS (Compare-And-Swap) 等无锁机制来保证操作的原子性,性能通常比
synchronized更好。
// 示例: // private static AtomicInteger count = new AtomicInteger(0); // ... // count.incrementAndGet(); // 原子性地执行 count++- 结论:
volatile适用于一写多读的场景,或者当变量的更新不依赖于其当前值时。对于count++这样的复合操作,必须使用synchronized或AtomicInteger这样的原子类来保证线程安全。
- 对于简单的原子操作(如整数自增、布尔值设置等),Java 提供了
2. AQS (AbstractQueuedSynchronizer):JUC 的龙骨
AQS 是 JUC 中绝大部分锁和同步工具的核心实现框架。它是一个抽象类,本身不是一个锁,而是用来构建锁和同步器的基础骨架。
2.1 AQS 的核心设计思想
AQS的核心思想是一种模板方法模式的应用。它提供了一个同步状态管理(state)和线程排队(FIFO队列)的基础框架。开发者只需实现对state的获取和释放逻辑,而线程的排队、阻塞、唤醒等复杂操作都由AQS框架本身来完成。
2.2 两大核心数据结构
-
volatile int state: 一个用volatile修饰的整型变量,用于表示同步状态。state的含义由具体实现类来定义。例如,在ReentrantLock中,它表示锁的重入次数;在Semaphore中,它表示剩余的许可数量。- AQS通过
getState(),setState(),compareAndSetState()这三个方法来原子地操作这个状态。
-
一个 FIFO 的线程等待队列 (CLH 队列):
- 这是一个双向链表,用于存放所有等待获取同步状态的线程。
- 当一个线程获取锁失败后,它就会被封装成一个
Node节点,加入到这个队列的尾部并被挂起,等待被唤醒。
2.3 底层实现:线程如何排队与唤醒?
这是AQS的精髓所在,也是面试深度考察点。
-
线程安全入队:
- 当一个新线程获取锁失败需要入队时,它会被封装成一个
Node节点。 - AQS通过自旋(for循环)+ CAS(Compare-And-Swap)这种无锁的方式,来原子性地将新节点添加到等待队列的队尾。这避免了在入队这个高并发操作上加锁,提高了效率。
- 当一个新线程获取锁失败需要入队时,它会被封装成一个
-
线程挂起(阻塞):
- 线程成功入队后,AQS会调用
LockSupport.park(this)方法将当前线程挂起。 LockSupport是JUC包中的一个工具类,它提供的park()/unpark()相比于Object.wait()/notify(),优点在于无需获取对象的监视器锁,并且可以先unpark再park而不会丢失信号。
- 线程成功入队后,AQS会调用
-
线程唤醒:
- 当持有锁的线程调用
release()方法释放同步状态时,它会通过LockSupport.unpark()方法唤醒等待队列头部的下一个节点所对应的线程。 - 被唤醒的线程会再次尝试获取同步状态(
state)。
- 当持有锁的线程调用
2.4 独占模式 vs. 共享模式
AQS支持两种资源共享模式:
-
独占模式 (Exclusive Mode):资源在同一时刻只能被一个线程持有。
- 例子:
ReentrantLock。
- 例子:
-
共享模式 (Shared Mode):资源在同一时刻可以被多个线程持有。
- 例子:
Semaphore(信号量)、CountDownLatch、ReadWriteLock中的读锁。
- 例子:
2.5 需要重写的核心方法 (模板方法)
开发者在实现自定义同步器时,需要根据选择的模式重写以下核心方法:
-
独占模式:
tryAcquire(int arg): 尝试以独占方式获取资源。tryRelease(int arg): 尝试以独占方式释放资源。isHeldExclusively(): 判断当前线程是否是独占持有资源。
-
共享模式:
tryAcquireShared(int arg): 尝试以共享方式获取资源。tryReleaseShared(int arg): 尝试以共享方式释放资源。