双检锁为什么需要使用volatile关键字

前置说明

这是一篇我自已关于使用双重检查锁存在的问题,以及volatile语义在其中的作用的理解。
是一篇个人记录性文章,可能在语言描述上有些直白,不会引用太多官方专业术语解释或说明。
有不正确的地方,也请留言指正。
下面开始正文:

一个单例实现

假如现在有一个Earth(地球)类,需要提供一个它的懒汉式单例实现,一种常规写法如下:

public class Earth {
    
    
    private static Earth earth = null;

    public static Earth getInstance() {
    
    
        if (earth == null) {
    
    
            earth = new Earth();
        }
        return earth;
    }
}

这种写法在单线程环境下,是完全没有问题的,不用考虑并发问题,不会存在临界区。
遗憾的是,实际情况,我们的电脑CPU一般是多核的,程序也往往都是需要多线程并发运行。在这样的环境,如果多条线程同时执行到这个方法内,这段代码可能new了多个对象,重复分配内存。同样,单例模式,在实际应用中也可能伴随着复杂的业务逻辑初始化,是不会允许这种重复初始化发生。
那么解决的方案,就是需要同步操作。比如:在这个方法上加锁

    public static synchronized Earth getInstance() {
    
    
        if (earth == null) {
    
    
            earth = new Earth();
        }
        return earth;
    }

当然,在不考虑性能的情况下,这个代码一定能保证线程安全,当最先拿到锁的线程,进入这个同步方法内,如果发现earth对象为空,便会构造一个对象并赋值,后续其它线程进入之后,对象已经创建。根据synchronized的语义,这里不会有任何问题。
但是,实际开发过程中,是期望锁的粒度尽量小,减少锁等待的阻塞时间,所以,把synchronized放到方法体内,对部分代码同步操作。就是接下来说的,双重检查锁。

双重检查锁

一个错误的代码范例:

public class Earth {
    
    
    private static Earth earth = null;

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

这个就是双重检查锁,首先判断earth是否为空,如果为空,进入同步块,再判断是否为空,还为空,便new个新对象给它。这样其它线程进入这个方法体内的时候,如果发现这个对象已经创建,不为空,便不会阻塞在同步块里等待,而是直接执行下面的代码,当然了,示例上下面的代码没什么逻辑,就是直接返回earth实例了。
p.s. 同步块的锁对象是不建议使用Earth.class,即所属类的类对象的锁,范围太大,其实静态方法同步块等可能也会用这个。最好是新建一个新对象用作锁对象。
当然,上面这个代码示例是不正确,因为声明的earth属性,没有使用volatile关键字。所以,这个双检锁是有问题的,为什么有问题,下面说明。

我也曾如此错误的使用

我刚毕业的时候,对于java的一些规范了解的也不够,写双检锁就是上面这样写的,像声明earth属性的时候,也不懂得要使用volatile。某天,我在写代码的时候,一个前辈看见我写双检锁的时候,给我说应该加上volatile关键字来声明这个要创建的单例对象属性。我就问,为什么。他给我说了一个,对于当时的我完全不能理解的专业术语,避免“重排序”。更多的也没告诉我,只是说了句在一些资料上见到过重排序问题。说的支支吾吾,我听的也迷迷糊糊!
在之后的一些时光中,我也看了不少JVM相关的资料或书籍,当然了,随着时间过了这么久,我现在其实记不太清那段时间看的书里有哪些具体内容了。只记得写双检锁要加上volatile声明。

正确的写法范式

public class Earth {
    
    
    private static volatile Earth earth = null;

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

关于volatile,脑海里始终是记得与它相关的两个术语:可见性、禁止重排序。
那,volatile语义中的可见性和禁止重排序究竟是什么?

volatile

关于“可见性”和“重排序”对于一些基础不太好的同学,可能是不太理解这是什么意思。这里作一些简单说明,可能不太专业,但尽量直白希望容易理解。
并发编程常见的三个问题:可见性、原子性和有序性。

  • 原子性
    这几个术语应该会经常听到,原子性不是本文关心的,不在讨论范围内,需要详细了解的可以找一些相关资料,简单说就是:我们在java里的一行代码,可能是需要解释为多条机器指令给CPU执行的(比如:num+=1这样),这几条指令在执行过程中随时可能发生线程切换执行其它语句的指令,而这条语句还未执行完成,这就是原子性问题。除非这几个指令操作在CPU执行过程不会中断,就是原子性。
  • 可见性
    这个说的是内存的可见性。比如,有一个变量:
int num = 0;

有线程A和线程B,线程A设置变量num=1,然后线程B读取num的值的时候,读取到的值还是0,B的读操作发生A的写操作之后,可是B读到的不是A刚写入的1。这个问题可能存在么?是的,存在。
这个属于硬件缓存带来的一致性问题。所以有JMM(java内存模型)来解决这个缓存一致性问题。
在我们常说的JMM中,会提到一个术语,叫java线程的本地内存(本地缓存),每个线程都有。有很多资料中,也会说到,在一个线程中执行代码时,当读取一个变量的值的时候,会先从主内存中加载到本地内存,修改完变量的值,修改的是本地内存的值,随后才会写回主内存。如果在写回主内存前,如果有其它线程读取这个变量的值,另外一条线程当然还获取不到在这条线程中的本地内存里更新的最新值,因为不论另一条线程是从它自己的本地内存读还是主内存获取都不最新的值,这就是上面说的内存可见性的问题。
我刚接触本地内存这个概念的时候,其实是不太理解这个本地内存是什么的,很多新手同学应当也是。
这个主内存,是我们的物理内存。
这个本地内存呢,其实在Java中是一个抽象概念,它包含CPU的缓存、寄存器或者是其它的硬件、编译优化等,并不是每个线程单独又开辟出来的一块内存空间。然而JMM其实讨论的也是本地内存这个抽象概念。
所以,java每次读取变量值的时候,是先从本地缓存读取,没有才从主内存加载到本地内存,写入也是先写到这里再同步到主内存。
volatile的可见性保证的就是每次读取都从主内存获取,写完同步到主内存,这样每条线程都能看到其它线程更新的最新值。

