并发三大特性底层硬件分析
原子性
概念
原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何的线程切换,它不一定是一条指令,可以是多条指令。
底层分析
java语言中执行的一段简单的代码往往需要多条CPU的指令实现,比如count++这部分代码,至少需要三条CPU指令:
1.首先把count从内存中读取到CPU的寄存器中
2.在寄存器中执行+1操作
3.最后将count的值写入内存中(可能写入到CPU的缓存中)
而线程切换是可以发生在任意的一条CPU指令执行之后的,注意,这里说的是CPU的指令,而不是java语言中的指令,对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的顺序执行,那么我们会发现两个线程都执行了 count++ 的操作,但是得到的结果不是我们期望的 2,而是 1。
这就是线程切换导致的数据错误问题,我们把**一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性,**CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
扩展问题
在JAVA规范规定所有变量的写操作都是原子的。
比如 :int a = 1, boolea flag = true ,int b = a
像一些复杂的操作入 i++,先读取i的值在更新i的值,里面有计算过程的。在java规范里是不保证原子性的。
32位Java虚拟机中的long和double变量写操作为何不是原子的?
long和double都是64位的,在32位java虚拟机中,在多线程并发执行修改一个变量的时候,有的线程会修改该变量的高32位或者低32位,会导致二进制转十进制的结果不准确。
使用volatile修饰可以保证long和double的原子性,这是java规范规定的,但也仅限于在32位虚拟机中。
可见性
底层分析
三层面导致的可见性问题?
- 寄存器里的变量修改,其它处理器无法看到
- 一个处理器运行的线程对变量的写操作都是针对写缓冲器来的并不是直接更新主内存,所以很可能导致一个线程更新了变量,但是仅仅写在了缓冲区里罢了,没有更新到主内存里去。这时其它处理器的线程是没法督导他的写缓冲区里变量值的
- 即使这个时候一个处理器的线程更新了写缓冲区之后,将更新同步到了自己的高速缓存里或者主内存,然后还把这个更新通知给了其它处理器,但是其他处理器可能就是把这个更新放到无效队列里去,没有更新他的告诉缓存,此时其它处理器的线程从告诉缓存里读数据的时候 读到的还是过时的旧值。
重排序
概念
在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序.
底层分析
代码编译的重排序
JAVA有两种编译器,一个是静态编译器javac,一个是动态编译器JIT。javac负责把java文件中的源代码编译为.class文件中的字节码,这个一般是程序写好之后进行编译的。JIT负责把.class文件中的字节码编译为JVM所在操作系统支持的机器码,一般在程序运行过程中进行编译。在这个编译的过程中,编译器是很有可能调整代码的执行顺序的,为了提高代码的执行效率,很可能会调整代码的执行顺序。JIT编译器对指令重排还是挺多的。
处理器的指令重排序
处理器在执行多个指令时,可以改变指令的执行顺序
指令乱序机制:
指令不一定说是拿到了一个指令立马可以执行的,比如有的指令是要进行网络通信,磁盘读写,获取锁等等,不是立马执行的,为了提升效率,在现代处理器里指令都是走的指令乱序执行的机制。把编译好的指令一条一条读取到处理器里,哪个指令先就绪可以执行,就先执行,不是按照代码顺序来的。每个指令结果放到一个重排序处理器中把各个指令的结果在按照代码顺序应用到主内存或者写缓冲器里。
猜测执行:
比如说if判断中的代码,很可能先去执行if里的代码算出来结果后,然后在来判断if是否成立。
内存重排序
有可能在一个处理器的实际执行指令过程中,在高速缓存和写缓冲器中的执行顺序有变化,导致其它处理器看到是乱序的。
Store 操作是是处理器将计算好的数据写入写缓冲器。Load 操作是处理器读取高速缓存里的数据。由此会分为4中内存重排序:
-
Store Store
处理器1执行W1写操作---->再执行W2写操作其他处理器看到的:W2---->W1
-
Load Load
处理器1执行L1–>L2其他处理器看到的:L2---->L1
-
Load Store
处理器1执行L1---->W2其他处理器看到的W2---->L1
-
Store Load
处理器1执行W1---->L2其他处理器看到的:L2---->W1
代码分析
代码说明Store Store乱序问题:
//共享变量
Resource resource = null;
Boolean isInit = false;
//处理器1
resource = new Resource();
isInit = true;
//处理器2
while(!isInit){
try{
Thread.sleep(1000);
}catch(Exception e){
}
}
resource.execute();
上述代码,表面假象处理器1应该先写了resource再写了isInit=true;但是写缓冲器很可能进行了内存重排序,结果先写了isInit=true在写了resource。此时resource还是为null。 处理器2就会看到isInit=true就会对resource对象执行execute方法。很明显会报null指针异常。
反正类似情况,高速缓存和写缓冲器都可以自己对load和store操作的结果落地到内存进行各种不同的重排序,进而造成上述4种内存重排序的发生
扩展问题
JIT编译器对创建对象的指令重排以及double check单例实践
public class DoubleCheckSingleton {
private DoubleCheckSingleton(){};
private volatile static DoubleCheckSingleton instance = null;
public static DoubleCheckSingleton getInstance(){
if(instance == null) { ①
synchronized (DoubleCheckSingleton.class) { ②
if (instance == null) { ③
instance = new DoubleCheckSingleton(); ④
}
}
}
return instance;
}
}
为什么用double check?
没有内部判断③的情况:如果线程1和线程2并发同时执行①此时两个线程的instance都是null,都会继续向下执行开始抢锁。此时不管谁抢到锁都会创建多次实例,这样就违背了对象单例。
没有外部判断①的情况:如果线程1拿到锁并执行完后创建对象实例,然后线程2在拿到锁后执行后续代码,此时线程3在执行getInstance 如果没有外部判断① 就会阻塞直到线程2执行完释放锁。所以如果不加外部判断会造成性能降低
不加volatile,重排序导致的线程安全问题
乍一看④只是对instance实例化了对象,其实在JIT动态编译时进行了三步操作。
- 给对象分配内存空间
- 执行该对象的构造函数,对这个对象实例进行初始化操作。
- 对象实例初始化后,就会把指针指向的内存地址赋值给instace引用变量
因为步骤2是个对象进行初始化这个步骤有可能是很耗时的,比如里面可能会执行一些网络通信,磁盘读写。从而JIT动态编译为了加速程序的执行速度可能会导致指令重排 步骤1->3->2。
此时在来回看④代码,如果线程1先对 instance = new DoubleCheckSingleton()时 并发生指令重排导致先执行步骤1在执行步骤3步骤2还没执行时,线程2执行到③ 会发现instance是不为空的,直接执行该对象的方法。这时候会发现instance是没有初始化好的会发生null异常。