文章预览
线程安全问题概括的说表现为3个方面:
原子性
、
可见性
和
有序性
原子性
原子操作是多线程环境下的一个概念,它是针对共享变量的操作而言的。原子操作的“不可分割”包括以下两个含义。
访问(读、写)某个共享变量的操作从其他执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会看到该操作执行了部分的中间效果。
访问同一组共享变量的原子操作死不能够被交错的。
总的来说Java通过良好总方式来实现原子性。一种是使用
锁
,另一种是利用处理器提供的
CAS指令
。
1 class Example{
2 private HostInfo hostInfo;
3 public void changeHostInfo(String ip, int port){
4 hostInfo.setIp(ip);//这里的操作不是原子操作
5 hostInfo.setPort(port);
6 }
7 public void connectToHost(){
8 String ip = hostInfo.getIp();
9 int port = hostInfo.getPort();
10 connect(ip, port);
11 }
12}
13class HostInfo{
14 private String ip;
15 private int port;
16}
上面的代码如果一个线程T1正在执行changHostInfo(),更新了ip还没更新port,同时另一个线程T2在执行connectToHost()这个时候port可能是旧的,就会有线程安全问题。
如果connectToHost()里面的操作能够保证原子性,就消除了导致竞太的可能性。
简单的修改,就是第4行,
Java语言中对于任何变量的读都是原子的,且对变量(除了long/double型变量除外)写操作(赋值操作)是原子性
。
1 class Example{
2 private HostInfo hostInfo;
3 public void changeHostInfo(HostInfo hostInfo){
4 this.hostInfo = hostInfo; //这个是原子操作
5 }
6 public void connectToHost(){
7 String ip = hostInfo.getIp();
8 int port = hostInfo.getPort();
9 connect(ip, port);
10 }
11}
12class HostInfo{
13 private String ip;
14 private int port;
15}
需要注意的是原子操作+原子操作,这中复合操作并非原子操作,多线程环境下就有线程安全问题。
可见性
处理器并不直接与主内存打交道,而是通过寄存器、高速缓存、写缓冲器和无效化队列等部件执行内存的读、写操作的。从这个角度看,这些组件相当于主内存的副本,可以统称为处理器缓存。
这就导致在多线程环境下,一个线程对共享变量更新后,后续访问该变量的线程可能无法立刻读到这个更新的结果,这就是可见性问题。
Volatile的作用就是提示JIT编译器被修饰的变量可能被多个线程共享,从而
禁止指令重排序
;另一个作用就是volatile读会刷新处理器缓存,即在读取该volatile变量时,如果该变量被其他线程修改,则当前线程会从主内存中同步被更新的值;volatile写会冲刷处理器缓存,保证更新的值会刷新到主内存。
此外要注意如果一个线程在同一时间更新了多个共享变量的值,即使这些值都是volatile的,另一个线程读取这些变量的时候,有些值并不是最新的。这些操作要通过额外的同步操作,保证原子性。
所以从线程安全角度来说,
光保证原子性是不够的,有时候还要保证可见性
,这样才能保证一个线程能够正确的获取到其他线程对共享变量的更新。
1 public class Test {
2 private volatile int count = 0;
3 public void setCount(int count) {
4 this.count = count;
5 System.out.println(this.count);
6 }
7 public static void main(String[] args) throws InterruptedException {
8 Test test = new Test();
9 new Thread(() -> {
10 while (true) {
11 if (test.count == 2) {
12 System.out.println("need to break");
13 break;
14 }
15 }
16 }).start();
17 for (int i = 0; i < 10; i++) {
18 Thread.sleep(10);
19 test.setCount(i);
20 }
21 }
22}
第二行不加volatile
0
1
2
3
4
第二行加volatile
0
1
2
need to break
3
4
有序性
重排序是对内存访问的操作(读和写)所做的一种优化,使得代码执行顺序和指定的顺序不一致。
重排序的来源有很多,包括编译器JIT,处理器和存储子系统
指令重排序
主要可能发生在JIT,比如说Object o = new Object();
可以分解为:
1 objRef = allocate(Object.class)分配Object实例所需要的内存空间,并获得一个指向该空间的引用
2 invokeConstructor(objRef)调用Object的构造器初始化objRef所指向的Object实例
3 o = objRef 将实例引用objRef赋值给实例变量o
在这个系列的操作中,JIT可能将2对应的指令重排序到3对应的指令,这就导致其他线程可能能够看到实例变量o,但是o对应的对象可能还没被初始化,或者未初始化完毕。
除此JIT导致指令重排序之外,
处理器也有可能导致指令重排序
,有些乱序执行的结果会先放入ROB(重排序缓冲器),ROB会将处理结果按处理器读取指令的顺序提交到主内存,此外处理器乱序执行还会采用猜测执行的技术(Speculation)比如后面介绍的控制依赖会涉及到处理器导致的指令重排序。
内存重排序
指令重排序的重排序对象是指令,实实在在的对指令顺序进行调整;内存重排序的对象是内存操作的结果,尽管可能此时程序顺序和执行顺序都是一致的,但在处理器缓存的作用下,处理器感知顺序可能出现重排序
分为LoadLoad,LoadStore,StoreStore,StoreLoad这几种类型。
类似StoreStore重排序,Processor0可能将S2执行重排序到S1之前,那么导致Processor1的代码发现ready是true,但是data还是0。
那么这是变量之间没有依赖的,
为了保证“貌似串行语义”
,如果变量之间有
数据依赖
,则重排序不会被允许,所以
只有不存在数据依赖关系的语句才会被重排序
,比如:
1 写后读:x=1, b=x+1即后一条语句包含前一条语句的执行结果
2 读后写:y=x, x=1即前一条语句读取变量后,后一条语句更新了该变量
3 写后写:x=1, x=2即两条语句更新统一个变量
1 int a = 10;
2 int b = 30;
3 int c = a * b; 注意a*b是有个读取值的动作
上面代码简单的可以看做5个动作
1 对a赋值10
2 对b赋值30
3 读取a的值
4 读取b的值
5 计算a*b的值然后赋值给c
这里语句1和2, 4可以尽心重排序,但是1, 3, 5不能重排序;同理2和1, 3可以重排序,但是2和4, 5不能重排序
另外要注意的是存在
控制依赖
的语句是可以被重排序的(上面提到的处理器猜测执行,重排序)
1 public class Test {
2 private int a = 0;
3 private boolean result = false;
4 public void write() {
5 a = 1;
6 result = true;
7 }
8 public void read() {
9 if(result){
10 int t = a * a;
11 }
12 }
13}
代码里5, 6行没有数据依赖是可以任意重排序的,9, 10行没有数据依赖,但是有控制依赖,也是会重排序的。
JIT和处理器会采用在if代码块之外先计算a*a的值,但是赋值给一个临时变量tmp,在做完if判断之后,可以直接将tmp的值赋给t
控制依赖会影响指令序列的并行度,处理器采用猜测执行来克服控制依赖带来的并行度的影响;处理器
猜测执行Speculation
可以提前读取并计算a
a,然后保存结果到
重排序缓冲ROB
*的硬件缓存中,如果if的判断为真,就把计算结果写到变量t中。
………………………………