二 Java并发编程 ——volatile关键字

文章目录3、 不保证原子性4、禁止指令重排
前言
在理解之前,要知道什么是JMM,这个可以看我写过的Java内存模型 。
1、简述
是jvm提供的轻量级的同步机制,它有3个特性:
(1)保证可见性
(2)不保证原子性
(3)禁止指令重排
2、 保证可见性
我们再来回顾下可见性的定义:
指当多个线程访问同一个变量时,一个线程修改了这个变量的值 , 其他线程能够立即看得到修改的值 。
从JMM内存模型来看,可见性是指多线程访问主内存的某一个资源时,如果某一个线程在自己的工作内存中修改了该资源,并写回主内存,那么JMM内存模型应该要通知其他线程来从新获取最新的资源,来保证最新资源的可见性 。
2.1.是如何保证可见性的?
对于关键字修饰的变量,当对变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中 。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下 , 为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议 。
缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候 , 会强制重新从系统内存里把数据读到处理器缓存里 。
所以,如果一个变量被所修饰的话,在每次数据变化之后,其值都会被强制刷入主存 。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中 。这就保证了一个在并发编程中,其值在多个缓存中是可见的 。
既然CPU有了MESI协议可以保证cache的一致性,那么为什么还需要这个关键词来保证可见性(内存屏障)?或者是只有加了的变量在多核cpu执行的时候才会触发缓存一致性协议?
两个解释结论:
多核情况下,所有的cpu操作都会涉及缓存一致性的校验,只不过该协议是弱一致性,不能保证一个线程修改变量后,其他线程立马可见,也就是说虽然其他CPU状态已经置为无效,但是当前CPU可能将数据修改之后又去做其他事情 , 没有来得及将修改后的变量刷新回主存 , 而如果此时其他CPU需要使用该变量 , 则又会从主存中读取到旧的值 。而则可以保证可见性,即立即刷新回主存 , 修改操作和写回操作必须是一个原子操作;
正常情况下,系统操作并不会进行缓存一致性的校验 , 只有变量被修饰了,该变量所在的缓存行才被赋予缓存一致性的校验功能 。
2.2.不使用修饰例子
先来验证下,不使用修饰是不是就没有可见性了 。
public class VolatileDemo4 {public static int a = 0;public synchronized static void addA() {a++;}public static void main(String[] args) throws InterruptedException {new Thread(() -> {addA();System.out.println(Thread.currentThread().getName() + " 子线程更新后的值为:" + a);}).start();while (a == 0) {}System.out.println("主线程执行完毕!具有可见性");}}
运行后结果如下,可以发现虽然子线程将a的值加了1,但是主线程仍然处于while循环中,说明a++在子线程执行运算后没有马上更新到主内存中 , 此时主线程去主内存读取到的值是0,因此会一直处于while循环中,最后得出的结论就是a不具有可见性 。
2.3.使用修饰例子
public class VolatileDemo4 {public static volatile int a = 0;public synchronized static void addA() {a++;}public static void main(String[] args) throws InterruptedException {new Thread(() -> {addA();System.out.println(Thread.currentThread().getName() + " 子线程更新后的值为:" + a);}).start();while (a == 0) {}System.out.println("主线程执行完毕!具有可见性");}}
执行后结果如下,发现a值已经被更新为1,主线程执行完毕,具有可见性 。
除了关键字,还有两个关键字和 final也能保证可见性 。但是这篇文章主要介绍的是,就不做详细验证了 。
3、 不保证原子性
我们再来回顾下原子性的定义:
一个不可再被分割的颗粒,原子性指的是一个或多个操作要么全部执行成功要么全部执行失败 , 期间不能被中断,也不存在上下文切换,线程切换会带来原子性的问题 。
3.1.产生原子性问题
我们经常网上能看到这样一句话:对基本数据类型的变量的读取和赋值操作是原子性操作(包括byte、short、int、float、、char),但是a++和a +=1以及变量之间的相互赋值就不是原子性操作 。但是基本都没有分析发生这种现象的具体原因,接下来我就通过javap -c反汇编指令来分析下 。我们先来看下下面这段代码 。
public class VolatileDemo1 {public static volatile int a = 0;public static volatile int b = 0;//操作Aprivate static void putA() {a = 1; //基本数据变量的赋值操作 , 这里的赋值是数字的赋值,不是变量之间的相互赋值}//操作Bpublic static void aPutB() {b = a;}//操作Cpublic static void addA() {a++;}//操作Dpublic static void aAddAndPutA() {a += 1;}public static void main(String[] args) {}}
编译后再通过javap -c反汇编执行结果如下:
我们先来看下操作C,也就是a++为什么不是原子性的 。上面的汇编里有这样一段代码:
可以发现a++可以拆分成3个指令:
(1)执行获取a的初始化值
(2)执行iadd对a累加1操作
(3)执行把累加后的a值写回
如果操作C在当线程中执行不会存在问题,但是在多线程中就会存在问题 。假设有线程A和B , 线程A执行到指令2 , 指令3未执行,马上线程B也执行了指令1,这样线程A和B都执行完所有指令 。最后的结果就是a的值为1 , 因为线程A执行add还未put操作的时候线程B就执行了get操作,此时线程B取到的是旧值,从而导致线程B最终put的值还是1 。
同理a+=1跟a++一样也是分3个指令步骤 。
变量间赋值b=a可以拆分成2个指令:
(1)执行获取a的初始化值
(2)执行把累加后的a值写回
还是线程A和B,当线程A执行到指令1,指令2未执行,马上线程B取了b值去做其他业务 。最后导致线程B取到的b值是0,而不是赋值后的1 。
最后说下基本数据变量的赋值操作为什么是原子性,从上面的汇编结果可以看出,他没有执行任何有关的get或put指令 , 也就不存在原子性问题 。
我们接着用代码去验证a++为什么不保证原子性,开10个线程,每个线程对a累加10000 , 期望的结果是,代码如下:
public class VolatileDemo2 {public static volatile int a = 0;public static void addA() {a++;}public static void main(String[] args) throws InterruptedException {final CountDownLatch latch = new CountDownLatch(10); //latch 10个线程 , 目的是为了计数for (int i = 0; i < 10; i++) {new Thread(() -> {for (int j=0; j<10000; j++) {addA();}System.out.println(Thread.currentThread().getName() + " 子线程执行完毕!");latch.countDown(); //让latch中的数值减1}).start();}try {latch.await(); //阻塞当前main主线程,直到latch中的值为0} catch (InterruptedException e) {e.printStackTrace();}System.out.println("主线程执行完毕!a的结果为:" + a);}}
运行结果如下,最后a的结果是98089,不是我们预期的,即使我们加了关键字修饰,依然不能保证原子性 。
3.2.如何解决原子性问题
通过关键字和CAS可以解决原子性问题
3.2.1.关键字
上面的addA方法添加关键字修饰,代码如下:
public class VolatileDemo2 {public static volatile int a = 0;public synchronized static void addA() {a++;}public static void main(String[] args) throws InterruptedException {final CountDownLatch latch = new CountDownLatch(10); //latch 10个线程,目的是为了计数for (int i = 0; i < 10; i++) {new Thread(() -> {for (int j=0; j<10000; j++) {addA();}System.out.println(Thread.currentThread().getName() + " 子线程执行完毕!");latch.countDown(); //让latch中的数值减1}).start();}try {latch.await(); //阻塞当前main主线程,直到latch中的值为0} catch (InterruptedException e) {e.printStackTrace();}System.out.println("主线程执行完毕!a的结果为:" + a);}}
我们再看下结果,是我们预期的值了
3.2.1.CAS方式
java.util...* 包中所有类,我们以为例 , 可以发现执行结果与上面的一样 。
public class VolatileDemo3 {public static volatile AtomicInteger a = new AtomicInteger();public static void addA() {a.getAndIncrement();}public static void main(String[] args) {final CountDownLatch latch = new CountDownLatch(10); //latch 10个线程 , 目的是为了计数for (int i = 0; i < 10; i++) {new Thread(() -> {for (int j=0; j<10000; j++) {addA();}System.out.println(Thread.currentThread().getName() + " 子线程执行完毕!");latch.countDown(); //让latch中的数值减1}).start();}try {latch.await(); //阻塞当前main主线程,直到latch中的值为0} catch (InterruptedException e) {e.printStackTrace();}System.out.println("主线程执行完毕!a的结果为:" + a);}}
4、禁止指令重排 4.1.什么是指令重排?
在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序 。一般重排序可以分为如下三种:
(1)编译器优化的重排序 。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
(2)指令级并行的重排序 。现代处理器采用了指令级并行技术来将多条指令重叠执行 。如果不存在数据依赖性(这里的数据依赖性只是在单个处理器中执行的指令序列和单个线程中执行的操作,对于不同处理器和不同线程之间的数据依赖性不被编译器和处理器考虑),处理器可以改变语句对应机器指令的执行顺序;
(3)内存系统的重排序 。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的 。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 。所以在多线程下,指令重排可能会导致一些问题 。
从 Java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
Java 编译器禁止处理器重排序是通过在生成指令序列的适当位置会插入内存屏障(重排序时不能把后面的指令重排序到内存屏障之前的位置)指令来实现的 。
4.2.单例模式验证禁止指令重排 4.2.1.单例模式—饿汉式(单线程)
饿汉式:在程序启动或单例模式类被加载的时候,单例模式实例就已经被创建 。
public class SingletonDemo1 {private static SingletonDemo1 instance = new SingletonDemo1();private SingletonDemo1() {System.out.println(Thread.currentThread().getName() + "执行构造方法SingletonDemo1()");}public static SingletonDemo1 getInstance() {return instance;}public static void main(String[] args) {SingletonDemo1 s1 = SingletonDemo1.getInstance();SingletonDemo1 s2 = SingletonDemo1.getInstance();System.out.println(s1==s2);}}
4.2.2.单例模式—懒汉式(单线程)
public class SingletonDemo2 {private static SingletonDemo2 instance = null;private SingletonDemo2() {System.out.println(Thread.currentThread().getName() + "执行构造方法SingletonDemo1()");}public static SingletonDemo2 getInstance() {if (instance == null) {instance = new SingletonDemo2();}return instance;}public static void main(String[] args) {SingletonDemo2 s1 = SingletonDemo2.getInstance();SingletonDemo2 s2 = SingletonDemo2.getInstance();System.out.println(s1==s2);}}
4.2.3.单例模式—饿汉式(多线程)
public class SingletonDemo3 {private static SingletonDemo3 instance = new SingletonDemo3();private SingletonDemo3() {System.out.println(Thread.currentThread().getName() + "执行构造方法SingletonDemo3()");}public static SingletonDemo3 getInstance() {return instance;}public static void main(String[] args) {for (int i=1; i<=10; i++) {new Thread(() -> {SingletonDemo3.getInstance();}).start();}}}
执行结果如下,无论执行多少次,发现只会new一次构造方法,说明饿汉是多线程下是线程安全的 。
4.2.4.单例模式—懒汉式(多线程)
public class SingletonDemo4 {private static SingletonDemo4 instance = null;private SingletonDemo4() {System.out.println(Thread.currentThread().getName() + "执行构造方法SingletonDemo3()");}public static SingletonDemo4 getInstance() {if (instance == null) {instance = new SingletonDemo4();}return instance;}public static void main(String[] args) {for (int i=1; i<=10; i++) {new Thread(() -> {SingletonDemo4.getInstance();}).start();}}}
多执行几次,发现多线程情况下出现了执行2次构造函数,这样会造成会有2个不同的对象,从而产生线程不安全现象 。
4.2.5.单例模式—双端检索机制(多线程)
在4.2.4中多线程懒汉式单例模式验证中,发现是线程不安全的 , 这里我们将它演变为双端检索机制(DCL),即在锁前和锁后都进行检查 。
public class SingletonDemo5 {private static SingletonDemo5 instance = null;private SingletonDemo5() {System.out.println(Thread.currentThread().getName() + "执行构造方法SingletonDemo3()");}public static SingletonDemo5 getInstance() {if (instance == null) {synchronized (SingletonDemo5.class) {if (instance == null) {instance = new SingletonDemo5();}}}return instance;}public static void main(String[] args) {for (int i=1; i<=10; i++) {new Thread(() -> {SingletonDemo5.getInstance();}).start();}}}
多次执行 , 发现构造方法只new了一次 。
上面从输出结果来看,DCL版单例模式看起来是线程安全的,但是上面的方法还是存在问题 。DCL版单例模式不一定是线程安全带 , 原因就是指令重排的存在 。接下来让我们分析下这是为什么?
查看= new ();编译后的指令,可以分为以下三个步骤:
(1)分配对象内存空间: = ();
(2)初始化对象:();
(3)设置指向分配的内存地址: = ;
通过上面三个步骤 , 可以发现步骤2和步骤3不存在数据依赖关系,因此可能出现132这种情况,此时重排序优化顺序是这样的:
(1)分配对象内存空间: = ();
(2)设置指向分配的内存地址: = ;
(3)初始化对象:();
那么这会在成什么问题呢?比如线程A执行了重排序后的步骤12 , 还未初始化对象 , 此时!=null;这个时候线程B抢占了CPU的资源,发现!=null,然后直接返回使用,发现为空,就会抛出空指针异常,从而导致线程不安全想象 。
从上面分析得出结果:指令重排可以保证串行语义一致 , 但是没有义务保证多线程间的语义也一致 。
4.2.6.单例模式—双端检索机制 + (多线程)
【二Java并发编程 ——volatile关键字】验证了4.2.5发现还是存在线程安全问题,针对这种现象 , 我们引入关键字来禁止指令重排 , 代码如下:
public class SingletonDemo6 {private static volatile SingletonDemo6 instance = null;private SingletonDemo6() {System.out.println(Thread.currentThread().getName() + "执行构造方法SingletonDemo6()");}public static SingletonDemo6 getInstance() {if (instance == null) {synchronized (SingletonDemo6.class) {if (instance == null) {instance = new SingletonDemo6();}}}return instance;}public static void main(String[] args) {for (int i=1; i<=10; i++) {new Thread(() -> {SingletonDemo6.getInstance();}).start();}}}
那么是怎么禁止指令重排呢?
这就要涉及到一个概念:内存屏障( ),又称为内存栅栏 。它是一个CPU指令,有2个作用:
(1)保证某些特定操作的执行顺序
(2)保证某些变量的内存可见性
编译器和处理器都可以进行指令重排,那么如果我们在程序中插入一条 (内存屏障),那么就会告诉编译器和cpu不能对这条指令进行重排 , 也就是说通过插入内存屏障 , 使屏障前后的指令不会进行重排优化,内存屏障还可以强制刷出cpu的缓存,因此cpu上的线程都能读到这些数据的最新版本 。
简单来说就是插入内存屏障后告诉cpu和编译器,这个内存屏障前后的指令你不要给我进行重排序 。
java规范下抽象出以下四种内存屏障:
(1) :作用在两个读(Load)操作之间内存屏障 。
(2) :作用在两个Store 操作之间的内存屏障 。
(3) :作用在 Load 操作和Store 操作之间的内存屏障 。
(4) :作用在 Store 操作和 Load 操作之间的内存屏障 。