面试官的一道简单的单例模式问题给我问懵了,详解单例模式双重检查加锁为什么要加volatile关键字!

Welcome ·
更新时间:2024-09-21
· 662 次阅读

目录 1.场景: 2.对象的创建过程  3.指令重排 4.CPU执行时间片  5.指令重排对双重检查加锁模式的影响   1.场景:

面试官:有用过单例模式吗?

我:有有有(自信满满)。

面试官:说说单例模式几种写法?

我:懒汉式和饿汉式,懒汉式巴拉巴拉,饿汉式巴拉巴拉。

面试官:我们都知道synchronized加锁是比较耗费资源的,你这种写法每次访问都需要获得锁(基础的懒汉式写法),效率比较低,有什么优化的方式吗?

我:沉思片刻,脑海灵光一现。可以采用双重检查加锁的方式,巴拉巴拉。(还好之前看到过,暗自庆幸)

面试官:为什么双重检查加锁需要加volatile关键字?

我:要不我们问问度娘?

在回答这个问题之前我们要明确这几点,一个是对象的创建过程,一个是什么是指令重排,一个是CPU时间片的概念,一个是synchronized不会禁止指令重排,最后一个是volatile禁止指令重排。

2.对象的创建过程 

对象的创建过程主要分成三步,如下图展示的汇编码所示,主要是0,4,7这三步。

0  这步是为新创建的对象申请内存,但是此时对象中的成员变量的值是默认的值(半初始化),即下图a 的值此时是0;

4 初始化对象,在这步才把10赋给成员变量a

7 建立关联,把testDemo引用和new 出来的TestDemo对象建立关联

public class TestDemo { private int a = 10; public static void main(String[] args) { TestDemo testDemo = new TestDemo(); // 0 new #2 申请内存,半初始化,此时a的值是0(当对象刚new出来的时候会给里面的成员变量设置默认初始值,int类型的初始值是0) // 3 dup 复制 // 4 invokespecial #3 <company/syncronized/TestDemo.> 初始化,在这步把10赋给a,此时a的值是10 // 7 astore_1 testDemo和new TestDemo()建立关联 // 8 return } } 3.指令重排

指令重排是JMM(java内存模型)中的一个概念,它是指计算机在执行程序时,编译器和处理器会对不存在数据依赖性的指令进行重新排序。

什么是数据依赖性:就是A,B两个指令,B指令的执行依赖A指令的执行,举个简单的例子,看下面的代码,语句2需要语句1申明a变量之后才能使用,那么它们之间就存在数据依赖性。

什么是指令重排:一般情况下rely()方法的执行顺序是1,2,3,4顺序执行,但是在编译器和处理器的优化下,执行顺序有可能变成1,3,2,4,也有可能是3,1,2,4,这个就是指令的一个重排。那么有没有可能出现1,4,2,3的情况呢,不会出现,现有3,4存在数据依赖,所以3必须在4之前被执行。 

public void rely(){ int a = 10; //语句1 a = a +1 ; //语句2 int b = 8; //语句3 b = b +1; //语句4 } 4.CPU执行时间片 

时间片定义:时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片,即该进程允许运行的时间,使各个程序从表面上看是同时进行的。如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。而不会造成CPU资源浪费。

产生的问题:根据上面的时间片定义,我们可以得出,CPU不会等待一个进程执行完毕之后在执行下一个进程,而是CPU会给每一个进程一段执行的时间,这段时间结束,就会轮到下一个进程去使用CPU,这样会出现一个上面问题呢?就是我的进程可能执行到一半,时间片时间到了,CPU给下一个进程了,那么我的进程在这个时间片内就只执行了一半,需要等待下次占有CPU的时候才能把全部的进程执行完毕。也就是说,一个进程可能是需要多个CPU执行时间片的时间来完成。

5.指令重排对双重检查加锁模式的影响

下面进入我们的正题。经过刚才的分析,我们知道创建对象是分成三步走,看下图的1,2,3。那么在这个创建过程中,有没有可能会发生2,3指令重排序?答案是肯定的,因为synchronized它是不会禁止指令重排,假设2,3指令发生了顺序交换,也就是test 引用先和 new SingleDemo对象建立连接,然后在初始化new  SingleDemo,那么此时问题就产生了。

之前我们提到cpu时间片的概念,那么我这个进程如果正好执行到test引用建立连接,cpu时间片时间到了,然后轮到下一个进程来执行。好的,那么小伙伴最容易困惑的点来了,synchronized不是保证原子性吗?我的线程还没有执行完毕,我还握着这把锁,就算下一个线程进来也是阻塞的状态,不会对我产生影响。只有当我下一个获得cpu,然后执行完毕,释放锁,那么下个线程才能进行操作,那么既然当前线程一定会执行完毕,那么顺序交换也没有影响了吧。是的,如果我当前线程执行完毕,确实没有影响,但是我下一个进程进来不是阻塞呢?

问题的关键来了,我第一个线程A进来之后,new singledemo()过程发生了指令重排,初始化和建立引用联系的顺序换了,我先建立了引用联系,也就是说此时test 引用指向了一个没有初始化,只有半初始化状态的new Singledemo对象,也就是说此时test是不为空的,然后这时候我cpu时间片的时间到了,然后当前线程A让出cpu,但是当前线程A仍然持有锁。我下一个线程B进来之后,在外层if(test==null)条件下进行判断,然后发现我test对象里面是有东西的,然后就直接return了,也就是说线程B已经获得了一个初始化的new Singledemo对象,你线程A锁着就锁着吧,我线程B拿到对象了。那么此时会产生一个什么问题,就是线程B中的成员变量信息是错误的,我本来a=10,你拿到半初始化状态对象的a=0,那么我在使用这个成员变量的时候就是不正确的。

那么怎么解决指令重排序的问题呢?加volatitle关键字,这样可以保证在new singledomo过程中不发生指令重排。

public class SingleDemo { //private volatile static SingleDemo test =null; private static SingleDemo test =null; private int a = 10; public int getA() { return a; } private SingleDemo(){} //双重检查加锁模式 public static SingleDemo get2(){ if(test==null) { //外层检查是问题关键 synchronized (SingleDemo.class) { if (test == null) { test = new SingleDemo(); //new singleDemo对象,半初始化 //1 //初始化 //2 //test引用 和 new singleDemo对象建立连接 //3 } } } return test; } } beAwesomeToday 原创文章 16获赞 11访问量 1万+ 关注 私信 展开阅读全文
作者:beAwesomeToday



面试 面试官 加锁 volatile 单例模式

需要 登录 后方可回复, 如果你还没有账号请 注册新账号