【查漏补缺02】(五)从DCL到Volatile的禁止指令重排序

一、双重检查锁

1、概念

双重检查锁  double-checked locking = DCL

也被称为"双重检查加锁优化","锁暗示"(Lock hint)

它是一种软件设计模式用来减少并发系统中竞争和同步的开销。

举个例子:

/**
 * 双重检查锁定
 *
 * @author xiaoshu
 */
public class DoubleCheckedLocking {
    private static Instance instance;

    public static Instance getInstance() {
        if (null == instance) {                             //1.第一次检查
            synchronized (DoubleCheckedLocking.class) {     //2.加锁
                if (null == instance) {                     //3.第二次检查
                    instance = new Instance();              //4.若是未加volatile修饰的共享变量,问题的根源出在这里
                }
            }
        }
        return instance;
    }
}

上面的锁可以通过同步来实现。synchronized将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。
反之,先对变量做判断,就不会立马执行同步方法。这种延迟初始化方案将能提供令人满意的性能。

从示例可以总结出 DCL的基本思路是:首先验证锁定条件(第一次检查),只有通过锁定条件验证,才开始进行加锁逻辑,并再次验证条件(第二次检查)。

简言之:
1)先检查共享变量是否被初始化
2)如果未初始化,则加锁,再二次判断变量是否被初始化,依然满足条件,则执行初始化。

2、存在的问题

上面的示例程序,在多线程访问下,存在线程安全问题。

假设:

共享变量instance

线程A,发现变量instance =null,则加锁,二次判断 instance = null,开始执行 instance = new Instance(); 

线程B,正当线程A在对instance 进行初始化(但未完成时),发现 instance !=null ,于是提前读取了instance的值,进行后续逻辑操作。这样,程序就可能异常或崩溃了。

为什么未完成初始化的instance 会被线程B 提前取用了?

问题出在 instance = new Instance(); 

这个操作是 非原子性操作,可以抽象为下面几条JVM指令: 1-2-3

  • memory =allocate(); //1:分配对象的内存空间
  • ctorInstance(memory); //2:初始化对象
  • instance =memory; //3:设置instance指向刚分配的内存地址

本来指令的执行顺序是1-2-3, 但是 JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度,JVM可能会对指令进行重排序。

也就是指令的执行顺序 可能被优化为: 1-3-2

  • memory =allocate(); //1:分配对象的内存空间
  • instance =memory; //3:instance指向刚分配的内存地址,此时对象还未初始化
  • ctorInstance(memory); //2:初始化对象

那么当线程A执行完 1-3后,可能出现3种情况:

1)线程A还没开始执行2,此时线程B 发现 instance=null, 符合预期。

2)线程A开始执行2,但还没完成(对象还没完成初始化),此时线程B 发现 instance != null , 线程B提前取用了instance ,去执行后续的逻辑。 半成品的instance,导致程序运行的不可预知性。

3)线程A执行完2,此时线程B发现 instance != null, 已完成初始化的instance 参与后续逻辑,符合预期。

所以,结论就是  JVM 为了优化性能,进行了指令重排序,可能会导致了共享变量的不安全,从而导致程序运行的未知问题。

如何解决这个问题?

这个问题其实就是, 如何实现线程安全的延迟初始化。

思路:

1)不允许2和3重排序 ———— volatile

2)允许2和3重排序,但不允许其他线程“看到”这个重排序。 —— 类的初始化 (本篇不作介绍)

二、volatile 

前言:

volatile 从字面意思看,就是不稳定的,易变的。

在我们上面的这个场景,它就是要适配 共享变量的易变 在 多线程之间可见。

volatile的两大作用

内存可见性

1、JVM内存模型

JVM内存模型: 主内存 + 线程(工作缓存)

原理:

线程中运行的代码最终都是交给CPU执行的,而代码执行时所需使用到的数据来自于内存(或者称之为主存)。

但是CPU是不会直接操作内存的,每个CPU都会有自己的缓存,操作缓存的速度比操作主存更快。

3点小原则:

  • 每个线程都有独立的工作内存,只能访问自己的工作内存。线程间的工作缓存相互独立。
  • 多个线程的共享变量,存储在主内存当中。
  • 当线程想要操作共享变量时,先从主内存copy一个副本到自己的工作内存,操作完毕后再同步回主内存。 —— 这里就引出了主存和线程缓存的数据完整性问题

JVM内存模型,规定了这两部分内存的协议,制定了8种原子操作,如下:

  • (1) lock:将主内存中的变量锁定,为一个线程所独占
  • (2) unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量
  • (3) read:将主内存中的变量值读到工作内存当中
  • (4) load:将read读取的值保存到工作内存中的变量副本中。
  • (5) use:将值传递给线程的代码执行引擎
  • (6) assign:将执行引擎处理返回的值重新赋值给变量副本
  • (7) store:将变量副本的值存储到主内存中。
  • (8) write:将store存储的值写入到主内存的共享变量当中

结合上面的示例场景,以及上述的操作,可以概括为:

1)加锁

2)某个独立线程 读写操作,同步回主存,具体操作包括:

     2.1)从主存中将共享变量 copy 到自己的工作缓存中。

     2.2)对缓存中的共享变量 进行修改。

     2.3)将修改后的值刷新到主存中。

3)解锁

volatile如何保证可见性?

在每次访问变量时都会进行一次刷新,因此每次访问都是主内存中最新的版本。所以volatile关键字的作用之一就是保证变量修改的实时可见性。

禁止指令重排

在JDK1.5之后,可以使用volatile变量禁止指令重排序。

指令重排 和 原子性操作的概念 在上面已经论述,不再重复。

volatile 如何 禁止 指令重排 ?

加入内存屏障(Memory Barrier),禁止JVM的指令重排。

内存屏障(Memory Barrier)

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

大多数的处理器都支持内存屏障的指令。

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
    • Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
    • Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。 --- DCL 问题!
    • 它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
    • Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。
    • Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

最后结论:

共享变量,加了 volatile 关键字修饰后,获得了 内存可见性 + 禁止指令重排序 的能力。 

任一个线程 对 共享变量 的修改,对于所有访问它的线程都是及时可见的。同时,对它的非原子性操作  也会通过特定策略 插入 内存屏障 确保 指令序列按照预期 顺序执行。

参考链接:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值