Volatile

Q: 多个线程共享一个变量 state。线程 A 把它改了,线程 B 能立刻看到吗?

A: 不一定。因为每个线程为了快,会把变量缓存到自己的"工作内存"里(这里可以去看一下JMM的内存模型,主内存还有工作内存这些),改完不一定马上写回主内存,别的线程也不一定马上去主内存重读。结果就是 A 改了,B 还在用旧值。

volatile 这个关键字就是解决这个的。给变量加上 volatile 后:

  • 谁改了,立刻写回主内存;

  • 谁要读,必须去主内存重新读。

这叫可见性。AQS 里那个核心的 state 就是用 volatile 修饰的,保证一个线程改了锁状态,其他线程马上能看见。

CAS

Q:两个线程同时想把 state 从 0 改成 1(都想抢锁),怎么保证只有一个成功?

A:普通的 state = state + 1 不行,因为这一行其实是"读-改-写"三步,两个线程可能都读到 0,都写了 1,结果锁被两个人同时拿到了——这就出 bug 了。

CAS(Compare And Swap,比较并交换)就是干这个的。它是一条CPU 级别的原子指令,一步完成,中间不会被打断。它的逻辑是:

CAS(内存位置, 期望的旧值, 想写的新值):
    如果 内存里现在的值 == 期望的旧值:
        就把它改成新值,返回 true(我抢成功了)
    否则:
        什么都不做,返回 false(有人比我先改了,我失败)

举个例子,两个线程都想把 state 从 0 改成 1:

  • 线程 A 执行 CAS(state, 0, 1):此刻 state 确实是 0,匹配 → 改成 1,返回 true,A 抢到锁

  • 线程 B 也执行 CAS(state, 0, 1):但 state 已经被 A 改成 1 了,不等于期望值 0 → 什么都不做,返回 false,B 抢锁失败

这样就保证了"同时抢,只有一个赢",而且全程没用 synchronized 这种重量级锁,效率很高。

一句话:CAS 让"读-改-写"这三步变成不可分割的一步,是无锁并发的基石。

Volatile和CAS

Q:二者如何配合使用?

A:

state使用volatile去修饰,保证线程修改了这个状态所有线程都能看到;线程去抢占锁(修改state变量)的时候,多个线程同时抢占,只有一个成功,其余都去排队(稍后详述)。

AQS

AQS全称是AbstractQueuedSynchronizier,即抽象队列同步器。

AQS 内部就两样核心东西:

  • state 表示"资源/锁的状态"

  • CLH 等待队列

state

private volatile int state;

int具体代表的什么意思,AQS没有自己规定,而是交给字类去定义,这是AQS的设计精髓——同一个state,不同同步器有不同的含义:

  • ReentrantLock:state = 0 表示没人占锁,state = 1 表示有人占了,state = 2、3... 表示同一个线程重入了几次(可重入就是这么实现的)。

  • Semaphore(信号量):state = 当前还剩几个许可证。

  • CountDownLatch:state = 还差几个就到 0(countDown 一次减一,减到 0 就放行)。

操作 state 有三个方法(子类用这三个来读写它):

java

getState()                          // 读
setState(int newState)              // 写
compareAndSetState(expect, update) // 用 CAS 改(第1步的 CAS!)

CLH

抢占state失败的线程,不能一直空转浪费CPU,得让他进入睡眠队列。AQS用了一个双向链表队列来管理。

运转规则:

抢锁失败 → 把自己包成一个 Node,CAS 加到队列尾部,然后调用 LockSupport.park() 把自己挂起(睡觉)。

  1. 持锁线程释放锁 → 唤醒队列里头节点的下一个节点(LockSupport.unpark()),让它醒来去抢锁。

  2. 谁抢到了锁,谁就成为新的 head

这个队列是公平的基础:先来的排前面,先被唤醒。所谓"公平锁/非公平锁"的区别,就是抢锁前要不要先看一眼队列里有没有人在等——这个后面会讲到。

state和CLH

线程来抢锁 │ ├─ 用 CAS 改 state 成功? │ │ │ 是 → 抢到锁,执行业务逻辑 │ │ │ 否 → 包成 Node,排到队列尾部 │ → park() 睡眠,等被唤醒 │ 持锁线程干完活,release: → 改 state 回去 → unpark 唤醒队列里下一个,让它醒来重新抢

以上就是AQS的核心图。

模版方法

定义父类把骨架流程写死,把其中几个关键步骤留成空方法,让子类去填。

AQS已经写好如:抢占锁失败如何排队,如何park,释放后如何唤醒,这套复杂的队列操作全部写好了,子类只需要解决一个问题 :

“什么叫做抢占了锁、什么叫做释放了锁”,这个需要子类自己定:

子类只需要重写下面这几个方法(用哪几个取决于你做独占锁还是共享锁):

tryAcquire(int arg)        // 独占式:试着抢锁,成功返回 true
tryRelease(int arg)        // 独占式:试着释放锁
tryAcquireShared(int arg)  // 共享式:试着抢(Semaphore/CountDownLatch 用)
tryReleaseShared(int arg)  // 共享式:试着释放
isHeldExclusively()        // 是不是当前线程独占

每个方法前都带个 try 前缀——意思是"常识,方法负责告诉你成没成功(返回 true/false),抢不到之后排队的麻烦逻辑AQS 自己处理"。

而且这些方法在 AQS 里默认是抛异常的(throw new UnsupportedOperationException()),逼着你子类必须自己实现想用的那几个。

两层分工
谁干 干什么 方法
子类填 定义"怎样算抢到/释放"(改 state 的逻辑) tryAcquire / tryRelease
AQS 写好 抢不到怎么排队、park、唤醒(队列操作) acquire / release
public class MySimpleLock {
​
    // 1. 写一个内部类,继承 AQS,只填两个"空"
    private static class Sync extends AbstractQueuedSynchronizer {
​
        // 填空①:怎样算抢到锁?
        // 我的定义:state 从 0 改成 1 成功,就算抢到
        @Override
        protected boolean tryAcquire(int arg) {
            if (compareAndSetState(0, 1)) {   // CAS:期望是0,改成1
                setExclusiveOwnerThread(Thread.currentThread()); // 记下锁是谁拿的
                return true;   // 抢到了!
            }
            return false;      // state 不是0,说明被别人占了,抢失败
        }
​
        // 填空②:怎样算释放锁?
        // 我的定义:把 state 改回 0
        @Override
        protected boolean tryRelease(int arg) {
            setExclusiveOwnerThread(null);
            setState(0);       // state 归零
            return true;
        }
    }
​
    private final Sync sync = new Sync();
​
    // 2. 对外暴露 lock / unlock,内部直接调 AQS 写好的模板方法
    public void lock() {
        sync.acquire(1);   // 注意:调的是 AQS 的 acquire,不是我们的 tryAcquire
    }
​
    public void unlock() {
        sync.release(1);   // 调 AQS 的 release
    }
}

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