从单例模式到HappensBefore

Java基础

浏览数:72

2019-5-6

目录

  • 双重检测锁的演变过程
  • 利用HappensBefore分析并发问题
  • 无volatile的双重检测锁

双重检测锁的演变过程

synchronized修饰方法的单例模式

双重检测锁的最初形态是通过在方法声明的部分加上synchronized进行同步,保证同一时间调用方法的线程只有一个,从而保证new Singlton()的线程安全:

public class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

这样做的好处是代码简单、并且JVM保证new Singlton()这行代码线程安全。但是付出的代价有点高昂: 所有的线程的每一次调用都是同步调用,性能开销很大,而且new Singlton()只会执行一次,不需要每一次都进行同步。

既然只需要在new Singlton()时进行同步,那么把synchronized的同步范围缩小呢?

线程不安全的双重检测锁

public class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

synchronized同步的范围缩小以后,貌似是解决了每次调用都需要进行同步而导致的性能开销的问题。但是有引入了新的问题:线程不安全,返回的对象可能还没有初始化。

深入到字节码的层面来看看下面这段代码:

instance = new Singleton()
returen instance;

正常情况下JVM编译成成字节码,它是这样的:

step.1 new:开辟一块内存空间
step.2 invokespecial:执行初始化方法,对内存进行初始化
step.3 putstatic:将该内存空间的引用赋值给instance
step.4 areturn:方法执行结束,返回instance

当然这里限定在正常情况下,在特殊情况下也可以编译成这样:

step.1 new:开辟一块内存空间
step.3 putstatic:将该内存空间的引用赋值给instance
step.2 invokespecial:执行初始化方法,对内存进行初始化
step.4 areturn:方法执行结束,返回instance

步骤2和步骤3进行了调换:先执行步骤3再执行步骤2。

  • 如果只有一个线程调用是没有问题的:因为不管步骤如何调换,JVM保证返回的对象是已经构造好了。
  • 如果同时有多个线程调用,那么部分调用线程返回的对象有可能是没有构造好的对象。

这种特殊情况称之为:指令重排序:CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。当然不是乱排序,重排序保证CPU能够正确处理指令依赖情况以保障程序能够得出正确的执行结果。

利用HappensBefore分析并发问题

什么是HappensBefore

HappensBefore:先行发生,是

  • 判断数据是否存在竞争、线程是否安全的重要依据
  • A happens-beforeB,那么A对B可见(A做的操作对B可见)
  • 是一种偏序关系。hb(a,b),hb(b,c) => hb(a,c)

换句话说,可以通过HappensBefore推断代码在多线程下是否线程安全

举一个《深入理解Java虚拟机》上的例子:

//以下操作在线程A中执行
int i = 1;

//以下操作在线程B中执行
j = i;

//以下操作在线程C中执行
i = 2;

如果hb(i=1,j=i),那么可以确定变量j的值一定等于1。得出这个结论的依据有两个:

  1. 根据HappensBefore的规则,i=1的结果可以被j=i观察到
  2. 线程C还没有登场

如果线程C的执行时间在线程A和线程B之间,那么j的值是多少呢?答案是不确定!因为线程C和线程B之间没有HappensBefore的关系:线程C对变量的i的更改可能被线程B观察到也可能不会!

HappensBefore关系

这些是“天然的”、JVM保证的HappensBefore关系:

  1. 程序次序规则
  2. 管程锁定规则
  3. volatile变量规则
  4. 线程启动规则
  5. 线程终止规则
  6. 线程中断规则
  7. 对象终结规则
  8. 传递性

重点介绍程序次序规则管程锁定规则volatile变量规则传递性,后面分析需要用到这四个性质:

  • 程序次序规则:在一个线程内,按照程序控制流顺序,书写在前面的操作HappensBefore书写在后面的操作
  • 管程锁定规则:对于同一个锁来说,在时间顺序上,上一个unlock操作HappensBefore下一个lock操作
  • volatile变量规则:对于一个volatile修饰的变量,在时间顺序上,写操作HappensBefore读操作
  • 传递性:hb(a,b),hb(b,c) => hb(a,c)

分析之前线程不安全的双重检测锁

public class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {                     //1
            synchronized (Singleton.class) {        //2
                if (instance == null) {             //3
                    instance = new Singleton();     //4
                    new                             //4.1
                    invokespecial                   //4.2
                    pustatic                        //4.3
                }
            }
        }
        return instance;                            //5
    }
}

经过上面的讨论,已经知道因为JVM重排序导致代码4.2提前执行了,导致后面一个线程执行代码1返回的值为false,进而直接返回了还没有构造好的instance对象: |线程1|线程2 | |–|–| | 1 | | | 2 | | | 3 | | | 4.1 | | | 4.3 | | | | 1 | | | 5 | | 4.2 | | | 5 | |

通过表格,可能清晰看到问题所在:线程1代码4.3 执行后,线程2执行代码1读到了脏数据。要想不读到脏数据,只要证明存在hb(T1-4.3,T2-1)(T1-4表示线程1代码4,T2-1表示线程2代码1,下同),那么是否存在呢?很遗憾,不存在:

  • 程序次序规则:不在同一个线程
  • 管程锁定规则:线程2没有尝试lock
  • volatile变量规则:instance对象没有通过volatile关键字修饰
  • 传递性:不存在

用HappensBefore分析,可以很清晰、明确看到没有volatile修饰的双重检测锁是线程不安全的。但,真的是这样的吗?

无volatile的双重检测锁

在第二部分,通过HappensBefore分析没有volatile修饰的双重检测锁是线程不安全,那只有用volatile修饰的双重检测锁才是线程安全的吗?答案是否定的。

用volatile关键字修饰的本质是想利用volatile变量规则,使得写操作(T1-4)HappensBefore读操作(T2-1),那只要另找一条HappensBefore规则保证即可。答案是程序次序规则管程锁定规则

先看代码:

public class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {                         //1
            synchronized (Singleton.class) {            //2
                if (instance == null) {                 //3
                    Singleton temp = new Singleton();   //4
                    temp.toString();                    //5
                    instance = temp;                    //6
                }
            }
        }
        return instance;                                //7
    }
}

在原有的基础上加了两行代码:

instance = new Singleton();   		//4

Singleton temp = new Singleton();   //4
temp.toString();                    //5
instance = temp;                    //6

为什么要这么做? 通过管程锁定规则保证执行到代码6时,temp对象已经构造好了。想一想,为什么?

  1. 其他线程执行代码1时,如果能够观察到T1-6的写操作,那么直接返回instance对象
  2. 如果没有观察到T1-6的写操作,那么尝试获取锁,此时管程锁定规则开始生效:保证当前线程一定能够观察到T1-6操作

执行流程可能是这样的: |线程1|线程2 | 线程3| |–|–| — | | 1 | | | | | | 1| | 2 | || | 3 | || | 4 | || | 5 | || | 6 | || | | | 2| | | | 3| | | 1 | 7 | | | 7 || | 7 | ||

无论怎样执行,其他线程都能够观察到T1-6的写操作

其他

volatile、synchronized为什么可以禁止JVM重排序

内存屏障。

JVM在凡是有volatile、synchronized出现的地方都加了一道内存屏障:重排序时,不可以把内存屏障后面的指令重排序到内存屏障前面执行,并且会及时的将线程工作内存中的数据及时更新到主内存中,进而使得其他的线程能够观察到最新的数据

参考资料

  1. 《深入理解Java虚拟机》

作者:那只是一股逆流