  • 有序性
    有序性问题真的是让人违背直觉。我们感觉顺序执行的代码就应该是从上往下,从前往后执行,有时候,结果却不是这么预期。
    原因就是编译器、运行时或者CPU等的优化导致的重排序的问题。
    下面是几种可能导致的重排序:
  1. 编译器或运行时的重排序,比如下面这个:a = 1; b = 2;,编译后的顺序可能为:b = 2; a = 1;(这里是打个比方,也不是说编译为字节码的重排序)。
  2. 硬件的重排序,比如CPU执行机器指令优化时的重排序
  3. 存储系统的重排序,这个是因为数据首先写入缓存,并不是马上更新到主存,有相应的条件和情况,这可就不保证哪个线程将一前一后哪个变量的值先写到缓存,它就会先更新到主存。
  4. 其它情况重排序。
    双检锁的这个重排序问题,指的是第2种,CPU的机器指令的一个重排序问题。

双检锁的指令重排序

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

这是前面双检锁创建对象的代码,假如有A、B两条线程进入这个方法发现earth对象为空,开始竞争这把锁,A线程拿到了锁进入同步块,B线程等待锁。A创建完对象释放锁,B拿到锁发现earth不为空,便也退出同步块返回earth实例。这是没有问题的。
但是,earth = new Earth();这行代码是有多个操作:

  1. 分配内存空间
  2. 在这块内存上初始化对象
  3. 将内存地址赋值给earth变量

重排序后:

  1. 分配内存空间
  2. 将内存地址赋值给earth变量
  3. 在这块内存上初始化对象

如果线程A执行完第2个动作后,此时earth不为空,线程A进入这个方法,判断earth是否为空,发现不为空,然后返回了一个尚未初始化完成的对象,它的属性可能还都是空。如果这时候访问这个变量的成员属性,就可能有问题了,比如空指针异常。
下图是我找的一个类似的测试的可以说明这个问题的截图,相关资料链接在最后面:
在这里插入图片描述

volatile如何禁止重排序

目前,在jdk1.5到1.8,应该只有volatile的语义包含禁止指令重排序了。synchronized也不支持,它只是保证有序性,和同步块的原子性。它的有序性是因为加锁每次只有一条线程执行临界区的代码,而这个原子性指的是加锁的这一块代码对外是原子性,而这块代码里是否重排序是不管的。
volatile的禁止重排序是使用了内存屏障。内存屏障就是在需要禁止重排序的前后加入相关指令,我是这样理解的:CPU会因为优化对相关指令重排序,或者流水线方式的话存在并行执行,加入内存屏障,就是告诉CPU,不用下功夫优化了,一步步按顺序执行。所以禁止重排序,一些优化就用不上了,性能肯定有下降。

volatile的使用场景

本文主要说明在双检锁中的使用,其它也有场景需要使用volatile。因为volatile的可见性保证,如果是写操作太频繁,就要慎用,会导致性能严重下降,尽量使用读多,写少的场景。

JDK1.5前的双检锁

volatile的禁止重排序的语义是在1.5(包含1.5)及其之后的版本才有的,在老的JMM中,volatile只保证可见性。所以,那个时候的双检锁怎么写好像都可能有问题,不是一定安全的,我甚至看到下面这种方案,也可能由于一些微妙的原因存在问题。

public class Earth {
    
    
    private static volatile Earth earth = null;

    public static Earth getInstance() {
    
    
        if (earth == null) {
    
    
            Earth tmp = null;
            synchronized (Earth.class) {
    
    
                tmp = earth;
                if (tmp == null) {
    
    
                    synchronized (Earth.class) {
    
    
                        tmp = new Earth();
                    }
                    earth = tmp;
                }
            }
        }
        return earth;
    }
}

使用饿汉式单例

当然,在多线程环境中,不一定非要使用双检锁这个懒汉式创建单例,推荐使用饿汉式这种静态单例模式,创建一个外部类,将该单例对象作为它的静态字段:

public class EarthHolder {
    
    

    private static final Earth EARTH = new Earth();

    public static Earth getInstance() {
    
    
        return EarthHolder.EARTH;
    }
}

推荐阅读

可以看下下面这几篇资料,上面的那个测试的指令的截图便来自这里:
http://gee.cs.oswego.edu/dl/cpj/jmm.html
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html

猜你喜欢

转载自blog.csdn.net/x763795151/article/details/109015712