Java并发——原子变量与非阻塞同步机制
本篇博文是Java并发编程实战的笔记。
本篇中介绍了并发编程(除锁定之外)的另一种思路——CAS。
前置知识#
本章大量使用了竞争锁获取和非竞争锁获取这两个名词,但是书中没有解释它们的意思,这两个概念是Java的synchronized
关键字在不同情况下的两种表现。
- 非竞争锁获取:即只有一个线程会访问
synchronized
代码块的情况,在这种情况下,JVM通常会做一些优化,即把完整的锁操作换成一个CAS操作外加一些锁操作。这种情况也被称作轻量级锁。 - 竞争锁获取:即有多个线程会尝试进入同一个
synchronized
代码块,这种情况下,JVM的操作就不是CAS那么简单了。
非竞争锁也被称为deflated锁,竞争锁也被称为inflated锁(膨胀锁)
锁的劣势#
我们主要看竞争锁获取的情况,JVM需要借助操作系统的功能来完成多个线程的锁获取。锁是一种悲观的技术,它总想着在它执行的过程中可能有其它线程来干扰它的执行,所以干脆把代码块锁住,不让其它线程进入,所以此时,JVM会将一些线程挂起(也有可能是自旋),并等待其它线程释放锁后才有可能被恢复执行。
这种操作系统级别的处理有很大的开销,如果你加锁的代码片段中只做了很少的操作,那么这种处理开销是划不来的。
还有,持有锁的线程的错误执行会影响其它线程的活跃性,比如一个持有锁的线程由于某些原因不得不陷入漫长的等待或者死循环,其它所有等待这个锁的线程都不能执行。即使被阻塞的线程具有比持有锁的线程更高的优先级,它也只能等待,这种情况称作优先级反转。
CAS#
并发程序设计越来越多的使用CAS技术(Compare And Swap,比较并交换)来代替这种悲观的锁技术来获得更好的活跃性。CAS是乐观的,它的思想是认为不会有线程来打扰自己的执行,但如果检测到有线程打扰了,那就宣告失败。
在使用锁进行并发设计时,我们学会了将锁的粒度变细以让代码中必须串行执行的部分变少,而这种细粒度的代码如果用CAS来实现保护则会获得更好的性能,因为粒度足够细,所以CAS宣告失败的概率就不会太高,通常只需要几次失败重试即可。
目前的CPU基本都支持CAS指令。
下面一段使用Java模拟的程序可以帮助理解CAS指令的运作,但是请注意,这里我们是在Java中模拟所以使用了锁技术,实际上的CAS指令都是由硬件(CPU)以极快的速度和原子的方式来完成的:
@ThreadSafe
public class SimulatedCAS {
@GuardedBy("this") private int value;
public synchronized int get() {
return this.value;
}
public synchronized int compareAndSwap(int expectedValue, int newValue) {
int oldValue = this.value;
if (oldValue == expectedValue)
this.value = newValue;
return oldValue;
}
public synchronized boolean compareAndSet(int expectedValue, int newValue) {
return (expectedValue == compareAndSwap(expectedValue, newValue));
}
}
compareAndSwap
方法就是CAS的工作方式,CAS用于执行对某个内存变量的设置(在这里是value),你需要给它一个你期待这个内存变量当前的值,和一个你要设置的新值,仅当当前值和你期待的值一致,内存变量被更新为新值。无论如何,CAS都返回你调用它之时这个内存变量的值。这个比较操作就是用来检测是否有其它线程干扰你执行的,如果其它线程在你修改内存变量时将这个值改了你就能检测到。
compareAndSet
是一个更加实用的方法,它的返回值代表内存变量是否被修改,从用户的视角来说,就是设置内存变量是否成功,它只需要比较CAS被调用时的值是否是你期待的值即可(即内存变量没被其它线程改动过)。
下面是使用SimulatedCAS
来实现的一个并发安全的计数器的示例:
public class CASCounter {
private final SimulatedCAS count = new SimulatedCAS();
public int increment() {
int v;
do {
v = count.get();
} while (!count.compareAndSet(v, v + 1));
return v;
}
public int get() {
return count.get();
}
// 下面是测试
static final int N_THREADS = 100;
static final int INCTIMES_PRE_THREAD = 100000;
public static void main(String[] args) throws InterruptedException {
CASCounter counter = new CASCounter();
ExecutorService service = Executors.newFixedThreadPool(N_THREADS);
Callable inc = () -> {
for (int i=0; i<INCTIMES_PRE_THREAD; i++) {
counter.increment();
}
return null;
};
for (int i=0; i<N_THREADS; i++) {
service.submit(inc);
}
service.shutdown();
service.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
System.out.println(counter.get());
System.out.println(counter.get() == N_THREADS * INCTIMES_PRE_THREAD);
}
}
JVM的
AtomicXXX
类都通过native
方法使用了硬件提供的CAS操作(如果平台支持的话),而java.util.concurrent
包下的很多并发工具都使用了AtomicXXX
。
原子变量类#
Java中提供了12个原子变量类,这12个类又可以分成四类:
- 标量类:
AtomicInteger
、AtomicLong
、AtomicBoolean
、AtomicReference
,提供CAS,整型和长整型具有运算方法。 - 数组类:
AtomicIntegerArray
、AtomicLongArray
、AtomicReferenceArray
。 - 更新器类
- 复合变量类
原子变量类中的操作都保证是原子的。
示例:CAS实现的NumberRange#
NumberRange是一个具有下界和上界的整数范围,其中有一个不变性条件,就是下界不能大于上界,我们可以用AtomicReference
来完成这一操作。
public class CASNumberRange {
@Immutable
private static class IntPair {
// lower <= upper
final int lower;
final int upper;
public IntPair(int lower, int upper) {
this.lower = lower;this.upper = upper;
}
}
private final AtomicReference<IntPair> bounds = new AtomicReference<>(new IntPair(0, 0));
public int getLower() { return bounds.get().lower; }
public int getUpper() { return bounds.get().upper; }
// 使用CAS设置,失败就重试
public void setLower(int lower) {
while (true) {
IntPair oldPair = bounds.get();
if (lower > oldPair.upper)
throw new IllegalArgumentException("Can't set lower bigger than upper");
if (bounds.compareAndSet(oldPair, new IntPair(lower, oldPair.upper))) {
return;
}
}
}
// 省略setUpper
}
性能比较:锁与原子变量#
作者使用ReentrantLock
和AtomicInteger
分别实现了伪随机数生成器。
这里nextInt
中的工作量不高,现在在高度竞争和实际竞争的情况下比较二者的吞吐量差异:
可以看到在高度竞争的情况下,基于CAS的AtomicInteger
反而不如Lock
,而在更加正常的竞争情况下,AtomicInteger
的表现要好很多。
这是因为Lock
处理竞争的方式是将线程挂起,而AtomicInteger
则是直接重试,重试导致了更多的竞争,而挂起则能够暂时将一部分竞争抑制,降低内存使用率和总线上的同步通信量。
在实际情况下可能产生的竞争程度来说,AtomicInteger
的表现往往要更好。
非阻塞算法#
如果一个线程的失败挂起不会导致另一个线程失败挂起,这种算法称之为非阻塞算法,如果一个算法的每个步骤都存在某些线程能执行下去,这种算法就称为无锁算法。纯CAS实现的算法既是非阻塞算法也是无锁算法。
非阻塞的栈#
下面是一个简单的,并不引入任何并发安全的栈,我们将使用AtomicReference
改造它,将它变成一个使用非阻塞算法保证并发安全的栈。
public class ConcurrentStack<E> {
private Node<E> top = null;
public void push(E item) {
Node<E> oldTop = top;
Node<E> newTop = new Node(item);
newTop.next = oldTop;
top = newTop;
}
public E pop() {
Node<E> oldTop = top;
if (oldTop == null) return null;
top = oldTop.next;
return oldTop.item;
}
private static class Node<E> {
public final E item;
public Node<E> next;
public Node(E item) {
this.item = item;
}
}
}
和书上写的不太一样,如果有问题请指教,主要思想就是通过CAS替换栈顶,失败重试。
public class ConcurrentStack<E> {
private AtomicReference<Node<E>> top = new AtomicReference<>();
public void push(E item) {
Node<E> newTop = new Node(item);
while (true) {
Node<E> oldTop = top.get();
newTop.next = oldTop;
if (top.compareAndSet(oldTop, newTop)) return;
}
}
public E pop() {
while (true) {
Node<E> oldTop = top.get();
if (oldTop == null) return null;
E result = oldTop.item;
if (top.compareAndSet(oldTop, oldTop.next)) return result;
}
}
// ...
}
非阻塞双端链表#
非阻塞双端链表的实现比栈复杂,因为它经常有两个CAS操作要原子的完成。用尾端插入举例(头端插入也是一样),尾端插入需要将尾部节点的next
换成新的节点,并将新节点设置成尾部节点,这需要两个CAS操作,如果第一个CAS和第二个CAS操作的过程中有一个线程进来了,那么这个新进来的线程将看到尾部节点的next
不为空。下面是我自己想到的一个实现方式,在内层用一个while
来阻止乱序执行,但这样产生了阻塞(新线程必须等待之前的完成),同样,对于我自己这个算法的正确性我不敢保证。
@ThreadSafe
public class ConcurrentLinkedQueue<E> {
private static class Node<E> {
final E item;
final AtomicReference<Node<E>> next;
public Node(E item, Node<E> next) {
this.item = item;
this.next = new AtomicReference<>();
}
}
private final Node<E> dummy = new Node<>(null, null);
// 初始化头和尾,它们最初都是这个假的节点
private final AtomicReference<Node<E>> head = new AtomicReference<>(dummy);
private final AtomicReference<Node<E>> tail = new AtomicReference<>(dummy);
// 尾端插入
public boolean put(E item) {
Node<E> newNode = new Node<>(item, null);
while (true) {
Node<E> oldTail = tail.get();
// 1. 先把oldTail的next设置成newNode
while (!oldTail.next.compareAndSet(null, newNode)) {
// 2. 再把tail设置成newNode
tail.compareAndSet(oldTail, newNode);
return true;
}
}
}
}
书中给的方法是,既然另一个线程已经知道如何判断它的进入是否在某一个线程的两次CAS操作中间了,那么它为什么不可以直接帮前面的线程把第二个动作做完,这样当第二个线程继续执行时,它也能看见自己的任务已经被完成了。
public boolean put(E item) {
Node<E> newNode = new Node<>(item, null);
while (true) {
Node<E> oldTail = tail.get();
if (oldTail == tail.get()) {
Node oldTailNext = oldTail.next.get();
// 如果`oldTailNext!=null`代表上一个线程的插入完成了一半,即替换了原来的尾部的next,还需要更新尾部指针,帮他做
// A
if (oldTailNext != null) {
// B
tail.compareAndSet(oldTail, oldTailNext);
} else {
// C
if (oldTail.next.compareAndSet(null, newNode)) {
// D
tail.compareAndSet(oldTail, newNode); // D
return true;
}
}
}
}
}
一个正常的插入应该先到C,它尝试将尾节点的next设置成新节点,如果C没成功,代表当前有线程正在执行插入操作,C将失败并重试,但这并不会影响正确性,因为C这里要插入的新节点还没有插入。如果C执行成功,代表没线程和它竞争,它正常进行插入,并且插入成功了,走到D,D尝试设置新的尾节点。D当然也可能由于一个线程和它竞争而失败,但D不会重新尝试,直接返回了。
如果一个线程的D失败,则后面的那个线程执行A时,肯定会发现oldTail.Next!=null
,它会走到B,帮之前D执行失败的线程推进尾节点,如果B执行失败,代表已经有了一个线程帮助之前的D修复了尾节点。B成功后,线程将进入新的循环来走到C处理自己的插入。
ABA问题#
CAS基于判断对象的值是否是你所期待的旧值来执行操作,但是有一种可能就是,比如你期待的旧值是A,这时很可能有一个线程将值设成了B又设回A。此时轮到你执行,你的CAS操作仍然会正常执行,但这其中已经有线程将你打断了。
我们上面的栈显然不存在这个问题,因为所有的节点都是自己管理,外部无法直接操作节点,也就无法产生ABA问题,如果你希望让外部管理Node
对象,那么问题就来了。
可以通过CASNumberRange
中差不多的手段,维护一个Pair
,其中除了存储Node还存储版本号,每次更新版本号都会发生变化(自增)。
作者:Yudoge
出处:https://www.cnblogs.com/lilpig/p/16173743.html
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。
欢迎按协议规定转载,方便的话,发个站内信给我嗷~
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!