|
| 1 | +--- |
| 2 | +title: Java非阻塞机制 |
| 3 | +date: 2018/05/19 |
| 4 | +categories: |
| 5 | +- Javase |
| 6 | +tags: |
| 7 | +- Javase |
| 8 | +- concurrent |
| 9 | +- juc |
| 10 | +--- |
| 11 | + |
| 12 | +# Java 非阻塞机制 |
| 13 | + |
| 14 | +> 本文内容基于 JDK1.8。 |
| 15 | +
|
| 16 | +<!-- TOC depthFrom:2 depthTo:3 --> |
| 17 | + |
| 18 | +- [concurrent 包的实现](#concurrent-包的实现) |
| 19 | +- [CAS](#cas) |
| 20 | + - [简介](#简介) |
| 21 | + - [操作](#操作) |
| 22 | + - [应用](#应用) |
| 23 | + - [原理](#原理) |
| 24 | + - [特点](#特点) |
| 25 | + - [总结](#总结) |
| 26 | +- [资料](#资料) |
| 27 | + |
| 28 | +<!-- /TOC --> |
| 29 | + |
| 30 | +## concurrent 包的实现 |
| 31 | + |
| 32 | +由于 Java 的 CAS 同时具有 volatile 读和 volatile 写的内存语义,因此 Java 线程之间的通信现在有了下面四种方式: |
| 33 | + |
| 34 | +1. A 线程写 volatile 变量,随后 B 线程读这个 volatile 变量。 |
| 35 | +2. A 线程写 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。 |
| 36 | +3. A 线程用 CAS 更新一个 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。 |
| 37 | +4. A 线程用 CAS 更新一个 volatile 变量,随后 B 线程读这个 volatile 变量。 |
| 38 | + |
| 39 | +同时,volatile 变量的读/写和 CAS 可以实现线程之间的通信。把这些特性整合在一起,就形成了整个 concurrent 包得以实现的基石。如果我们仔细分析 concurrent 包的源代码实现,会发现一个通用化的实现模式: |
| 40 | + |
| 41 | +首先,声明共享变量为 volatile; |
| 42 | + |
| 43 | +然后,使用 CAS 的原子条件更新来实现线程之间的同步; |
| 44 | + |
| 45 | +同时,配合以 volatile 的读/写和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信。 |
| 46 | + |
| 47 | +AQS,非阻塞数据结构和原子变量类(Java.util.concurrent.atomic 包中的类),这些 concurrent 包中的基础类都是使用这种模式来实现的,而 concurrent 包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent 包的实现示意图如下: |
| 48 | + |
| 49 | +<p align="center"> |
| 50 | + <img src="https://raw.githubusercontent.com/dunwu/Javase-notes/master/images/concurrent/juc-architecture.png"> |
| 51 | +</p> |
| 52 | + |
| 53 | +## CAS |
| 54 | + |
| 55 | +### 简介 |
| 56 | + |
| 57 | +CAS(Compare and Swap),字面意思为比较并交换。CAS 有 3 个操作数,内存值 V,旧的预期值 A,要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做。 |
| 58 | + |
| 59 | +### 操作 |
| 60 | + |
| 61 | +我们常常做这样的操作 |
| 62 | + |
| 63 | +```Java |
| 64 | +if(a==b) { |
| 65 | + a++; |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +试想一下如果在做 a++之前 a 的值被改变了怎么办?a++还执行吗?出现该问题的原因是在多线程环境下,a 的值处于一种不定的状态。采用锁可以解决此类问题,但 CAS 也可以解决,而且可以不加锁。 |
| 70 | + |
| 71 | +```Java |
| 72 | +int expect = a; |
| 73 | +if(a.compareAndSet(expect,a+1)) { |
| 74 | + doSomeThing1(); |
| 75 | +} else { |
| 76 | + doSomeThing2(); |
| 77 | +} |
| 78 | +``` |
| 79 | + |
| 80 | +这样如果 a 的值被改变了 a++就不会被执行。按照上面的写法,a!=expect 之后,a++就不会被执行,如果我们还是想执行 a++操作怎么办,没关系,可以采用 while 循环 |
| 81 | + |
| 82 | +```Java |
| 83 | +while(true) { |
| 84 | + int expect = a; |
| 85 | + if (a.compareAndSet(expect, a + 1)) { |
| 86 | + doSomeThing1(); |
| 87 | + return; |
| 88 | + } else { |
| 89 | + doSomeThing2(); |
| 90 | + } |
| 91 | +} |
| 92 | +``` |
| 93 | + |
| 94 | +采用上面的写法,在没有锁的情况下实现了 a++操作,这实际上是一种非阻塞算法。 |
| 95 | + |
| 96 | +### 应用 |
| 97 | + |
| 98 | +非阻塞算法 (nonblocking algorithms) |
| 99 | + |
| 100 | +一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法。 |
| 101 | + |
| 102 | +现代的 CPU 提供了特殊的指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。 |
| 103 | + |
| 104 | +拿出 AtomicInteger 来研究在没有锁的情况下是如何做到数据正确性的。 |
| 105 | + |
| 106 | +```Java |
| 107 | +private volatile int value; |
| 108 | +``` |
| 109 | + |
| 110 | +首先毫无疑问,在没有锁的机制下可能需要借助 volatile 原语,保证线程间的数据是可见的(共享的)。 |
| 111 | + |
| 112 | +这样才获取变量的值的时候才能直接读取。 |
| 113 | + |
| 114 | +```Java |
| 115 | +public final int get() { |
| 116 | + return value; |
| 117 | +} |
| 118 | +``` |
| 119 | + |
| 120 | +然后来看看++i 是怎么做到的。 |
| 121 | + |
| 122 | +```Java |
| 123 | +public final int incrementAndGet() { |
| 124 | + for (;;) { |
| 125 | + int current = get(); |
| 126 | + int next = current + 1; |
| 127 | + if (compareAndSet(current, next)) |
| 128 | + return next; |
| 129 | + } |
| 130 | +} |
| 131 | +``` |
| 132 | + |
| 133 | +在这里采用了 CAS 操作,每次从内存中读取数据然后将此数据和+1 后的结果进行 CAS 操作,如果成功就返回结果,否则重试直到成功为止。 |
| 134 | + |
| 135 | +而 compareAndSet 利用 JNI 来完成 CPU 指令的操作。 |
| 136 | + |
| 137 | +```Java |
| 138 | +public final boolean compareAndSet(int expect, int update) { |
| 139 | + return unsafe.compareAndSwapInt(this, valueOffset, expect, update); |
| 140 | +} |
| 141 | +``` |
| 142 | + |
| 143 | +整体的过程就是这样子的,利用 CPU 的 CAS 指令,同时借助 JNI 来完成 Java 的非阻塞算法。其它原子操作都是利用类似的特性完成的。 |
| 144 | + |
| 145 | +其中 unsafe.compareAndSwapInt(this, valueOffset, expect, update)类似: |
| 146 | + |
| 147 | +```Java |
| 148 | +if (this == expect) { |
| 149 | + this = update |
| 150 | + return true; |
| 151 | +} else { |
| 152 | + return false; |
| 153 | +} |
| 154 | +``` |
| 155 | + |
| 156 | +那么问题就来了,成功过程中需要 2 个步骤:比较 this == expect,替换 this = update,compareAndSwapInt 如何这两个步骤的原子性呢? 参考 CAS 的原理 |
| 157 | + |
| 158 | +### 原理 |
| 159 | + |
| 160 | +Java 代码如何确保处理器执行 CAS 操作? |
| 161 | + |
| 162 | +CAS 通过调用 JNI(JNI:Java Native Interface 为 Java 本地调用,允许 Java 调用其他语言。)的代码实现的。JVM 将 CAS 操作编译为底层提供的最有效方法。在支持 CAS 的处理器上,JVM 将它们编译为相应的机器指令;在不支持 CAS 的处理器上,JVM 将使用自旋锁。 |
| 163 | + |
| 164 | +### 特点 |
| 165 | + |
| 166 | +#### 优点 |
| 167 | + |
| 168 | +一般情况下,比锁性能更高。因为 CAS 是一种非阻塞算法,所以其避免了线程被阻塞时的等待时间。 |
| 169 | + |
| 170 | +#### 缺点 |
| 171 | + |
| 172 | +##### ABA 问题 |
| 173 | + |
| 174 | +因为 CAS 需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA 问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么 A-B-A 就会变成 1A-2B-3A。 |
| 175 | + |
| 176 | +从 Java1.5 开始 JDK 的 atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。 |
| 177 | + |
| 178 | +##### 循环时间长开销大 |
| 179 | + |
| 180 | +自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起 CPU 流水线被清空(CPU pipeline flush),从而提高 CPU 的执行效率。 |
| 181 | + |
| 182 | +比较花费 CPU 资源,即使没有任何用也会做一些无用功。 |
| 183 | + |
| 184 | +##### 只能保证一个共享变量的原子操作 |
| 185 | + |
| 186 | +当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量 i = 2,j=a,合并一下 ij=2a,然后用 CAS 来操作 ij。从 Java1.5 开始 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。 |
| 187 | + |
| 188 | +### 总结 |
| 189 | + |
| 190 | +可以用 CAS 在无锁的情况下实现原子操作,但要明确应用场合,非常简单的操作且又不想引入锁可以考虑使用 CAS 操作,当想要非阻塞地完成某一操作也可以考虑 CAS。不推荐在复杂操作中引入 CAS,会使程序可读性变差,且难以测试,同时会出现 ABA 问题。 |
| 191 | + |
| 192 | +## 资料 |
| 193 | + |
| 194 | +* [Java 并发编程实战](https://item.jd.com/10922250.html) |
| 195 | +* [Java 并发编程的艺术](https://item.jd.com/11740734.html) |
| 196 | +* https://www.jianshu.com/p/473e14d5ab2d |
| 197 | +* https://blog.csdn.net/ls5718/article/details/52563959 |
| 198 | +* http://tutorials.jenkov.com/java-concurrency/non-blocking-algorithms.html |
0 commit comments