天天看点

volatial探讨(三)-DCL单例中的volatile和HappensBefore原则DLC单例介绍

DLC单例介绍

1.1什么是DCL单例(知道什么是DCL单例的可以忽略1.1)

DCL的全称为 Double Check Lock 中文翻译为,双重检查锁。顾名思义运用双重检查的方式进行加锁

我们先来看一段代码A-1

package com.gxw.first.code.volite;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;

public class DCLSignal {
    private Integer i;
    //未用volatile
    private static DCLSignal dclSignal;   //1-1
    //使用volatile 
    private volatile static DCLSignal dclSignal;   //1-2
    //构造一个初始值
    DCLSignal() {
        this.i = 111;
    }
    //双重检查锁获取单例
    public static DCLSignal getInstance() {
        if (dclSignal == null ) {
            //这行代码是为了造成一些时差,方便验证DCL单例不加volatile的后果
            System.out.println("即将进入锁竞争 "+Thread.currentThread().getName());
            synchronized (DCLSignal.class) {
                if (dclSignal == null) {
                    dclSignal = new DCLSignal();
                }
            }
        }
        return dclSignal;
    }

    public static void clear() {
        dclSignal = null;
    }

    public Integer getI() {
        return i;
    }

    public void setI(Integer i) {
        this.i = i;
    }

    public static void main(String[] args) throws InterruptedException {
        int num = 10;
        ArrayList<Thread> threads = new ArrayList<>();
        long start=System.currentTimeMillis();
        while (true) {
            DCLSignal.clear();
            for (int i = 0; i < num; i++) {
                Thread thread = new Thread(() -> {
                    DCLSignal instance = DCLSignal.getInstance();
                    //这里用来验证重排序的发生
                    if (instance.getI() == null || instance.getI() == 0) {
                        //注意这里不能直接打印instance.getI()因为System.out.println()中有synchronized,会打破重排序,所以这里会打印出instance.i=111;
                        System.out.println("instance i = 0 耗时:"+((System.currentTimeMillis()-start)/1000L+"秒"));
                        System.exit(0);
                    }
                });
                thread.start();
                threads.add(thread);
            }

            threads.forEach((t) -> {
                try {
                    t.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
    }
}

           

打开1-1的执行结果

即将进入锁竞争 Thread-12781

即将进入锁竞争 Thread-12790

即将进入锁竞争 Thread-12800

即将进入锁竞争 Thread-12801

instance i = 0 耗时:3秒

可以看到未使用volatile修饰的情况下返回了一个和预期不符的值。为什么会返回一个(i==null)呢?从理论上来将,在只要对象的构造方法执行了,返回的对象的成员变量i就不可能为null,必然会有初始值111。这里发生了什么?我们来详细探讨一下。

1.2 JAVA当中的new指令

我们来看一下new对象发生了什么?写出以下代码

package com.gxw.first.code.volite;

public class NewMain {
    public static void main(String[] args) {
        Object o= new Object();
    }
}
           

在IDEA中点击view->show Bytecode

可以看到以下编译代码

// class version 52.0 (52)
// access flags 0x21
public class com/gxw/first/code/volite/NewMain {

  // compiled from: NewMain.java

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 3 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this Lcom/gxw/first/code/volite/NewMain; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x9
  public static main([Ljava/lang/String;)V
    // parameter  args
   L0
    LINENUMBER 5 L0
    NEW java/lang/Object       // new对象第一步
    DUP                        //new对象第二部
    INVOKESPECIAL java/lang/Object.<init> ()V    //new对象第三步
    ASTORE 1      //new对象的第三步
   L1
    LINENUMBER 6 L1
    RETURN
   L2
    LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
    LOCALVARIABLE o Ljava/lang/Object; L1 L2 1
    MAXSTACK = 2
    MAXLOCALS = 2
}

           

可以看到java创建一个对象被编译成了4条指令

1、NEW

2、DUP

3、INVOKESPECIAL

4、ASTORE

那么这四条汇编指令都是做什么呢?可以查一下汇编手册

1、NEW指令负责开内存上开辟一个空间
2、DUP指令可以不用太关心(new创建的实例是默认初始化值,可以认为是没有进行构造方法的一个对象,创建后会将这个对象压入操作数栈顶;后面因为INVOKESPECIAL方法会消耗一个栈顶的引用给对象初始化,所以这里DUP是用来拷贝一份对象的引用来进行真正的引用传递)
3、INVOKESPECIAL 用来调用new创建实例的初始化工作可以看到这里关联了java.lang.Object.init() 方法用来初始化
4、ASTORE方法,ASTORE方法负责把创建的对象关联到 Object o=new Object() 代码中 o这个变量上

通过前面两章的探讨,我们知道CPU由于需要优化寄存器和ALU之间的运算速度不匹配所有会对指令进行重排序,所以如果以上代码的顺序发生了改变会怎么样呢?

当出现以下的重排序场景时

1、NEW //开辟对象空间

2、DUP //复制对象引用

3、LASTORE //返回对象引用

4、INVOKESPECIA //调用构造方法初始化对象

可以发现对象的引用在重排序下,是会提前返回的。返回对象引用的时候对象可能还没有被初始化。

我们之前讲过重排序遵循 as-if-serial 那么这种情况是否遵循这个原则呢?

可以看到new指令在单线程情况下先进行返回引用复制,然后再进行初始化后并不影响这条指令的最终结果,但是在多线程下会发生些什么呢?我们来分析一下

volatial探讨(三)-DCL单例中的volatile和HappensBefore原则DLC单例介绍

可以看到这里线程B返回了一个未进行构造方法的对象,这就是如上代码A-1发生和预期不符结果的原因。解决方法也很简单,使用volatile来修饰单例 DCLInstance就可以了。

学到这里是不是很头疼?感觉重排序带来的问题无处不在,在编码的时候很难全部考虑到所有的重排序场景,所以这里有了happens-before原则

1.3 happens-before原则(先行发生原则)

1、程序次序规则(Program Order Rule)

单线程内,先写的代码,先执行,后写的代码后执行

2、管程锁定规则(Monitor Lock Rule):管程—一种通用的同步语句,在Java中主要指的就是Synchronized。而管程锁定规则指的就是对一个锁的unlock happens-before 后续对这个锁的lock操作。如下,若线程A、B同时访问read方法,当线程A执行完之后,线程B能够获取到线程A对变量的操作。
public class HappensBefore {
 
    int value = 10;
 
    public void read(){
        synchronized (this){ //此处加锁
            if(value < 100){
                value ++;
            }
        }//此处自动解锁
    }
           

3.volatile变量规则(Volatile Variable Rule):

对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”是指时间上的先后顺序。

4.传递性(Transitivity):

如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

5.线程启动规则(Thread Start Rule):

Thread对象的start()方法先行发生于此线程的每一个动作。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程B前的操作。换句话说就是,如果线程A调用线程B的Start()方法(在线程A中启动线程B),那么该start()操作happens-before于线程B中的任意操作。