目录
4.使用场景分析(单例模式双端检锁机制怎么也存在线程安全问题)
可见性,原子性,顺序性是并发编程三要素。volatile能够实现可见性,不能保证原子性,禁止指令重排保证了顺序性,在并发编程种非常常见,面试中也是常客。面试官往往会说聊下对volatile理解,当你回答完第一个问题,接下来将是接连的炮轰。
下面将从并发要素对volatile进行深入理解,懂的原理了,就可以应对相关面试的变种
1.可见性
一.了解JMM可见性需先知道主内存与工作内存的关系
由于 CPU 的处理速度很快,相比之下,内存的速度就显得很慢,所以为了提高 CPU 的整体运行效率,减少空闲时间,在 CPU 和内存之间会有 cache 层,也就是缓存层的存在。虽然缓存的容量比内存小,但是缓存的速度却比内存的速度要快得多,其中 L1 缓存的速度仅次于寄存器的速度。结构示意图如下所示:
(图-1)
Java 作为高级语言,只需要关心 JMM 抽象出来的主内存和工作内存的概念。为了更方便你去理解,可参考下图:
(图-2)
上述的图用我们日常的大白话说就是:多个线程需要去使用共享变量,但是规范是每个线程只能够直接接触到工作内存,无法直接操作主内存,而工作内存中所保存的正是主内存的共享变量的副本,主内存和工作内存之间的通信是由 JMM 控制的。
JMM 有以下规定:
(1)所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;
(2)线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;
(3) 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。
二.volatile可见性
通过分析 (图-2)可能引发不同的副本变量在彼此线程不可见行问题,也就是说线程修改 自己内存的副本变量的值其它线程不能立马感应到。 如果主内存中的共享变量加了volatile修饰,可保证线程之间变量修改了彼此可感知到。
如下例子阐释加不加volatile的区别:
不加volatile,修改了change()方法内的值,主内存的变量无法感知到
package com.yang; import java.util.concurrent.TimeUnit; public class TestVolatile { int x = 0; public void change() { this.x = 1; } public static void main(String[] args) { // 资源类 TestVolatile myData = new TestVolatile(); // AAA线程 实现了Runnable接口的,lambda表达式 new Thread(() -> { System.out.println(Thread.currentThread().getName() + "\t come in"); // 线程睡眠3秒,假设在进行运算 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } // 修改number的值 myData.change(); // 输出修改后的值 System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.x); }, "AAA").start();
while(myData.x == 0) { // main线程就一直在这里等待循环,直到number的值不等于零 } // 按道理这个值是不可能打印出来的,因为主线程运行的时候,number的值为0,所以一直在循环 // 如果能输出这句话,说明AAA线程在睡眠3秒后,更新的number的值,重新写入到主内存,并被main线程感知到了 System.out.println(Thread.currentThread().getName() + "\t mission is over"); /** * 最后输出结果: * AAA come in * AAA update number value:60 * 最后线程没有停止,并行没有输出 mission is over 这句话,说明没有用volatile修饰的变量,是没有可见性 */
} } |
程序陷入死循环如下图:
加上volatile修饰后: volatile int x = 0;工作内存的值修改后主内存的值能感应到成功退出循环。(ps:如果多线程情况下,volatile修饰后,其它子线程也是可以感知到的)
(ps:工作内存与主内存同步延迟现象导致的可见性问题, 可通过synchronized或volatile关键字解决)
2.不保证原子性
原子性:通俗的讲就是一个单位不可再分割,跟数据库中原子性一个意思。
通过前面对JMM的介绍,我们知道,各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后在写回到主内存中的。这就可能存在一个线程AAA修改了共享变量X的值,但是还未写入主内存时,另外一个线程BBB又对主内存中同一共享变量X进行操作,但此时A线程工作内存中共享变量X对线程B来说是不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。
package com.yang;
public class VolatileTest{ volatile static int i ; public static void main(String[] args) throws InterruptedException { Runnable runnable=new Runnable() { @Override public void run() { for (int j=0;j<10000;j++){ i++; } } }; Thread thread1=new Thread(runnable); thread1.start(); Thread thread2=new Thread(runnable); thread2.start(); thread1.join(); thread2.join(); System.out.println("i的最终累加结果="+i); //正常的话是20000,测了好几次,就有一次结果是20000,剩下都是小于20000 } } |
运行结果如下:
两个线程累加1+...10000,正常的话是20000,i被volatile修饰也不能保证原子性
ps:i++ 操作,表面上看只是一行代码,但实际上它并不是一个原子操作,它的执行步骤主要分为三步(第一步:读取,第二部累加,第三部保存),而且在每步操作之间都有可能被打断。
线程 1 首先拿到 i=1 的结果,然后进行 i+1 操作,但此时 i+1 的结果并没有保存下来,线程 1 就被切换走了,于是 CPU 开始执行线程 2,它所做的事情和线程 1 是一样的 i++ 操作,但此时我们想一下,它拿到的 i 是多少?实际上和线程 1 拿到的 i 的结果一样都是 1,为什么呢?因为线程 1 虽然对 i 进行了 +1 操作,但结果没有保存,所以线程 2 看不到修改后的结果。这两个线程依次递加,最后极大的可能性是小于20000(我操作很多次竟然有一次罕见的正确了等于20000,总之这样是线程不安全的)
3.禁止指令重排
指令重排理解
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重排,重排遵从一下三点规则:
1.单线程环境里面确保最终执行结果和代码顺序的结果一致
2.处理器在进行重排序时,必须要考虑指令之间的数据依赖性
3.多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
指令重排例子理解:
public void mySort() { int x = 4; int y =5; x = x + 3; y = x * x; } |
按照正常单线程环境,执行顺序是 1 2 3 4
但是在多线程环境下,可能出现以下的顺序:
(1)2 1 3 4 (2) 1 3 2 4
上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样但是指令重排也是有限制的,即不会出现下面的顺序
4 3 2 1
因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性
因为步骤 4:需要依赖于 y的申明,以及x的申明,故因为存在数据依赖,无法首先执行
volatile禁止指令重排
Volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象。
首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
(1)保证特定操作的顺序
(2)保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)
由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。
4.使用场景分析(单例模式双端检锁机制怎么也存在线程安全问题)
单例模式在多线程情况下,为了线程安全通过引入DCL Double Check Lock 双端检锁机制,但不一定是线程安全的。
通过引入DCL(Double Check Lock) 双端检锁机制就是在进来和出去的时候,进行检测。
核心如下代码看起来没啥问题,运行了几次结果都对
public class SingletonDemo { private static SingletonDemo instance = null; private SingletonDemo () { System.out.println(Thread.currentThread().getName()+"\t 我是构造方法"); } public static SingletonDemo getInstance() { if(instance == null) { // 同步代码段的时候,进行检测 synchronized (SingletonDemo.class) { if(instance == null) { instance = new SingletonDemo(); } } } return instance; } public static void main(String[] args) { for (int i = 0; i < 10; i++) { new Thread(() -> { SingletonDemo.getInstance(); }, String.valueOf(i)).start(); } } } |
但是可能会存在某一个线程执行到第一次检测的时候,读取到 instance 不为null,instance的引用对象可能没有完成实例化。因为 instance = new SingletonDemo();可以分为以下三步进行完成:
memory = allocate(); // 1、分配对象内存空间
instance(memory); // 2、初始化对象
instance = memory; // 3、(instance指向刚刚分配的内存地址,此时instance != null)
但是我们通过上面的三个步骤,能够发现,步骤2 和 步骤3之间不存在 数据依赖关系,而且无论重排前 还是重排后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。
memory = allocate(); // 1、分配对象内存空间
instance = memory; // 3、设置instance指向刚刚分配的内存地址,此 时instance != null,但是对象还没有初始化完成
instance(memory); // 2、初始化对象
这样就会造成什么问题呢?
也就是当我们执行到重排后的步骤2,试图获取instance的时候,会得到null,因为对象的初始化还没有完成,而是在重排后的步骤3才完成,因此执行单例模式的代码时候,就会重新在创建一个instance实例。指令重排只会保证串行语义的执行一致性(单线程),但并不会关系多线程间的语义一致性。
所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,这就造成了线程安全的问题。所以需要引入volatile,来保证出现指令重排的问题,从而保证单例模式的线程安全性。
把private static SingletonDemo instance = null;改成
private static volatile SingletonDemo instance = null;