type
status
date
slug
summary
tags
category
icon
password
Property
Feb 22, 2025 08:43 AM

原子操作的方案渐进

  • 什么是原子操作?
  • 为什么需要原子操作?
  • 什么是线程安全?
  • 如何实现线程安全?
  • 什么是死锁?
  • 如何避免死锁?
  • 有锁安全与无锁线程安全?

一、CAS

1.1 CAS基本释义

每一个 CAS 操作过程都包含三个运算符: 一个内存地址 V,一个期望的值 A 和一 个新值 B,操作的时候如果这个地址上存放的值等于这个期望的值 A,则将地址上的值赋为新值 B,否则不做任何操作。CAS 的基本思路就是, 如果这个地址上的值和期望的值相等, 则给其赋予新值, 否则不做任何 事儿,但是要返回原值是多少。 自然 CAS 操作执行完成时, 在 业务上不一定完成了, 这个时候我 们就会对 CAS 操作进行反复重试, 于是就有了 循环 CAS。很明显, 循环 CAS 就是在一个循环里不 断的做 cas 操作, 直到成功为止。 Java 中的 Atomic 系列的原子操作类的实现则是利用了循环 CAS 来实现。

1.2 CAS会存在三大问题

  1. ABA 问题 因为 CAS 需要在操作值的时候, 检查值有没有发生变化, 如果没有发生变化 则更新,但是如果 一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行 检查时会发现它的值没有发生变化,但是实际上却变化了。ABA 问题的解决思路就是使用版本号。在变量前面追加上版本号, 每次变量 更新的时候把版本号加 1,那么 A →B →A 就会变成 1A →2B →3A。举个通俗点的 例子, 你倒了一杯水放桌子上, 干了点别的事, 然后同事把你水喝了又给你重新 倒了一杯水, 你回来看水还在, 拿起来就喝, 如果你不管水中间被人喝过, 只关 心水还在,这就是 ABA 问题。
  1. 循环时间长开销大 自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。因为如果长时间自旋,就是获取到了时间片但是没有做任何事。
  1. 只能保证一个共享变量的原子操作 当对一个共享变量执行操作时, 我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共 享变量操作时,循环 CAS 就无法保证操作的原子性, 这个时候 就可以用锁。还有一个取巧的办法, 就是把多个共享变量合并成一个共享变量来操作。比 如,有两个共享变量i =2,j=a,合并一下 ij=2a,然后用 CAS 来操作 ij。从 Java 1.5 开始, JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把 多个变量放在一个对象里来进行 CAS 操作。

1.3 CAS在JDK中的实际应用

  1. Atomic原子基础类 AtomicIntegerArray整形数组原子类,AtomicInteger整形原子类属于基本类型,只能更新一个变量。如果需要更新多个变量可以考虑使用原子更新引用类型AtomicReference。但是这种也是存在ABA问题的,如果想处理ABA问题,那么可以使用AtomicStampedReference或者是AtomicMarkableReference原子类,前者利用版本戳的形式记录了每次改变以后的版本号,后者携带一个布尔类型的标记位。
  1. Atomic原子更新字段类 如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类, Atomic 包提供了以下 3个类进行原子字段更新。要想原子地更新字段类需要两步。第一步,因为原子更新字段类都是抽象类, 每次使用的时候必须使用静态方法 newUpdater()创建一个更新器, 并且需要设置 想要更新的类和属性。第二步,更新类的字段(属性)必须使用 public volatile 修饰符,这是为什么呢?其实很容易理解,因为使用CAS操作时,必须保证当CAS成功时,变量的写操作对所有线程可见。若字段未被volatile修饰,则无法确保这一点。写入线程的volatile写 → 读取线程的volatile读
notion image
除了上面所说的引用类型字段类的更新还有AtomicIntegerFieldUpdater整形字段原子更新类,还有长整形原子字段更新类AtomicLongFieldUpdater。

1.4 CAS升级版原子类:LongAdder、LongAccumulator、DoubleAdder、DoubleAccumulator

  1. 为什么整形Adder只有LongAdder没有IntegerAdder?同理,为什么浮点型没有FloatAdder只有DoubleAdder? 在Java中,LongAdder的设计主要是为了解决高并发场景下频繁写入的计数器性能问题,我知道你可能跟我一样,想知道为什么只有一个LongAdder却没有IntegerAdder,我通过翻阅资料,查到了一些潜在的可能性,首先是数值范围方面,相对于Integer,Long更适合高并发计数器,因为Long的数值范围更大(64位),能容纳更大的值,避免在高并发场景下快速溢出,而Integer在极端情况下可能很快溢出,限制了其在高吞吐场景中的实用性。再就是分段累加(Cell分散竞争)思想,LongAdder通过内部维护多个Cell(分段),将竞争分散到不同的内存位置,从而减少线程冲突。这种设计在写入频繁的场景下显著优于AtomicLong。如果为int实现类似的IntegerAdder,其优化效果可能不如LongAdder明显。因为int的操作本身更快(32位运算),且内存占用更小,分段优化的收益可能无法覆盖额外的内存和复杂度成本。
  1. 使用场景:LongAdder与LongAccumulator简单使用

