线程安全—可见性和有序性
在并发编程中,需要处理的两个关键问题:线程之间如何通信以及线程之间如何同步。
通信是指线程之间以或者机制交换信息,java的并发采用的是共享内存模型,线程之间共享程序的公共状态,通过读写内存中的公共状态进行隐式通信。
同步是是指程序中用于控制不同线程间操作发生相对顺序的机制。
最开始首先应该知道计算机中的缓存在其中起的作用
CPU Cache(高速缓存):由于计算机的存储设备与处理器的处理设备有着几个数量级的差距,所以现代计 算机都会加入一层读写速度与处理器处理速度接近相同的高级缓存来作为内存与处理器之间的缓冲,将运 算使用到的数据复制到缓存中,让运算能够快速的执行,当运算结束后,再从缓存同步到内存之中,这 样,CPU就不需要等待缓慢的内存读写了。
主(内)存:一个计算机包含一个主存,所有的CPU都可以访问主 存,主存比缓存容量大的多(CPU访问缓存层的速度快于访问主存的速度!但通常比访问内存寄存器的速度还是要慢点)
运作原理:通常情况下,当一个CPU要读取主存(RAM - Main Mernory)的时候,他会将主存中的数据读 取到CPU缓存中,甚至将缓存内容读到内部寄存器里面,然后再寄存器执行操作,当运行结束后,会 将寄存器中的值刷新回缓存中,并在某个时间点将值刷新回主存。
为什么需要CPU Cache?
答:CPU 的频率太快了,快到主存跟不上,这样在处理器时钟周期内,CPU常常需要等待主存,浪费资源。 所以cache 的出现,是为了缓解 CPU 和内存之间速度的不匹配问题 结构:cpu-> cache-> memory).
什么是java的内存模型?
每个线程之间共享变量都存放在主内存里面,每个线程都有一个私有的本地内存,本地内存是Java内存模型中抽象的概念,并不是真实存在的(他涵盖了缓存写缓冲区。寄存器,以及其他硬件的优化) 本地内存中存储了以读或者写共享变量的拷贝的一个副本。
注意:由于工作内存(缓冲区)仅对自己的处理器可见,它会导致处理器质质性内存操作的顺序可能会与内存实际的操作顺序不一致,内存的操作顺序被重排序了,这是与后面讲的指令重排序不同的另一种重排序。
线程一对共享变量的改变想要被线程二看见,就必须执行下面两个步骤:
1.编译器优化的重排序(编译器优化)
2.指令级并行重排序(处理器优化)
3.内存系统的重排序(处理器优化)
是不是所有的语句的执行顺序都可以重排呢?
什么是数据依赖性?
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖。数据依赖分下列三种类型:
名称 | 代码示例 | 说明 |
写后读 | a = 1;b = a; | 写一个变量之后,再读这个位置。 |
写后写 | a = 1;a = 2; | 写一个变量之后,再写这个变量。 |
读后写 | a = b;b = 1; | 读一个变量之后,再写这个变量。 |
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。所以,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。也就是说:在单线程环境下,指令执行的最终效果应当与其在顺序执行下的效果一致,否则这种优化便会失去意义。这句话有个专业术语叫做as-if-serial semantics (as-if-serial语义)
int num1=1;//第一行
int num2=2;//第二行
int sum=num1+num;//第三行
- 单线程:第一行和第二行可以重排序,但第三行不行
- 重排序不会给单线程带来内存可见性问题
- 多线程中程序交错执行时,重排序可能会照成内存可见性问题。
可见性分析:
导致共享变量在线程间不可见的原因:
- 线程的交叉执行
- 重排序结合线程交叉执行
- 共享变量更新后的值没有在工作内存与主内存间及时更新
答案是:不一定能看到。
由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?
执行顺序是:2 -> 3 -> 4 -> 1 (这是完全存在并且合理的一种顺序,如果你不能理解,请先了解CPU是如何对多个线程进行时间分配的)操作3和操作4重排序后,因为操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。
我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义!
package com.xidian.count; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; import com.xidian.annotations.ThreadSafe; import lombok.extern.slf4j.Slf4j; @Slf4j @ThreadSafe public class CountExample3 { // 请求总数 public static int clientTotal = 5000; // 同时并发执行的线程数 public static int threadTotal = 200; public static int count = 0; public static void main(String[] args) throws Exception { ExecutorService executorService = Executors.newCachedThreadPool(); final Semaphore semaphore = new Semaphore(threadTotal); final CountDownLatch countDownLatch = new CountDownLatch(clientTotal); for (int i = 0; i < clientTotal ; i++) { executorService.execute(() -> { try { semaphore.acquire(); add(); semaphore.release(); } catch (Exception e) { log.error("exception", e); } countDownLatch.countDown(); }); } countDownLatch.await(); executorService.shutdown(); log.info("count:{}", count); } private synchronized static void add() { count++; } }
volatile实现可见性
volatile关键字:
- 能够保证volatile变量的可见性
- 只能保证单个volatile变量的原子性,对于volatile++这种复合操作不具有原子性
深入来说:通过加入内存屏障和禁止重排序优化来实现的。
-
对volatile变量执行写操作时,会在写操作后加入一条store屏障指令
- store指令会在写操作后把最新的值强制刷新到主内存中。同时还会禁止cpu对代码进行重排序优化。这样就保证了值在主内存中是最新的。
-
对volatile变量执行读操作时,会在读操作前加入一条load屏障指令
- load指令会在读操作前把内存缓存中的值清空后,再从主内存中读取最新的值。
package com.xidian.count; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; import com.xidian.annotations.NotThreadSafe; import lombok.extern.slf4j.Slf4j; @Slf4j @NotThreadSafe public class CountExample4 { // 请求总数 public static int clientTotal = 5000; // 同时并发执行的线程数 public static int threadTotal = 200; public static volatile int count = 0; public static void main(String[] args) throws Exception { ExecutorService executorService = Executors.newCachedThreadPool(); final Semaphore semaphore = new Semaphore(threadTotal); final CountDownLatch countDownLatch = new CountDownLatch(clientTotal); for (int i = 0; i < clientTotal ; i++) { executorService.execute(() -> { try { semaphore.acquire(); add(); semaphore.release(); } catch (Exception e) { log.error("exception", e); } countDownLatch.countDown(); }); } countDownLatch.await(); executorService.shutdown(); log.info("count:{}", count); } private static void add() { count++; // 1、count 从主存中取出count的值 // 2、+1 在工作内存中执行+1操作 // 3、count 将count的值写回主存 //及时将count用vilatile修饰,每次从主存中取到的都是最新的值,可是当多个线程同时取到最新的值,执行+1操作,当刷新到主存中的时候会覆盖结果,从而丢失一些+1操作 } }
volatile实现共享变量内存可见性有一个条件,就是对共享变量的操作必须具有原子性。比如 num = 10; 这个操作具有原子性,但是 num++ 或者num--由3步组成,并不具有原子性,所以是不行的。
假如num=5,此时有线程A从主内存中获取num的值,并执行++,但在还未见修改写入主内存中,又有线程B取得num的值,对其进行++操作,造成丢失修改,明明执行了2次++,num的值却只增加了1.
-
对变量的写入操作不依赖其当前值
- 不满足:number++、count=count*5
- 满足:boolean变量、记录温度变化的变量等
-
该变量没有包含在具有其他变量的不变式中
- 不满足:不变式 low<up
综上,volatile特别适合用来做线程标记量,如下图
synchronized和volatile的比较;
- synchronized锁住的是变量和变量的操作,而volatile锁住的只是变量,而且该变量的值不能依赖它本身的值,volatile算是一种轻量级的同步锁
- volatile不需要加锁,比synchronized更加轻量级,不会阻塞线程。
- 从内存可见性角度讲,volatile读相当于加锁,volatilexie相当于解锁。
- synchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性。
注:由于voaltile比synchronized更加轻量级,所以执行的效率肯定是比synchroized更高。在可以保证原子性操作时,可以尽量的选择使用volatile。在其他不能保证其操作的原子性时,再去考虑使用synchronized。
有序性
Happens-before原则,先天有序性,即不需要任何额外的代码控制即可保证有序性,java内存模型一个列出了八种Happens-before规则,如果两个操作的次序不能从这八种规则中推倒出来,则不能保证有序性。
第一条规则要注意理解,这里只是程序的运行结果看起来像是顺序执行,虽然结果是一样的,jvm会对没有变量值依赖的操作进行重排序,这个规则只能保证单线程下执行的有序性,不能保证多线程下的有序性。
总结