|
| 1 | +# Java并发编程之原子性、可见性以及有序性 |
| 2 | + |
| 3 | + |
| 4 | + |
| 5 | +- 缓存导致的可见性问题 |
| 6 | +- 线程切换带来的原子性问题 |
| 7 | +- 编译优化带来的有序性问题 |
| 8 | + |
| 9 | + |
| 10 | + |
| 11 | +## 原子性(Atomicity) |
| 12 | + |
| 13 | +众所周知,原子是构成物质的基本单位,所以原子代表着不可分。 |
| 14 | +即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。 |
| 15 | +最简单的一个例子就是银行转账问题,赋值或者`return`。比如`a = 1;`和 `return a;`这样的操作都具有原子性 |
| 16 | +原子性不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作! |
| 17 | +加锁可以保证复合语句的原子性,Java中提供了两个高级指令 `monitorenter`和 `monitorexit`,也就是对应的synchronized同步锁来保证原子性。 |
| 18 | + |
| 19 | +非原子性操作 |
| 20 | +类似`a += b`这样的操作不具有原子性,在某些`JVM`中`a += b`可能要经过这样三个步骤: |
| 21 | + |
| 22 | +- 取出`a`和`b` |
| 23 | +- 计算`a+b` |
| 24 | +- 将计算结果写入内存 |
| 25 | + 如果有两个线程`t1`,`t2`在进行这样的操作。`t1`在第二步做完之后还没来得及把数据写回内存就被线程调度器中断了,于是`t2`开始执行,`t2`执行完毕后`t1`又把没有完成的第三步做完。这个时候就出现了错误, |
| 26 | + 相当于`t2`的计算结果被无视掉了。所以上面的买碘片例子在同步`add`方法之前,实际结果总是小于预期结果的,因为很多操作都被无视掉了。 |
| 27 | + 类似的,像`a++`这样的操作也都不具有原子性。所以在多线程的环境下一定要记得进行同步操作。 |
| 28 | + |
| 29 | +## 可见性(Visibility) |
| 30 | + |
| 31 | +可见性指的是当一个线程修改了共享变量后,其他线程能够立即得知这个修改。 |
| 32 | + |
| 33 | +在多核处理器中,如果多个线程对一个变量进行操作,但是这多个线程有可能被分配到多个处理器中运行,那么编译器会对代码进行优化,当线程要处理该变量时,多个处理器会将变量从主内存复制一份分别存储在自己的片上存储器中,等到进行完操作后,再赋值回主存。(这样做的好处是提高了运行的速度,因为在处理过程中多个处理器减少了同主内存通信的次数);同样在单核处理器中这样由于备份造成的问题同样存在!这样的优化带来的问题之一是变量可见性——如果线程`t1`与线程`t2`分别被安排在了不同的处理器上面,那么`t1`与`t2`对于变量`A`的修改时相互不可见,如果`t1`给`A`赋值,然后`t2`又赋新值,那么`t2`的操作就将`t1`的操作覆盖掉了,这样会产生不可预料的结果。所以,即使有些操作时原子性的,但是如果不具有可见性,那么多个处理器中备份的存在就会使原子性失去意义。 |
| 34 | + |
| 35 | +volatile、synchronized、final都可以解决可见性问题。 |
| 36 | + |
| 37 | +## 有序性(Ordering) |
| 38 | + |
| 39 | +有序性:即程序执行的顺序按照代码的先后顺序执行。 |
| 40 | + |
| 41 | +有序性简单来说就是程序代码执行的顺序是否按照我们编写代码的顺序执行,一般来说,为了提高性能,编译器和处理器会对指令做重排序,重排序分3类 |
| 42 | + |
| 43 | +- 编译器优化重排序,在不改变单线程程序语义的前提下,改变代码的执行顺序 |
| 44 | +- 指令集并行的重排序,对于不存在数据依赖的指令,处理器可以改变语句对应指令的执行顺序来充分利用CPU资源 |
| 45 | +- 内存系统的重排序,也就是前面说的CPU的内存乱序访问问题 |
| 46 | + |
| 47 | +也就是说,我们编写的源代码到最终执行的指令,会经过三种重排序。 |
| 48 | + |
| 49 | +比如编写时顺序如下的程序: |
| 50 | + |
| 51 | +```java |
| 52 | +1. a = 5; |
| 53 | +2. b = 20; |
| 54 | +3. c = a + b; |
| 55 | +``` |
| 56 | + |
| 57 | +编译器优化后执行的顺序可能变成: |
| 58 | + |
| 59 | +```java |
| 60 | +1. b = 20; |
| 61 | +2. a = 5; |
| 62 | +3. c = a + b; |
| 63 | +``` |
| 64 | + |
| 65 | +在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果 |
| 66 | + |
| 67 | +在单例模式的实现上有一种双重检验锁定的方式(Double-checked Locking): |
| 68 | + |
| 69 | +```java |
| 70 | +public class Singleton{ |
| 71 | + private static Singleton instance; |
| 72 | + public static Singleton getInstance(){ |
| 73 | + if (instance == null){ |
| 74 | + synchronized(Singleton.class){ |
| 75 | + if(instance == null) { |
| 76 | + instance = new Singleton(); |
| 77 | + } |
| 78 | + } |
| 79 | + } |
| 80 | + return instance; |
| 81 | + } |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +我们先看 instance=newSingleton() 的未被编译器优化的操作 |
| 86 | + |
| 87 | +- 指令 1:分配一块内存 M; |
| 88 | +- 指令 2:在内存 M 上初始化 Singleton 对象; |
| 89 | +- 指令 3:然后 M 的地址赋值给 instance 变量。 |
| 90 | + |
| 91 | +编译器优化后的操作指令 |
| 92 | + |
| 93 | +- 指令 1:分配一块内存 M; |
| 94 | +- 指令 2:将 M 的地址赋值给 instance 变量; |
| 95 | +- 指令 3:然后在内存 M 上初始化 Singleton 对象。 |
| 96 | + |
| 97 | +现在有A,B两个线程,我们假设线程A先执行getInstance()方法,当执行编译器优化后的操作指令2时(此时候未完成对象的初始化),这时候发生了线程切换,那么线程B进入,刚好执行到第一次判断instance==nul会发现 instance不等于null了,所以直接返回instance,而此时的 instance 是没有初始化过的。 |
| 98 | + |
| 99 | + |
| 100 | + |
| 101 | +Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则来获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。 |
| 102 | + |
| 103 | +## **先行发生原则:** |
| 104 | + |
| 105 | +如果Java内存模型中所有的有序性都只靠volatile和synchronized来完成,那么有一些操作将会变得很啰嗦,但是我们在编写Java并发代码的时候并没有感觉到这一点,这是因为Java语言中有一个“先行发生”(Happen-Before)的原则。这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的主要依赖。 |
| 106 | + |
| 107 | +先行发生原则是指Java内存模型中定义的两项操作之间的依序关系,如果说操作A先行发生于操作B,其实就是说发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包含了修改了内存中共享变量的值、发送了消息、调用了方法等。下面是Java内存模型下一些”天然的“先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。 |
| 108 | + |
| 109 | +- 程序次序规则(Pragram Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。 |
| 110 | + |
| 111 | +- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。 |
| 112 | + |
| 113 | +- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,这里的”后面“同样指时间上的先后顺序。 |
| 114 | + |
| 115 | +- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。 |
| 116 | + |
| 117 | +- 线程终于规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。 |
| 118 | + |
| 119 | +- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。 |
| 120 | + |
| 121 | +- 对象终结规则(Finalizer Rule):一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。 |
| 122 | + |
| 123 | +- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。 |
| 124 | + |
| 125 | +一个操作”时间上的先发生“不代表这个操作会是”先行发生“,那如果一个操作”先行发生“是否就能推导出这个操作必定是”时间上的先发生“呢?也是不成立的,一个典型的例子就是指令重排序。所以时间上的先后顺序与先生发生原则之间基本没有什么关系,所以衡量并发安全问题一切必须以先行发生原则为准。 |
| 126 | + |
| 127 | +```java |
| 128 | +int i = 0; |
| 129 | +boolean flag = false; |
| 130 | +i = 1; //语句1 |
| 131 | +flag = true; //语句2 |
| 132 | +``` |
| 133 | +上面代码定义了一个`int`型变量,定义了一个`boolean`类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么`JVM`在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗? |
| 134 | +不一定,为什么呢?这里可能会发生指令重排序`(Instruction Reorder)`。 |
| 135 | +下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢? |
| 136 | +再看下面一个例子: |
| 137 | + |
| 138 | +```java |
| 139 | +int a = 10; //语句1 |
| 140 | +int r = 2; //语句2 |
| 141 | +a = a + 3; //语句3 |
| 142 | +r = a*a; //语句4 |
| 143 | +``` |
| 144 | +这段代码有4个语句,那么可能的一个执行顺序是: |
| 145 | +语句2->语句1->语句3->语句4 |
| 146 | +那么可能不可能是这个执行顺序呢?语句2->语句1->语句4->语句3,这是不可能的,因为处理器在进行重排序时是会考虑指令之间的数据依赖性, |
| 147 | +如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。 |
| 148 | + |
| 149 | +虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子: |
| 150 | +```java |
| 151 | +//线程1: |
| 152 | +context = loadContext(); //语句1 |
| 153 | +inited = true; //语句2 |
| 154 | + |
| 155 | +//线程2: |
| 156 | +while(!inited ){ |
| 157 | + sleep() |
| 158 | +} |
| 159 | +doSomethingwithconfig(context); |
| 160 | +``` |
| 161 | +上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此时线程2会以为初始化工作已经完成, |
| 162 | +那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。 |
| 163 | +从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。 |
| 164 | + |
| 165 | + |
| 166 | + |
| 167 | +--- |
| 168 | +- 邮箱 :charon.chui@gmail.com |
| 169 | +- Good Luck! |
| 170 | + |
| 171 | + |
0 commit comments