二、AQS

Java并发工具类中,大部分的都是基于AQS去实现的。AQS是AbstractQueuedSynchronizer类的缩写,它代表的是一种规范。AQS的设计思想,源自计算机管程结构,管程是一种程序结构,是一种方法论,他在计算机中还有一个很常见的名字,叫监视器。科班出身的同学应该非常熟悉,管程一般指的就是操作系统中的Monitor,我们一直都在于Monitor打交道,java中的Monitor锁是不是很熟悉,就是监视器锁,Synchronized锁底层不就是基于Monitor锁来实现的吗。管程核心方法论在于如何实现在一个时间点,最多只有一个线程在执行管程的某个子程序。根据这一方法论,有不同的实现策略,Java正是基于某一种策略实现的AQS。
再回过头来看,实际上并发问题的无非就是解决两大问题:一是互斥问题:如何保证线程之间操作的原子性,也就是同一个时刻只允许一个线程访问共享资源。二是同步问题:如何保证线程之间通信,协作。在管程的发展史上,先后出现过以下三种不同的管程模型
  • MESA模型
  • Hoare模型
  • Hasen模型
而我们现在讨论的Java正是基于MESA模型实现的。
这里引用一下ProcessOn的公开模板库中一款对于MESA介绍的图片:源自这里
notion image

1.1 MESA模型 的核心思想

图片来自CSDN博客作者:球小爷原创
notion image
  1. 互斥访问:管程内部有一个互斥锁(Monitor Lock),同一时间只能有一个线程执行管程内的代码(临界区)。
  1. 条件变量:线程在进入管程后,若无法满足继续执行的条件(如资源不足),会主动挂起(yield),并进入等待队列。
  1. 阻塞与唤醒
      • 阻塞(Block):当线程无法继续执行时,释放管程锁,将自己加入对应的条件变量的等待队列。
      • 唤醒(Signal):当线程完成某个操作后,调用 signal 方法,唤醒等待队列中的一个线程。

