← 返回文章列表

Java 并发:锁、AQS 与 CAS 的边界

2014年3月18日Java
并发编程AQS锁机制

先检查库存大于零,再减库存。这是笔者做过几年 Java 之后,闭着眼也能写出来的一段代码:单线程里它绝对正确,扔进并发场景里就会偶尔把库存扣成负数。两个线程同时读出 stock = 1,都判断「大于零」,都执行扣减,库存变成 -1,超卖的账最后要由业务来还。

问题出在两句话中间的缝隙里。单独一次读、单独一次写本身是原子的,可是「读出来再据此决定写什么」这一整段没人保证。真正要护住的从来不是某一行语句,而是几步之间的配合。

这篇是笔者给自己补的并发控制笔记:锁、AQS 与 CAS 各自能管住什么,边界又画在哪里。翻出旧的笔记时才发现,里面好几处只记了结论、没记前提,正好借这篇重新理一遍。

分开读写都安全,合起来为什么就不安全

共享状态的麻烦可以拆成三件事:原子性、可见性与有序性。线程切换可能发生在任意指令之间,所以复合动作会被拆开;一个线程写过的值也不保证立刻被另一个线程看到,这不是逻辑错误,而是缓存与重排序的正常行为。

可见性出问题的样子很有迷惑性。线程 A 把 running 置为 false,线程 B 里那个 while (running) 可能一直转下去,因为 B 读的是自己 CPU 缓存里的副本。这个循环里没有任何写入,看起来完全人畜无害,却能把一个核心跑满。加锁能顺带解决它,是因为解锁会把写回主内存、加锁会从主内存刷新,synchronized 和 volatile 都建立了这种「先写后读」的先后约束;反过来,只用普通字段传递状态,就没有人替我们建立这条约束。

volatile 能管住可见性:读它一定能读到最新写入,写它之前的操作也不会被重排到后面。它管不了原子性,读到最新值不代表「根据这个值算出来的新值」能安全落地。这个区别决定了它只能用于状态标记一类场景。

换个角度看开头那段扣减逻辑,真正需要被保护的从来不是 stock 这个 int,而是「读库存、判断、扣减」这条约束。也正因为如此,哪怕把 stock 声明成 volatile,超卖依然会发生。

同一份状态上还有哪些操作、它们怎么彼此交错,会一直跟着这个设计走。若状态只是进程内累加,synchronized 或 AtomicLong 大致能收住;一旦部署成多个实例、同一个账户会被不同节点同时扣减,进程内的锁就退化成摆设,得上数据库行锁或分布式协调(多副本之间怎么取舍,取决于一致性和可用性哪一头更不能让步,那是另一个层面的账)。这个边界应该在动手加锁之前先画出来。

synchronized 足够时,就让它管理临界区

synchronized 同时提供互斥和内存可见性,并且把锁的释放与代码块退出绑在一起:正常返回、return、抛异常,monitor 都会退出。字节码里成对出现的 monitorenter 与 monitorexit 就是这件事的证据,编译器会在异常出口也补上一条 monitorexit,所以临界区中途抛异常不会把锁带走。这正是它最省心的地方:不用记着释放。

要让这段保证成立,两个前提得先满足。一是锁住哪个对象:synchronized 方法锁的是 this(静态方法锁的是 Class 对象),如果两个方法分别锁了不同的对象,互斥关系根本不存在。二是大家遵守同一把锁,读写都走同一个 monitor,有人绕过锁直接改字段,规则就作废了。这个「谁用哪把锁」的约定比记住一串优化名词重要得多。

synchronized 是可重入的:同一线程重复进入同一把锁,计数加一,退出时减一,减到零才真正释放。

synchronized (obj) {
    synchronized (obj) {   // 同一线程,直接进入,计数为 2
    }
}

重入是有意的设计。没有它,一个同步方法调用另一个同步方法就会把自己锁死——而这正是递归和模板方法里最常见的写法。子类调用父类的同步方法,也只有重入才走得通。

锁的落点在对象头。HotSpot 的对象头里有一个 Mark Word,专门用来记录这把锁的状态,线程栈帧上再配一个锁记录(Lock Record)。轻量级锁的加锁过程是:把对象头当前内容复制到锁记录里(这份副本叫 displaced mark word),然后 CAS 把对象头指向这条锁记录。CAS 失败说明有人抢先,于是自旋重试;自旋也失败,锁才升到重量级——JVM 为对象分配一个 ObjectMonitor(对象监视器),向操作系统申请互斥量,线程真正挂起。

挂起和唤醒都要穿过内核态,一次上下文切换的开销可能比临界区里的代码还大。轻量级锁和偏向锁当年就是冲着这笔开销来的:临界区很短、竞争不激烈时,与其去内核态排队,不如在用户态自旋几次。

