【死磕 Java 并发】-----J.U.C 之深入分析 CAS
CAS,Compare And Swap,即比较并交换。Doug lea 大神在同步组件中大量使用 CAS 技术鬼斧神工地实现了 Java 多线程的并发操作。整个 AQS 同步组件、Atomic 原子类操作等等都是以 CAS 实现的,甚至 ConcurrentHashMap 在 1.8 的版本中也调整为了 CAS+Synchronized。可以说 CAS 是整个 JUC 的基石。
CAS 分析
在 CAS 中有三个参数:内存值 V、旧的预期值 A、要更新的值 B,当且仅当内存值 V 的值等于旧的预期值 A 时才会将内存值 V 的值修改为 B,否则什么都不干。其伪代码如下:
JUC 下的 atomic 类都是通过 CAS 来实现的,下面就以 AtomicInteger 为例来阐述 CAS 的实现。如下:
Unsafe 是 CAS 的核心类,Java 无法直接访问底层操作系统,而是通过本地(native)方法来访问。不过尽管如此,JVM 还是开了一个后门:Unsafe,它提供了硬件级别的原子操作。
valueOffset 为变量值在内存中的偏移地址,unsafe 就是通过偏移地址来得到数据的原值的。
value 当前值,使用 volatile 修饰,保证多线程环境下看见的是同一个。
我们就以 AtomicInteger 的 addAndGet()方法来做说明,先看源代码:
内部调用 unsafe 的 getAndAddInt 方法,在 getAndAddInt 方法中主要是看 compareAndSwapInt 方法:
该方法为本地方法,有四个参数,分别代表:对象、对象的地址、预期值、修改值(有位伙伴告诉我他面试的时候就问到这四个变量是啥意思...)。该方法的实现这里就不做详细介绍了,有兴趣的伙伴可以看看 openjdk 的源码。
CAS 可以保证一次的读-改-写操作是原子操作,在单处理器上该操作容易实现,但是在多处理器上实现就有点儿复杂了。
CPU 提供了两种方法来实现多处理器的原子操作:总线加锁或者缓存加锁。
总线加锁:总线加锁就是就是使用处理器提供的一个 LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。但是这种处理方式显得有点儿霸道,不厚道,他把 CPU 和内存之间的通信锁住了,在锁定期间,其他处理器都不能其他内存地址的数据,其开销有点儿大。所以就有了缓存加锁。
缓存加锁:其实针对于上面那种情况我们只需要保证在同一时刻对某个内存地址的操作是原子性的即可。缓存加锁就是缓存在内存区域的数据如果在加锁期间,当它执行锁操作写回内存时,处理器不在输出 LOCK#信号,而是修改内部的内存地址,利用缓存一致性协议来保证原子性。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改,也就是说当 CPU1 修改缓存行中的 i 时使用缓存锁定,那么 CPU2 就不能同时缓存了 i 的缓存行。
CAS 缺陷
CAS 虽然高效地解决了原子操作,但是还是存在一些缺陷的,主要表现在三个方法:循环时间太长、只能保证一个共享变量原子操作、ABA 问题。
循环时间太长
如果 CAS 一直不成功呢?这种情况绝对有可能发生,如果自旋 CAS 长时间地不成功,则会给 CPU 带来非常大的开销。在 JUC 中有些地方就限制了 CAS 自旋的次数,例如 BlockingQueue 的 SynchronousQueue。
只能保证一个共享变量原子操作
看了 CAS 的实现就知道这只能针对一个共享变量,如果是多个共享变量就只能使用锁了,当然如果你有办法把多个变量整成一个变量,利用 CAS 也不错。例如读写锁中 state 的高地位
ABA 问题
CAS 需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:如果一个值原来是 A,变成了 B,然后又变成了 A,那么在 CAS 检查的时候会发现没有改变,但是实质上它已经发生了改变,这就是所谓的 ABA 问题。对于 ABA 问题其解决方案是加上版本号,即在每个变量都加上一个版本号,每次改变时加 1,即 A —> B —> A,变成 1A —> 2B —> 3A。
用一个例子来阐述 ABA 问题所带来的影响。
有如下链表
假如我们想要把 B 替换为 A,也就是 compareAndSet(this,A,B)。线程 1 执行 B 替换 A 操作,线程 2 主要执行如下动作,A 、B 出栈,然后 C、A 入栈,最终该链表如下:
完成后线程 1 发现仍然是 A,那么 compareAndSet(this,A,B)成功,但是这时会存在一个问题就是 B.next = null,compareAndSet(this,A,B)后,会导致 C 丢失,改栈仅有一个 B 元素,平白无故把 C 给丢失了。
CAS 的 ABA 隐患问题,解决方案则是版本号,Java 提供了 AtomicStampedReference 来解决。AtomicStampedReference 通过包装[E,Integer]的元组来对对象标记版本戳 stamp,从而避免 ABA 问题。对于上面的案例应该线程 1 会失败。
AtomicStampedReference 的 compareAndSet()方法定义如下:
compareAndSet 有四个参数,分别表示:预期引用、更新后的引用、预期标志、更新后的标志。源码部门很好理解预期的引用 == 当前引用,预期的标识 == 当前标识,如果更新后的引用和标志和当前的引用和标志相等则直接返回 true,否则通过 Pair 生成一个新的 pair 对象与当前 pair CAS 替换。Pair 为 AtomicStampedReference 的内部类,主要用于记录引用和版本戳信息(标识),定义如下:
Pair 记录着对象的引用和版本戳,版本戳为 int 型,保持自增。同时 Pair 是一个不可变对象,其所有属性全部定义为 final,对外提供一个 of 方法,该方法返回一个新建的 Pari 对象。pair 对象定义为 volatile,保证多线程环境下的可见性。在 AtomicStampedReference 中,大多方法都是通过调用 Pair 的 of 方法来产生一个新的 Pair 对象,然后赋值给变量 pair。如 set 方法:
下面我们将通过一个例子可以可以看到 AtomicStampedReference 和 AtomicInteger 的区别。我们定义两个线程,线程 1 负责将 100 —> 110 —> 100,线程 2 执行 100 —>120,看两者之间的区别。
运行结果:
运行结果充分展示了 AtomicInteger 的 ABA 问题和 AtomicStampedReference 解决 ABA 问题。
版权声明: 本文为 InfoQ 作者【chenssy】的原创文章。
原文链接:【http://xie.infoq.cn/article/cc0841166b730a55a9ea6e079】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论