1.2 MESA 的工作流程

  1. 进入管程
      • 线程尝试获取管程锁。
      • 若成功,执行临界区代码;若失败,进入等待队列并阻塞。
  1. 条件等待
      • 在临界区内,若需要等待某个条件(如 count > 0),调用 wait 方法:
        • 线程被阻塞,直到其他线程通过 signal 唤醒它。
    1. 唤醒操作
        • 当条件满足时,调用 signal 方法:
          • 被唤醒的线程重新竞争管程锁,若成功则继续执行。

      1.3 源码解读AQS模型

      JDK8中体现出来的公平锁与非公平锁的核心区别:底层调用的时候,非公平再进入sync的lock方法的时候,会先进行一次cas,如果失败,才会进入acquire队列,如果成功就相当于没有排队了,直接获取到的刚好释放的锁了。而公平锁,则会优先acquire。回去看当前线程是不是独占线程,这是重入锁机制,会持续累加state。如下图ReentrantLock独占式锁源码所示:
      notion image
      其中AQS底层核心的CAS操作,是基于Unsafe类去实现的:
      notion image
      此外,AQS源码注释中也提到,条件队列仅在独占模式下被访问。
      AQS的这段懒加载其实写的非常好,结合上面注释可以得知,CLH队列不会构造对象的时候创建头节点,只会在存在首次竞争时候才会进行初始化逻辑。之后在通过CAS操作将当前函数传入的节点设置为tail尾部节点,相当于尾插,最后将header的next位置赋值尾节点的内存地址。
      此外,在添加等待线程的时候,它也会间接使用到懒加载
      之后是唤醒线程的核心操作:这里会进行判断waitValue,这个value是在Node中定义的四种状态,先将将要唤醒的线程状态通过CAS操作清除,然后如果当前节点没有后继节点,说明是空链表或者存在链表断裂的情况,因此当直接的后继无效时,必须从队尾反向扫描,直到找到第一个有效的节点(waitStatus <= 0)。为什么要从尾部开始寻找,因为节点的 next 指针可能在并发操作中被修改(例如,后续节点已被取消并被移出队列),正向遍历可能失效。可以发现,AQS底层挂起线程是通过LockSupport来实现的,底层调用的也是JVM层面的unpark函数,下文会讲一些扩展。注意,反向扫描可能因节点状态频繁变化而失效,但 AQS 通过 CAS 和状态检查机制规避了这一问题。这里的唤醒,是为了唤醒 线程,使其重新竞争锁。
      notion image
      扩展:
      简单记录一下,LockSupport底层的unpark函数是如何实现的:
      以 HotSpot 为例,unpark 的底层流程如下:
      1. 检查目标线程是否被阻塞:如果目标线程当前处于 BLOCKED 或 WAITING 状态,则尝试将其标记为可运行。
      1. 更新线程状态:在 TCB(每个线程在 JVM 内部都有一个对应的 线程控制块(Thread Control Block, TCB),其中包含线程的状态、优先级等信息。unpark 操作会修改 TCB 中的标志位,将线程状态从阻塞恢复为可调度) 中将线程状态设置为 RUNNABLE,并清除阻塞原因。
      1. 插入就绪队列:将线程加入操作系统的就绪队列(Ready Queue),使其有机会被 CPU 调度。
      1. 触发调度:提醒操作系统进行上下文切换,尽快调度被唤醒的线程。
      不同的 JVM 实现(如 HotSpot、OpenJDK)会通过操作系统原语(如 POSIX 的 pthread_cond_signal 或 Windows 的 ResumeThread)通知操作系统唤醒目标线程。例如:
      • Linux/x86:使用 futex(快速用户空间互斥)机制,通过 syscall 触发内核的线程调度。
      • Windows:调用 NtResumeThread 函数恢复线程执行。

      JDK17中公平锁与非公平锁的核心区别:
      从JDK8之后,由于JDK 8 引入的偏向锁在存在大量短生命周期锁时性能优异,但当锁被反复抢占时,会退化为重量级锁并带来额外开销。JDK9开始,Synchronized的偏向锁被移除,在 JDK 15 中被标记为废弃(JEP 351),相关优化也被移除。JDK8中,非公平锁会在进入等待队列前执行一次抢锁,如果失败才会进入队列,这种优化 需要内存屏障保证原子性,增加了 CPU 负载,8之后,JDK不在支持这种优化,ReentrantLock 不再依赖偏向锁的快速路径 CAS 操作,移除快速路径后,所有锁获取操作统一通过队列进行,减少了内存屏障的使用频率。意味着并发优化进行了改动。JDK17 中 AQS的底层实现也在这次并发优化中做出了一些改进。
      notion image
      ReentranReadWriteLock读写锁的引入
      ReentrantLock是AQS独占锁的直接实现,支持公平与非公平锁,但是对于独占锁,同一时间只能有一个线程持有锁,而对于常见的读多写少的业务场景,独占锁不能支撑并发度,因此JUC额外提供了读写场景下的AQS规范锁。读写锁核心实现还是基于AQS,以维护state值作为核心,由于存在读写了两种状态,因此读写锁是通过对变量二进制拆分使用来达到一个变量支持两种状态的,int类型的state本质上是占有四个字节,一个字节占有八位存储,4*8一共占有32位存储,读写锁通过对高16位设置为读锁,低十六位设置为写锁,通过与或逻辑运算,从而达到通过位运算控制读写锁状态,如果state值大于0,但是高十六位按位与结果是0,说明存在写锁,反之则证明存在读锁,读写互斥,读读共享,写锁可以叠加读锁进而保证无缝衔接,相当于是锁降级了,但不是直接降级。
      notion image
      与ReentrantLock一致的是,他也支持可重入,前面介绍过ReentrantLock,他可重入是通过维护state值来达到的,等于0的时候就是无线程持有锁,对于ReentrantReadWriteLock,他将state拆分高低十六位来区分读写锁,那是怎么支持的锁重入呢?继续看源码就可得知
      notion image

      总结一下:

      乐观读写锁StampedLock不支持可重入,先乐观读,读完检测有没有写锁,有再加悲观锁继续读,读最新结果。看下来AQS的锁优缺点都十分明显,如果要讲究效率不考虑锁申请的重复流程,直接CAS是最直接的,但是想缓存一些数据以空间换取并发下的时间,比如设计一些条件队列,等待队列来避免线程重复创建申请锁,那就走AQS这种思路。

      扩展:linux的Pthread模型

      请看后续的专栏文章介绍Linux的P_thread模型,这个模型与锁的唤醒以及等待相关。
      未完待续…
      相关文章
      计算机视觉(一):深度学习的人脸应用
      Lazy loaded image
      计算机视觉(二):特征向量计算
      Lazy loaded image
      计算机视觉(三):人脸识别之特征提取
      Lazy loaded image
      Flowable(一):Java知识学习
      Lazy loaded image
      Flowable(二):数据库篇
      Lazy loaded image
      Flowable(三):Liquibase模式管理
      Lazy loaded image
      JUC核心篇(三):LockSupport与线程阻塞JUC核心篇(五):JUC并发工具类的使用
      Loading...
      fntp
      fntp
      多一点兴趣,少一点功利
      最新发布
      JUC核心篇(七):线程池底层原理
      2025-2-26
      JUC核心篇(六):阻塞队列
      2025-2-24
      JUC核心篇(四):CAS与AQS
      2025-2-22
      JUC技术篇(六):Volatile关键字
      2025-2-21
      JUC技术篇(五):Synchronized锁
      2025-2-21
      JUC核心篇(三):LockSupport与线程阻塞
      2025-2-21
      公告
      📝 博客只为了记录我的学习生涯
      😎 我的学习目标是成为一名极客
      🤖 我热爱开源当然我也拥抱开源
      💌 我期待能收到你的Email留言
      📧 我的邮箱:stickpoint@163.com
      欢迎交流~