不少资料会画一条「无锁 → 偏向锁 → 轻量级 → 重量级」的升级链,HotSpot 从 JDK 1.6 起确实默认打开了这一套:偏向锁靠对象头里的线程 ID 省掉后续 CAS,轻量级锁把竞争挡在用户态的锁记录里,实在抢不到才升到重量级。但把它当成所有 JVM 都走的路径就错了——换一个 JVM 实现、换一个版本,策略未必一样;而且偏向锁的撤销本身要付代价,撤销频繁的场景下,它未必比直接升到重量级划算。所以讨论锁的性能,得连同实现、竞争情况和临界区长度一起说,否则「轻量级锁更快」这句话没有主语。

等待进入 synchronized 与调用 Object.wait() 是两件事:前者不可中断,后者在等待期间会释放 monitor,也能被中断并抛 InterruptedException。

ReentrantLock 换来的是什么

限时等待、可中断获取、多个条件队列、可选的公平模式,这几件事 synchronized 给不了,于是有了 ReentrantLock。

需求synchronizedReentrantLock
限时等待没有tryLock(long, TimeUnit)
中断响应等待进入时不可中断lockInterruptibly()
多个条件队列一个 wait set多个 Condition
公平性不可选构造参数可选
释放方式退出代码块自动释放必须在 finally 里 unlock()

显式锁的代价是把「什么时候释放」交回了开发者手里。约定写法是把 lock() 放在 try 之外、unlock() 放在 finally 里:

lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();
}

lock() 写在 try 里面是常见错误:一旦加锁失败抛异常,finally 会照样执行 unlock(),而这时锁并不在本线程手上。漏掉 finally 更糟,线程会一直持有这把锁,后面的请求全部堵在门外,排查时看线程栈只能看到一堆 WAITING,看不出是谁忘了释放。

限时等待的价值在排查和防雪崩上。tryLock() 不带参数时会立即返回,拿不到就返回 false,适合「能拿就拿,拿不到走别的路径」;带超时的版本用来兜住「万一锁被某个卡住的线程长期占着」。可中断的意义类似:等锁的线程收到中断可以及时退出,而不是陪着一起卡死。

多条件队列则解决了「一次唤醒全部」的老问题。synchronized 配 Object.wait() 只有一个等待集合,notifyAll() 会把生产者、消费者、超时清理等不同原因的等待者一起叫醒,绝大多数被叫醒的线程只能再睡回去。ReentrantLock 可以按条件分出多条队列,唤醒只针对其中一条:

lock.lock();
try {
    while (!enough()) {       // 必须用 while:被唤醒不等于条件成立
        notEnough.await();    // 等待期间释放锁,并移到 notEnough 条件队列
    }
} finally {
    lock.unlock();
}

公平模式由构造参数决定,new ReentrantLock(true) 会让新来的线程先去排队,代价是多几次唤醒与上下文切换,吞吐通常低于非公平模式。默认的非公平模式则允许插队,这对已经拿到 CPU 的线程更友好。

要弄清 ReentrantLock 这些行为从哪来,得往下走一层。它自己不实现排队,把等待、唤醒、重入计数全部委托给了框架,只负责定义 state 的含义。

AQS 把等待机制与状态规则分开

AbstractQueuedSynchronizer(AQS,抽象队列同步器)只做两件事:维护一个 volatile int state,维护一条 CLH(Craig, Landin, and Hagersten)双向队列。state 的含义不由框架决定——ReentrantLock 用它记录重入次数,Semaphore 用它记录剩余许可,CountDownLatch 用它记录还要倒数几次。框架额外记着的只有当前独占线程是谁(AbstractQueuedSynchronizer 的父类 AbstractOwnableSynchronizer 就负责这一件事),排查时能从线程转储里认出持有者,靠的就是它。

AQS 把等待机制与状态规则分开:框架负责线程排队与唤醒,子类负责解释 state 的含义

框架那一半是 acquire、release 这些模板方法,它们被声明为 final:入队、自旋、LockSupport.park、唤醒后继节点,全在框架内完成。子类那一半是钩子:独占模式实现 tryAcquire 与 tryRelease,共享模式实现 tryAcquireShared 与 tryReleaseShared。所以不能把 Semaphore、CountDownLatch 都说成重写 tryAcquire 和 tryRelease,它们走的是共享模式那条钩子。它们的语义差异也落在 state 上:CountDownLatch.await() 在 state 归零前一直等,且计数只减不增,归零以后不能重置——一次性就是它的定位,需要反复使用的场景得另找工具。

AQS 里其实有两类队列。同步队列管的是「谁能拿到资源」,节点在这里排队等 tryAcquire 成功;每条 Condition 各自维护一条条件队列,管的是「条件什么时候成立」。调用 await() 时线程从同步队列挪到条件队列并释放锁,被 signal() 唤醒后再移回同步队列,重新参与抢锁。弄混这两条队列,就很容易把「被唤醒」当成「条件已经成立」。

获取失败的线程会被包装成节点挂到队列尾部,先在前驱节点上自旋试探,自旋一段时间仍不成功才 park。这条队列是 FIFO,但 FIFO 等待队列并不自动意味着公平获取:非公平模式下,新来的线程会先试着直接 CAS 抢一次,抢到了就插队进临界区;公平模式才会先看队列里有没有人在等。AQS 的文档把这些边界写得很清楚,顺带说明了独占与共享两套钩子的差别。

