在只有双重检查锁,没有volatile的懒加载单例模式中,由于指令重排序的问题,我确实不会拿到两个不同的单例了,但我会拿到“半个”单例

而发挥神奇作用的volatile,可以当之无愧的被称为Java并发编程中“出现频率最高的关键字”,常用于保持内存可见性和防止指令重排序。

保持内存可见性

内存可见性(Memory Visibility):所有线程都能看到共享内存的最新状态。

失效数据

以下是一个简单的可变整数类:

public class MutableInteger {
private int value;
public int get(){
return value;
}
public void set(int value){
this.value = value;
}
}

MutableInteger不是线程安全的,因为getset方法都是在没有同步的情况下进行的。如果线程1调用了set方法,那么正在调用的get的线程2可能会看到更新后的value值,也可能看不到

解决方法很简单,将value 声明为volatile变量:

private volatile int value;

神奇的volatile关键字

神奇的volatile关键字解决了神奇的失效数据问题。

Java变量的读写

Java通过几种原子操作完成工作内存主内存的交互:

  1. lock:作用于主内存,把变量标识为线程独占状态。
  2. unlock:作用于主内存,解除独占状态。
  3. read:作用主内存,把一个变量的值从主内存传输到线程的工作内存。
  4. load:作用于工作内存,把read操作传过来的变量值放入工作内存的变量副本中。
  5. use:作用工作内存,把工作内存当中的一个变量值传给执行引擎。
  6. assign:作用工作内存,把一个从执行引擎接收到的值赋值给工作内存的变量。
  7. store:作用于工作内存的变量,把工作内存的一个变量的值传送到主内存中。
  8. write:作用于主内存的变量,把store操作传来的变量的值放入主内存的变量中。

volatile如何保持内存可见性

volatile的特殊规则就是:

  • read、load、use动作必须连续出现
  • assign、store、write动作必须连续出现

所以,使用volatile变量能够保证:

  • 每次读取前必须先从主内存刷新最新的值。
  • 每次写入后必须立即同步回主内存当中。

也就是说,volatile关键字修饰的变量看到的随时是自己的最新值。线程1中对变量v的最新修改,对线程2是可见的。

防止指令重排

在基于偏序关系Happens-Before内存模型中,指令重排技术大大提高了程序执行效率,但同时也引入了一些问题。

一个指令重排的问题——被部分初始化的对象

懒加载单例模式和竞态条件

一个懒加载单例模式实现如下:

class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) { //这里存在竞态条件
instance = new Singleton();
}
return instance;
}
}

竞态条件会导致instance引用被多次赋值,使用户得到两个不同的单例。

DCL和被部分初始化的对象

为了解决这个问题,可以使用synchronized关键字将getInstance方法改为同步方法;但这样串行化的单例是不能忍的。所以我猿族前辈设计了DCL(Double Check Lock,双重检查锁)机制,使得大部分请求都不会进入阻塞代码块:

class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() {
if ( instance == null ) { //当instance不为null时,仍可能指向一个“被部分初始化的对象”
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
}
}
}
return instance;
}
}

“看起来”非常完美:既减少了阻塞,又避免了竞态条件。不错,但实际上仍然存在一个问题——当instance不为null时,仍可能指向一个"被部分初始化的对象"

问题出在这行简单的赋值语句:

instance = new Singleton();

它并不是一个原子操作。事实上,它可以”抽象“为下面几条JVM指令:

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

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM可以以“优化”为目的对它们进行重排序,经过重排序后如下:

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

可以看到指令重排之后,操作 3 排在了操作 2 之前,即引用instance指向内存memory时,这段崭新的内存还没有初始化——即,引用instance指向了一个"被部分初始化的对象"。此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if条件判为false,方法返回instance引用,用户得到了没有完成初始化的“半个”单例。

解决这个该问题,只需要将instance声明为volatile变量:

private static volatile Singleton instance;

也就是说,在只有DCL没有volatile的懒加载单例模式中,仍然存在着并发陷阱。我确实不会拿到两个不同的单例了,但我会拿到“半个”单例(未完成初始化)。

