【线程安全】由单例模式双重检查加锁DCL引出的synchronized与volatile关键字

1. 什么是DCL

DCL是double check lock的单例实现方式,延迟加载。

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

    public void otherMethod(){
        
    }
}

2. 有synchronized修饰了为什么还需要volatile

synchronized的作用是通过互斥锁的方式,保证锁块的代码整体上在被多个线程调用时能够有序执行,因为多个线程会依次获得锁并执行锁块代码。

但是从锁块内部来看,其内部的代码在执行时是可能发生重排序的。而instance = new Singleton();这一行代码并非原子操作,其执行指令包括:分配内存,创建对象,将变量指向该对象内存区域。而在分配内存一定先执行,但是创建对象和变量指向该内存区域是可以重排序的。如果发生了重排序,线程A获得锁执行该行代码,执行了分配内存、变量指向之后还未执行创建对象,此时cpu分配给了另外一个线程,由于第一个check在synchronized之外,因此该线程可以执行该check逻辑,发现instance非null并使用,造成异常,因为此时instance实际上是非法的未创建成功的状态。

而volatile关键字强制保证其修饰变量的操作是不会指令重排序的,从而避免上述情况发生。

通过上面的例子就会发现,通常说synchronized既保证原子性又保证有序性是不准确的,准确的说是粗粒度地保证了有序性,通过原子性实现的,有序是粗粒度的,从其修饰的代码块在多个线程轮流获取该锁后执行的角度看,整个代码块是有序的,但是其块内的多个指令之间是可能重排序的。synchronized关键字通过操作系统层面的互斥锁来实现代码块的互斥同步,从而保证原子性,通过原子性保证整个代码块的有序执行。

volatile通过指令层面的屏障来保证其修饰变量的操作指令不会被重排序,是细粒度的,指令级别的有序性。

3. synchronized

【作用】

三个作用:保证原子性、保证“有序性”、保证可见性

通过互斥同步保证原子性、并保证粗粒度的有序性。之所以说是粗粒度,是因为只保证整个同步代码块是各个线程顺序执行的,代码块内部并非有序。

可重入的、非公平的、自动释放锁

为什么说synchronized是重量级的?因为synchronized底层依赖操作系统互斥锁,强制要求其余线程进入阻塞状态,而Java中线程是被映射到操作系统的内核线程之上的,要阻塞必须操作系统帮忙完成,因此阻塞操作必须从用户态切换到内核态,而从用户态到内核态的切换时开销很大的,因为用户态到内核态的切换需要中断指令完成用户放弃cpu、由操作系统上cpu,必然也需要一系列寄存器等的环境保存和恢复,这个开销很大。

【实现原理】

synchronized关键字从Java语言层面上看是通过某个对象作为锁,获取锁后才可以执行锁代码块。

从JVM层面上看是在进入锁代码块之前执行monitorenter字节码指令、执行之后执行monitorexit字节码指令,而monitorenter字节码指令的执行实际上是获取锁对象monitor,获取成功进而执行锁代码块或者获取失败被阻塞挂在当前锁对象的等待队列中,由于是字节码指令,也就是JVM层面的c++实现的,锁对象monitor实际上是一个c++编写的数据结构,其结构与java.util.concurrent的lock类似,有表示当前锁被重入次数的状态、获取当前锁失败进入阻塞的等待队列等。

从更底层的角度看,synchronized的字节码指令是依赖操作系统的互斥量来实现互斥操作,monitor依赖操作系统的管程互斥量。

【锁优化】

《深入理解Java虚拟机》13.3节

4. volatile

【作用】

volatile关键字有两个作用:保证可见性、保证有序性(防止指令重排序),也可以认为是JMM对volatile的特殊规定。

可见性是指被volatile修饰的变量,多个线程进行操作时,一个线程的写操作能够在写之后被其它线程及时读取到最新值。而不是依然使用旧值。这和JMM是分不开的,JMM被设计为主内存和各个线程的工作内容的结构,因此才会出现某线程修改后其它线程依然使用旧值的问题。

有序性是指被volatile修饰的变量,对其操作的指令之间以及与挨着的前后指令之间禁止重排序,保证有序性。

【实现原理】

可见性:JMM规定volatile关键字修饰的变量进行写必须刷回主内存,并且会使得各个工作内存值失效,因此在写之后其它线程要用时必须到主内存读最新值,保证可见性。该规定如何实现的呢?是内存屏障,内存屏障会要求写操作同步刷回主内存,并使得其他工作内存值失效。

插入内存屏障后,内存屏障后的指令不能在内存屏障前的指令之前执行。内存屏障是一系列的指令,用于限制对内存的访问顺序,对内存屏障的深入介绍参考:

https://www.infoq.com/articles/memory_barriers_jvm_concurrency/

https://mechanical-sympathy.blogspot.com/2011/07/memory-barriersfences.html

有序性:通过内存屏障来实现,在对volatile修饰的变量的操作指令前后插入内存屏障,内存屏障禁止指令重排序。

因此volatile的实现核心就是内存屏障。

但是volatile并不能保证其修饰的变量的操作是原子性的。

5. JMM

《深入理解Java虚拟机》12.3节,并发编程时不需要从内存模型的访问上考虑是否对某个变量是并发安全的访问,这太底层了,只需理解Java的内存模型即可。要判断一个操作是否是线程安全的,依靠先行发生原则判断。

JMM是为了屏蔽硬件及操作系统底层的内存个性,提供一个统一的内存访问模型,使得Java程序不必根据不同的底层系统或硬件编写不同的代码。这是目的。

JMM为了实现上述目的,在定义模型是主要考虑的是如何处理并发过程中原子性、可见性、有序性这三个方面。

基本数据类型的访问、读写(读写指的是单操作比如value = 1,++是多操作)都是具备原子性的(例外就是long和double的非原子性协定)。

6. happens-before

这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的定义之中。

下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序,此时就需要考虑通过同步器或者volatile来保证线程安全。

  • 程序次序规则(Program Order Rule)

在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。要正确理解这句话,这并非表示一个线程内的指令是按时间先后顺序执行的,只是从语义上看最终的结果和按顺序执行是一致的,但是在不破坏语义的情况下,指令时会被重排序执行的。

  • 管程锁定规则(Monitor Lock Rule)

一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。这里指的就是synchronized关键字的锁定,从Java语言上看是通过synchronized关键字上锁,JVM层面是通过monitorenter、monitorexit加解锁,而更底层地来看,JVM也是利用了lock、unlock指令,通过操作系统的管程(可以认为是操作系统中互斥锁的称呼)来锁定。

  • volatile变量规则(Volatile Variable Rule)

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

  • 线程启动规则(Thread Start Rule)

Thread对象的start()方法先行发生于此线程的每一个动作。

  • 线程终止规则(Thread Termination Rule)

线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。

  • 线程中断规则(Thread Interruption Rule)

对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。

  • 对象终结规则(Finalizer Rule)

一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

  • 传递性(Transitivity)

如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页