日常业务代码基本不需要自己实现一个同步器。锁、信号量、并发容器表达得清楚的约束,直接用它们更容易验证;读 AQS 源码的价值在于知道 ReentrantLock 的公平开关究竟改了什么行为。

CAS 适合什么样的更新

CAS(Compare-And-Swap,比较并交换)由处理器指令提供原子语义:比较某个位置是否仍是预期值,是则写回新值,否则什么都不做,由调用方决定重试还是放弃。自增可以写成这样:

long cur;
do {
    cur = value.get();
} while (!value.compareAndSet(cur, cur + 1));

两个线程都读到 cur = 7,各自算出 8:只有一个能写进去,另一个发现位置上的值已经不是 7,回到循环开头重读。整个过程没有任何线程被阻塞,这是它相对加锁的吸引力所在。

高竞争下这段循环的代价不是线性的。每次失败的比较交换都要把前面的计算重做一遍,而 compareAndSet 自身带着内存屏障,开销高于普通写;当 N 个线程抢同一个位置,尝试次数会迅速膨胀,CPU 花在自旋上的时间可能超过临界区本身。这也是为什么 CAS 更适合竞争不激烈、临界区极短的场景。

CAS 只能保护一个内存位置。若有两个字段必须一起满足某个约束,分别换成两个原子类解决不了问题:账户的余额与冻结额要满足「余额 + 冻结额 = 总额」,两个 AtomicLong 各自原子,却没有谁保证这个等式,中间态一旦被读到,账就是错的。这种约束仍然要靠锁、不可变对象整体替换,或者把状态所有权重新划一遍。

无锁方案换掉的只是阻塞,约束本身一条都没少。更新要能重试、失败要能识别、中间态不能被外界看到:这些要求加锁时同样存在,只是锁用阻塞把它们掩盖了。把「无锁」当作性能与正确性的双重升级,通常会在压力测试里付学费。

java.util.concurrent.atomic 里的东西值得按「能否组合成不变式」分两类看。AtomicInteger、AtomicLong、AtomicReference 这类单点更新,用在计数、状态位、CAS 保护的单引用上很合适;而「余额 + 冻结额 = 总额」这种跨字段约束,原子类给不了答案。AtomicReference 配不可变对象是个常用折中:整体换个新对象,引用切换本身是原子的,读到的要么是旧快照要么是新快照,不会看到改了一半的状态。业务上直接说「状态整体替换」时,落地的常常就是它。

ABA 是另一类麻烦:值经历 A → B → A,CAS 只看当前值,察觉不到中间那段变化。如果算法只关心计数结果,这段变化无所谓;如果拿它保护的是链表指针或对象复用,中间态可能已经破坏过结构。JDK 为此提供了 AtomicStampedReference 这类带版本戳的方案,需不需要用,取决于算法是否真的在意中间过程。

LongAdder 用分散累加换吞吐:热点不再是单个 value,而是一组 cell,更新落到不同 cell 上,冲突自然减少。代价是它没有单一真相,sum() 在并发更新时不是原子快照,读到的只是遍历各个 cell 累加的那一瞬间。统计吞吐量、请求数这类场景够用;拿它做严格额度控制就会出问题——扣减并发进行时读到的总额倾向于偏低,检查因此放行了本该拒绝的请求。LongAdder 的文档把统计用途与精确同步控制分开讲,正是这个原因。

排查顺序:先问状态该不该共享

真到了要处理并发性能的时候,笔者的顺序是先问这份状态是否真的需要共享,再看锁等待和更新竞争。不少竞争来自不必要的共享,或者把不相干的动作圈进了同一个临界区。减少共享、缩短临界区、把状态收敛到一处,往往比换一种锁更直接。正确性要求定下来之后,性能比较才有前提。

这几条是笔者复查代码时会先扫一眼的地方:

  • lock() 不在 try 外、unlock() 不在 finally 里,或者中间有 return 绕过了释放
  • 把 volatile 当锁用,指望它保护「读—判断—写」这套复合动作
  • 把 LongAdder 的 sum() 当成可以拿来做准入判断的实时值
  • 两个必须一起成立的字段拆成两个原子类,各自都原子,合起来没有约束
  • 日志里只打印锁等待,不打印是哪把锁、谁持有它,出问题时只能靠猜

最后一条尤其值得留意。线程栈能告诉我们在等什么,却常常不告诉我们谁持有它——ReentrantLock 会记录当前持有者,synchronized 的 ObjectMonitor 里也有 owner,但这些信息不会自己出现在默认的线程转储里,得先想起来去看。

写完这一篇才发现,当年背得最熟的几段口径都得加上前提才能用:锁升级那条链条是 HotSpot 自己的做法,不是并发编程的通用路径;Semaphore 和 CountDownLatch 走的是共享模式钩子,不是独占模式;FIFO 队列和公平获取是两件事。笔记体的好处是这些修正都留在原处,翻回来就能看见。这个系列还有几篇要补,接下来想回到 I/O 那一段:阻塞调用、NIO 与 Reactor 各自在等什么,各自又占着谁。

← 返回文章列表