DCL单例模式是如何保证数据安全的?

1,410 阅读8分钟

承接上文证明CPU指令是乱序执行的

DCL单例(Double Check Lock)到底需不需要volatile?

image.png

new对象这一步,对应着汇编层面的这3个指令,

image.png

指令0是申请空间,设置默认值;

指令7是执行构造方法,设置初始值;

指令4是建立栈中的对象名称和堆中对象的关联。

下面详细的介绍下该过程,

image.png

在多线程访问的情况下:线程1执行指令1,new对象,此时m值为0即还没有调用构造方法的时候t已经有默认值0了。

在单线程的情况下,只要不影响最终一致性,4和7两个指令可以任意换顺序。

在多线程的情况下,就会出现问题:

假设4和7突然换了顺序即发生了指令重排(new一半发生了指令重排),t和astore先建立关联,image.pngt就指向了创建一半的对象;

当t指向了new了一半的对象的时候,在这个时间点,第二个线程过来了,第二个线程首先判断是否为空,image.png

此时这个t不等于空了,因为它已经指向了初始化一半的对象了,不为空的话,就不需要上锁了,直接拿来用就可以了,此时就访问到了m为0这个状态的对象了即使用了初始化一半的对象。

这个问题就是由于指令的重排序造成的,此时你可能有这个疑问?

都上锁了,只有一个线程才能运行,其他线程居然还能访问到中间状态?

synchroinzed确实可以保证原子性,也能够保证可见性,但是唯一不能保证的是有序性。

synchroinzed里面的顺序,可以随便换,只要能保证单线程的最终一致性。

如果两段代码都是位于同一把锁的情况下,绝对不可能这个线程执行了一半,另外的线程可以看到,

image.png

判断实例是否为空的的代码是未上锁的,第二个线程来的时候,不需要上锁就可以进行这个判断。

上锁的代码和不上锁的代码之间是并发运行的,它们之间没有互斥和序列化,未上锁的代码就可以访问到中间状态,虽然上了锁的代码new对象实例化了一半,没有上锁的代码也是可以访问到的,这个时候如果重排序的话,就会访问到中间状态。

这2个指令(指令4和7)为什么可以换顺序?难道java中的指令都可以随便换顺序吗?

不能随便换顺序,比如一个对象还没有new完的时候,就不可能执行finalize方法进行回收或者还没有new完就不能启动。

程序里面规定了这8种情形不可以换顺序,这种被称为happens-before原则,

image.png

对象的创建过程

image.png

在run main方法的时候,先将源码编译成字节码,然后通过Idea的Show Bytecode With Jclasslib插件工具查看字节码的内容,image.png

这是main方法中生成的整个字节码,相当于java的汇编语言,这段字节码一共有5条语句构成。

image.png

new对象的时候转换成java的二进制码会执行这5条指令:

image.png

第一步new(对应指令0),申请一块空间,刚刚申请出这块空间的时候m是0;

image.png

指令4是一个特殊调用,T.init方法是指执行构造方法,构造方法执行完之后,m才会变成8;

image.png

astore指令将栈里面的t和这个对象建立关联。

new一个对象的时候,会有3步构成:

  • 指令0:申请一块空间,初始值是0
  • 指令4:执行构造方法,初始化成8
  • 指令7:将栈中的t和对象关联起来

java中为什么要给个初始值0?

在c++或c语言中申请一块内存的时候,里面有一个成员变量,这个成员变量最开始的值会是多少?

如果不给它赋予初始值,最开始的值叫遗留值,是指原来在这块内存中被用过的值,具体值是随机的,这是c或c++的做法,这也是c++不安全的地方。

java的做法是先给它一个默认的初始化值0,java为什么这么做?

主要是为了安全,如果这里装的正好是上一个程序的密码,如果随随便便的就能访问到上一个程序的遗留值就能够拿到密码了,java做了很多安全上的设计。

著名的this溢出问题

image.png

this实际上是方法里面一个默认的形参,this指向的就是自己这个对象的本身。

image.png

当new这个对象的时候,申请空间里面的num值会有一个变成0的过程,调用完成构造方法之后才会变成8,在这个构造方法还没有完成的时候,这个线程就启动了,这里的this.num会把0打印出来;

这两条语句(指令4和7)是可以换顺序的;

t看成this,当m=0的时候,4和7交换位置,先执行astore(astore执行完了才会给this.num赋值),构造方法还没有执行完呢,就可以直接建立关联(栈中的t和对象建立关联)了,这个this就是初始化了一半的对象, 这个就叫溢出问题,形象的比喻:穿衣服还没有穿好,就出去见人了。

所以不要在构造方法中启动线程,因为很可能访问到对象初始化一半的状态,不是你期望的那个结果。

单例模式

image.png

构造方法被private修饰,目的就是不给别人用,只能自己创建当前类的对象。

但这种写法有问题,在这个对象还没有用的时候,直接先把它创建出来了,会造成空间和cpu资源的浪费,能不能等用到的时候再new?

image.png

(为了暴露多线程的问题这里设了一个毫秒)

这是懒汉式单例模式即什么时候用到了才去new,但会出现更大的问题:在多线程访问的情况下,很可能访问的不是同一个对象。

image.png

将方法上锁,在同一个时间段内只能有一个线程执行里面的代码,这个线程执行完了,另外一个线程才允许执行里面的代码,这是多线程的原子性,整个的语句当作一个原子,不可分割。

如果锁定的代码中有一些业务逻辑,就会发现锁定的代码耗时就会太长了,比如从db中读取数据的业务逻辑没有必要上锁,为了让锁的粒度稍微变得细一点,让可执行的时间效率稍微高一点,则不给整个方法上锁,

image.png

这样缩小了锁的粒度,不将业务代码的是否等于空的判断加锁,等判断为空之后,再上锁,上完锁,再new对象,这是追求效率的写法,但在多线程访问的情况下能不能保证数据的一致性?

image.png

第一个线程判断对象为空,在获取锁之前,切换给了第二个线程执行,第二个线程判断对象为空,获取锁,上完锁后,new对象,把锁释放,然后第二个线程结束,在这个时候第一个线程继续运行,因为第一个线程已经判断完了,直接申请上锁,此时是可以上锁成功的,因为第二个线程已经把锁释放了,线程1又new了一个对象。

image.png

上完锁之后,再判断是否依然为空,如果依然为空,说明在上锁的过程当中没有其他人把它给new出来,如果还依然保持为空,就把它new出来。

image.png

第一个线程在做第一步判断的时候是不为空的,因为第二个线程已经把它new出来了,所以不会再new第二遍,这样就会保证单例的唯一。

双重检查中间加了一个锁,这是很多开源软件非常标准的做法。

最外层的判空有必要吗?

一定有必要存在,假如不做这个判断,确实可以保证整个单例只有一个,但是效率会偏低。

如果有1万个线程都来去new这个对象 ,如果外面没有这层检查,1万个线程都会申请上锁,因为所有线程来了之后都会执行这句话,申请上锁的过程是一个非常重要的过程,只要有外面这层检查的话,只有一个线程new成功了,其他线程来了判断一下不为空,就再也不需要上锁的过程了,这个判断仅1、2个纳秒而已,而这个上锁的动作需要好几百个纳秒,所以外面那层检查是不可以省略的。