然而,许多面试书籍中,涉及懒加载的单例模式最多深入到DCL,却只字不提volatile。这“看似聪明”的机制,曾经被我广大初入Java世界的猿胞大加吹捧——我在大四实习面试跟谁学的时候,也得意洋洋的从饱汉、饿汉讲到Double Check,现在看来真是傻逼。对于考查并发的面试官而言,单例模式的实现就是一个很好的切入点,看似考查设计模式,其实期望你从设计模式答到并发和内存模型。

volatile如何防止指令重排

volatile关键字通过“内存屏障”来防止指令被重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。

下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

进阶

在一次回答上述问题时,忘记了解释一个很容易引起疑惑的问题:

如果存在这种重排序问题,那么synchronized代码块内部不是也可能出现相同的问题吗?

即这种情况:

class Singleton {
...
if ( instance == null ) { //可能发生不期望的指令重排
synchronized (Singleton.class) {
if ( instance == null ) {
instance = new Singleton();
System.out.println(instance.toString()); //程序顺序规则发挥效力的地方
}
}
}
...
}

难道调用instance.toString()方法时,instance也可能未完成初始化吗?

首先还请放宽心,synchronized代码块内部虽然会重排序,但不会在代码块的范围内导致线程安全问题

Happens-Before内存模型和程序顺序规则

程序顺序规则:如果程序中操作A在操作B之前,那么线程中操作A将在操作B之前执行。

前面说过,只有在Happens-Before内存模型中才会出现这样的指令重排序问题。Happens-Before内存模型维护了几种Happens-Before规则,程序顺序规则最基本的规则。程序顺序规则的目标对象是一段程序代码中的两个操作A、B,其保证此处的指令重排不会破坏操作A、B在代码中的先后顺序,但与不同代码甚至不同线程中的顺序无关

因此,在synchronized代码块内部,instance = new Singleton()仍然会指令重排序,但重排序之后的所有指令,仍然能够保证在instance.toString()之前执行。进一步的,单线程中,if ( instance == null )能保证在synchronized代码块之前执行;但多线程中,线程1中的if ( instance == null )却与线程2中的synchronized代码块之间没有偏序关系,因此线程2中synchronized代码块内部的指令重排对于线程1是不期望的,导致了此处的并发陷阱。

类似的Happens-Before规则还有volatile变量规则监视器锁规则等。程序猿可以借助(Piggyback)现有的Happens-Before规则来保持内存可见性和防止指令重排。

注意点

上面简单讲解了volatile关键字的作用和原理,但对volatile的使用过程中很容易出现的一个问题是:

错把volatile变量当做原子变量。

出现这种误解的原因,主要是volatile关键字使变量的读、写具有了“原子性”。然而这种原子性仅限于变量(包括引用)的读和写,无法涵盖变量上的任何操作,即:

  • 基本类型的自增(如count++)等操作不是原子的。
  • 对象的任何非原子成员调用(包括成员变量成员方法)不是原子的。

如果希望上述操作也具有原子性,那么只能采取锁、原子变量更多的措施。

总结

综上,其实volatile保持内存可见性和防止指令重排序的原理,本质上是同一个问题,也都依靠内存屏障得到解决。更多内容请参见JVM相关书籍。


参考:


本文链接:volatile关键字的作用、原理

作者:猴子007

出处:https://monkeysayhi.github.io

本文基于 知识共享署名-相同方式共享 4.0 国际许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名及链接。

