沉默的背影 X-Pacific

keep learning

volatile引发的一系列血案

最早读《深入理解java虚拟机》对于volatile部分就没有读明白,最近重新拿来研究并记录一些理解

理解volatile前需要把以下这些概念或内容理解:

1、JMM内存模型

2、并发编程的三问题:原子性、一致性、有序性

3、先行发生原则

然后我们结合上面的几个知识点来看volatile如何使用

JMM内存模型

先看一下上面这张图片,即Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存

那么JMM为何要如此设计?其主要原因有两点:1、达到各平台访问内存效果的一致性 2、提升数据访问速度

对于提升数据访问速度,主要用到了CPU高速缓存这部分内容:计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存

在本文中,JMM能够帮助我们理解为什么会发生可见性问题

并发编程的三问题:原子性、可见性、有序性

原子性问题

原子性指:一个操作执行时不能被打断或插入

比如i++,JVM指令包括3个操作:读取x的值,进行加1操作,写入新的值,如果并发执行i++,可能这三步操作不同线程会穿插执行,原子性就是指,任何一个线程运行这三个操作时,其他线程不能进入运行这三步操作

如何解决原子性问题:
1、synchronized 2、Lock、其他锁

可见性问题

每个线程都有各自的工作内存(高速缓存、详见JMM),线程A更改了变量的值后,线程B从自己的工作内存中获取变量的值还可能是A修改前的值

如何解决可见性问题:

1、volatile关键字 2、Lock、synchronized

有序性问题

先看下什么是指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。如果程序不满足先行发生原则,那么可能发生指令重排

指令重排就影响了程序的有序性

如何解决有序性问题
1、volatile关键字 2、Lock、synchronized

从上面的三个问题来看volatile只能解决:可见性问题、有序性问题,但无法解决原子性问题,原子性问题仍需要锁的手段才能解决

先行发生原则 Happens-Before

先行发生原则(Happens-Before)是判断数据是否存在竞争、线程是否安全的主要依据,先行发生原则,可以帮你判定是否并发安全的,从而不必去猜测是否是线程安全了

下面是Java内存模型中一些“天然的”先行发生关系,这些先行发生关系无需任何同步协助器协作java自带这些规则,可以直接在编码中使用。如果两个关系不在此列,而又无法通过这些关系推导出来,它们的顺序就无法保证,虚拟机可以对它们任意重排序

程序次序规则: 同一个线程内,按照代码出现的顺序,前面的代码 happens-before 后面的代码,准确的说是控制流顺序,因为要考虑到分支和循环结构。
管程锁定规则: 对于一个监视器锁的unLock操作 happens-before 于每个后续对同一监视器锁的Lock操作。
volatile变量规则: 对volatile域的写入操作 happens-before 于每个后续对同一个域的读操作。
线程启动规则: 在同一个线程里,对Thread.start的调用会 happens-before 于每一个启动线程中的动作。
线程终结规则: 线程中的所有动作都 happens-before 于其它线程检测到这个线程已经终结,或者从Thread.join()调用成功返回,或者Thread.isAlive返回false.
中断规则: 一个线程调用另一个线程的interrupt happens-before 于被中断的线程发现中断(通过抛出InterruptedException 或者调用isInterrupted和interrupted)
终结规则: 一个对象的构造函数的结束 happens-before 于这个对象finalizer的开始
传递性: 如果 A happens-before 于 B,且 B happens-before 于 C,则 A happens-before 于C。

其中比较重要且难以理解的几条是:

程序次序规则

一段程序代码的执行在单个线程中看起来是有序的。虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性

管程锁定规则

一个unlock操作先行发生于后面(时间上)对同一个锁的lock操作,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作

volatile变量规则

对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读操作,如果线程1写入了volatile变量v,接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程),可以看成是volatile解决可见性问题的描述

总结下来就是先行发生原则可以确定两件事:

1、能帮助我们判断程序是否线程安全

2、能帮助我们确定程序是否可能发生指令重排

 使用volatile

有了以上知识储备我们来看一下volatile如何正确的使用

1、多读单写

只有一个线程控制改变volitile变量的值,一个或多个线程并发读取volitile变量的值都可以用volitile

通常:线程开关或者状态标记的场景可以使用

因为可见性保证了volatile多读单写的能力,但又因为volatile没有解决原子性问题的能力,所以不是多读多写

public static volatile boolean flag = false;
//这种情况不添加volatile就有可能造成无法退出程序了
//添加了volatile就强制从主内存中获得值,就不会出现上述问题了
//这个例子体现:只有一个线程控制改变volatile变量的值 很多线程并发读取volitile变量的值都可以用volatile
new Thread(() -> {
    while(!flag){
    }
    System.out.println("退出了");
}).start();
try {
    Thread.sleep(3000);
} catch (InterruptedException e) {
    e.printStackTrace();
}
System.out.println("setup");
flag = true;
//特别说明:我测试flag是非volatile,当不在while(!flag){上sleep,会一直循环,这种非常可能拿不到更改后的值,一直从工作内容中获得缓存值false。

2、防止指令重排

防止指令重排,通常:单例懒汉模式 double-check中使用

public class LazySingleton {
    private volatile static LazySingleton lazySingleton = null;
    private LazySingleton(){
    }
    public static LazySingleton getInstance(){
        if(lazySingleton == null){
            synchronized (LazySingletonill.class){
                if (lazySingleton == null) {
                    lazySingleton = new LazySingleton();
                }
            }
        }
        return lazySingleton;
    }
    public static void main(String[] args){
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                System.out.println(LazySingleton.getInstance().hashCode());
            }).start();
        }
    }
}

我们来看一下为什么不加volatile会引发指令重排的问题:

首先,这个出现问题的概率并不高,并且我通过jdk8的版本反编译并未和帖子内容一致,姑且先把帖子的原理写一下:

instance = new LazySingleton();,其实JVM内部已经转换为多条指令:
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址

但是经过指令重排序后如下:

memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址,此时对象还没被初始化
ctorInstance(memory); //2:初始化对象

2、3步骤指令重排后发生了交换

 假如线程A获得了锁并且正在执行lazySingleton = new LazySingleton();,这个实例化的jvm指令发生了重排,即instance = memory先于ctorInstance(memory)执行,刚好instance = memory执行完毕,线程B登场在执行if(lazySingleton == null){时为false,线程B return了一个没有初始化对象的实例出去,出现了返回不正确结果的现象

 
发个牢骚:这个单例写法真的太矫情了,另外这种懒汉模式double-check写法演化问题分析详见:
https://gitee.com/zxporz/zxp-thread-test/blob/master/src/main/java/org/zxp/thread/volatileTest/singleton/LazySingletonill.java
 
posted @ 2019-05-11 14:19  乂墨EMO  阅读(374)  评论(0编辑  收藏  举报