并发编程实战01---可见性,原子性,有序性问题


前言:

由于CPU,内存,i/o设备之间的速度存在差异,为了提高CPU的使用效率,平衡这三者之间的速度差异,计算机体系结构,操作系统,编译程序都做了相应的优化,主要体现在以下三方面:

       1-CPU增加了缓存,以均衡与内存之间的速度差异;---可见性

        2-操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU与I/O设备之间的速度差异;---原子性

        3-编译程序优化指令执行顺序,使得缓存能够更合理的能利用;---有序性

上述优化虽然提高了cpu的使用效率,但也带来了并发编程一些诡异的bug(引起了可见性,原子性,有序性等问题)

源头之一:缓存导致的可见性问题


单线程---CPU缓存与内存的关系

单核时代,所有的线程都在同一颗CPU上运行,操作的是同一块缓存区域,CPU缓存和内存的一致性容易解决。线程A修改了变量V的值,对于线程B来说是可见的。

可见性的定义:一个线程对共享变量的修改,对另一个线程能够立即看见。、


多线程---CPU缓存与内存的关系

多核时代,不同的CPU有各自的缓存。当不同的线程运行在不同的CPU上,他们操作的是各自的CPU缓存,例如线程A改变了自己CPU缓存中V的值,对于线程B来说,这个操作是不可见的。

源头之二:线程切换带来的原子性问题


线程切换示意图

由于IO速度太慢,早期的操作系统发明了多进程,操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行,这个50毫秒就称为“ 时间片 ”。

Java并发程序是基于多线程的,所以也会涉及到任务切换。任务切换的大多数情况是在时间片结束的时候。

我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令来完成,例如 count +=1,就至少需要3条指令:

指令一:首先,把count变量重内存加载到CPU寄存器

指令二:在寄存器里执行 +1 操作

指令三:将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)

操作系统任务切换,可以发生在任意一条指令结束的时候。


非原子操作的执行示意图

原子性:一个或者多个操作在CPU执行的过程中不被中断的特性。

源头之三:编译优化带来的有序性问题 

编译器为了优化性能,会改变程序中语句的执行顺序。例如 “a=6 ; b=7”,编译器优化后可能会变成“b=7 ; a=6 ”

在 Java 领域一个经典的案例就是利用双重检查创建单例对象。例如下边的代码:


单列模式-双重检查

以上的代码在并发访问的时候会有问题:假设线程A和线程B同时调用getInstance()方法,两个线程都发现instance对象为空,于是两个线程会去争抢Singleton.class这个锁,此时JVM会保证只有一个线程能获取到锁,假设该线程为A,此时线程B就会进入等待状态,然后线程A会创建Singleton对象,此时线程B会被唤醒,它会再次去检验instance对象,此时就会出现问题。原因在于 new对象这个过程,它会被编译器优化成三个步骤:

1.分配一块内存M

2.把内存M的地址值指向变量instance

3.在内存M中初始化变量instance

我们以为的new操作过程:

1.分配一块内存M

2.在内存M中执行初始化变量instance

3.把内存M的地址值指向变量instance


双重检查-异常执行过程

instance变量用volatile修饰即可解决上述问题。

你可能感兴趣的:(并发编程实战01---可见性,原子性,有序性问题)