volatile关键字的作用、原理的更多相关文章

  1. 面试题:volatile关键字的作用、原理

    在只有双重检查锁,没有volatile的懒加载单例模式中,由于指令重排序的问题,我确实不会拿到两个不同的单例了,但我会拿到“半个”单例. 而发挥神奇作用的volatile,可以当之无愧的被称为Java ...

  2. Java 中 volatile 关键字及其作用

    引言 作为 Java 初学者,几乎从未使用过 volatile 关键字.但是,在面试过程中,volatile 关键字以及其作用还是经常被面试官问及.这里给各位童靴讲解一下 volatile 关键字的作 ...

  3. Java并发编程学习笔记 深入理解volatile关键字的作用

    引言:以前只是看过介绍volatile的文章,对其的理解也只是停留在理论的层面上,由于最近在项目当中用到了关于并发方面的技术,所以下定决心深入研究一下java并发方面的知识.网上关于volatile的 ...

  4. volatile关键字的作用

    引言:以前只是看过介绍volatile的文章,对其的理解也只是停留在理论的层面上,由于最近在项目当中用到了关于并发方面的技术,所以下定决心深入研究一下java并发方面的知识.网上关于volatile的 ...

  5. Java中volatile关键字及其作用是什么?

    在 Java 多线程中如何保证线程的安全性?那我们可以使用 Synchronized 同步锁来给需要多个线程访问的代码块加锁以保证线程安全性.使用 synchronized 虽然可以解决多线程安全问题 ...

  6. Java volatile 关键字底层实现原理解析

    本文转载自Java volatile 关键字底层实现原理解析 导语 在Java多线程并发编程中,volatile关键词扮演着重要角色,它是轻量级的synchronized,在多处理器开发中保证了共享变 ...

  7. 多线程中volatile关键字的作用

    原文链接:https://blog.csdn.net/xuwentao37x/article/details/27804169 多线程的程序是出了名的难编写.难验证.难调试.难维护,这通常是件苦差事. ...

  8. const,static,volatile关键字的作用

    const关键字: 1.欲阻止一个变量被改变,可使用const,在定义该const变量时,需先初始化,以后就没有机会改变他了: 2.对指针而言,可以指定指针本身为const,也可以指定指针所指的数据为 ...

  9. Java内存模型中volatile关键字的作用

    volatile作用总结: 1. 强制线程从公共内存中取得变量的值,而不是从线程的私有的本地内存中,volatile修饰的变量不具有原子性(修改一个变量的值不能同步). 2. 保证volatile修饰 ...

随机推荐

  1. 抓取错误之onerror

    一处定义,可以抓取全局的错误,相当于一个全局的try catch呀. <html> <head> <script type="text/javascript&q ...

  2. Maven中使用Jetty容器

    1.在pom.xml中添加Jetty的插件 <plugin> <groupId>org.mortbay.jetty</groupId> <artifactId ...

  3. Eclipse js报错问题解决办法

    最近在Eclipse中导入新项目后会发现js报错,但是不影响程序的运行,但是对于程序员的我们来说多少还是比较在意代码前面的红色的X的,有木有??? 上网也查了很多方法,对于其中一种方法表示不能完全解决 ...

  4. 优化cocos2d/x程序的内存使用和程序大小

    本站文章均为李华明Himi原创,转载务必在明显处注明:转载自[黑米GameDev街区] 原文链接: http://www.himigame.com/iphone-cocos2d/1043.html ☞ ...

  5. LSTM神经网络

    LSTM是什么 LSTM即Long Short Memory Network,长短时记忆网络.它其实是属于RNN的一种变种,可以说它是为了克服RNN无法很好处理远距离依赖而提出的. 我们说RNN不能处 ...

  6. docker-compose工具

    环境:CentOS7.0 安装版本:1.3.2 参考资料:https://docs.docker.com/compose/install/ 安装方式: [root@Docker ~]#curl -L ...

  7. 负margin

    负margin理论: 何谓参考线?参考线就是 margin移动的基准点,此基准点相对于box(自身)是静止的.而margin的数值,就是box相对于参考线的位移量. 一个完整的margin属性是这么写 ...

  8. 怎样取消老毛桃软件赞助商---只需在输入框中输入老毛桃官网网址“laomaotao.org”

    来源:www.laomaotao.org 时间:2015-01-29 在众多网友和赞助商的支持下,迄今为止,老毛桃u盘启动盘制作工具已经推出了多个版本.如果有用户希望取消显示老毛桃软件中的赞助商,那不 ...

  9. 【poj3169】【差分约束+spfa】

    题目链接http://poj.org/problem?id=3169 题目大意: 一些牛按序号排成一条直线. 有两种要求,A和B距离不得超过X,还有一种是C和D距离不得少于Y,问可能的最大距离.如果没 ...

  10. test20180922 打铁的匠

    题意 分析 法一:吉司机线段树 这是一个在线的\(O( n + q \cdot \log^2 n)\)做法. 考虑维护节点到根的权值前缀和cost,那么查询的时候区间减去子树根节点的cost就是价值. ...