Java 锁机制详解(二)volatile

上接 Java 锁机制详解(一)synchronized

一、 多线程隐患

1. 内存可见性

在赋值变量时,会经历数据由 CPU 写入到 内存 的过程,由于现代 CPU 一般都会有多级缓存,导致写指令可能并不能立即将数据写入到内存。如下图:

CPU写入数据到内存.png

举个例子:

public class Demo {
    private int a = 0;

    public void write() {
        a = 1;
    }

    public void read() {
        Log.e("TAG", a);
    }
}

线程 A 调用 write(),线程 B 调用 read(),返回的结果可能是 1 也有可能是 0,参考上图可知,线程 B 调用 read() 时,数据可能写入到了多级缓存里,而没有写入到内存中,导致读取到的值是 0。

多线程的程序中,一个线程写入的数据不能及时反映到另一个线程中,此时会说一个线程对变量的修改对另一个线程不可见,即 内存可见性

放到 Java 里,对内存可见性又做了一层抽象:

JVM 规定所有变量都存在 主存 中,但是每个线程又有自己的 工作内存,线程的操作都是以工作内存为主,它们只能访问自己的工作内存,且工作前后都要把值在 同步回主内存

即线程执行时,对于读操作,会先从主存中读值,然后赋值到工作内存的副本中,最后传给 CPU。对于写操作,CPU 会先写入到工作内存的副本,然后再传回给主存,此时主存才真正更新。即 Java 的内存可见性

方便理解,主存即可当作内存可见性中的内存,工作内存即可视为内存可见性中的 CPU 多级缓存。

2. 指令重排

看个例子

public class Demo {
    int a = 0;
    boolean flag = false;

    /**
    * A线程执行
    */
    public void writer(){
        a = 1;                  // 1
        flag = true;            // 2
    }

    /**
    * B线程执行
    */
    public int read(){
        if(flag){               // 3
          return a;             // 4
        }
    }
}

线程 A 调用 writer(),线程 B 调用 read(),因为指令重排的影响,read() 返回值可能是 0,也可能是 1。

为什么会这样呢?下面分析原因。

CPU 和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。举个例子:

int a = 1 ;    //1  
int b = 2 ;    //2  
int c = a + b; //3

由于语句 1 和语句 2 不存在依赖关系,因此在重排序时,语句 1、2 可以随意排序,只要总位于语句 3 前即可。

这在单线程是没有问题的,但是多线程情况下,就会有问题。比如指令重排一开始的例子,可能执行顺序为:

2 → 3 → 4 → 1 结果为 0.

可见指令重排在多线程情况下可能导致原语义的破坏。

二、 volatile 解析

正是因为多线程环境下存在 内存可见性指令重排 等问题,所以诞生了 volatile 关键字。

首先对于 内存可见性 导致的问题,volatile 修饰的成员变量在每次被线程访问时,都强迫从主存中重读该成员变量的值。而且,当成员变量发生变化时,强迫线程将变化值回写到主存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值,这样也就保证了同步数据的 可见性

然后对于 指令重排 导致的问题,volatile 做了如下规定:

  1. 在 volatile 变量的写入指令之前,对其它变量的读写指令不能重排到该指令之后。
  2. 在 volatile 变量的读取指令之后,对其它变量的读写指令不能重排到该指令之前。

还是上边的例子:

public class Demo {
    int a = 0;
    volatile boolean flag = false;

    /**
    * A线程执行
    */
    public void writer(){
        a = 1;                  // 1
        flag = true;            // 2
    }

    /**
    * B线程执行
    */
    public int read(){
        if(flag){               // 3
          return a;             // 4
        }
    }
}

因为变量 flag 写入指令之前,其它变量的读写指令不能重排到该指令之后,所以语句 1 一定先语句 2 执行,那么 read() 结果一定为 1。

volatile 如何限制指令重排,涉及到内存屏障的知识点,不是本文重点。

三、 synchronized 与 volatile

既然说到 volatile,就必然提到几个与 synchronized 相关的问题。

1. synchronized 能防止指令重排序吗?

能,synchronized 保证原子性、有序性和可见性,只是代价高。

JVM 规定了 happens-before 规则,volatile 和 synchronized 可以防止指令重排序本质都是遵守此规则。关于 happens-before 后续会单出一篇博客来说明,这里可以不深究。

2. double check 单例是否需要使用 volatile?

public class SingletonClass { 

  private static SingletonClass instance = null; 

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

  private SingletonClass() { } 
}

因为第一个 if 没有包入到 synchronized 中,所以 synchronized 的有序性对第一个 if 是不生效的。

对于 new SingletonClass(); 这个语句,并不是原子操作,可以分为以下三个步骤:

  1. 为 instance 分配内存;
  2. 调用构造函数初始化成员变量;
  3. 将 instance 指向分配的内存空间。

其中步骤 2、3 的顺序因为指令重排的原因,是不确定的。

这就可能发生如下情况:

  1. 线程 A 先将 instance 指向分配的内存空间,此时构造函数还未调用,就执行下一步。
  2. 线程 B 执行第一个 if 语句时,instance 不为空,于是返回、调用。但是实际上 instance 还未初始化,于是出错了。

将 instance 修饰为 volatile,实际上是保证了第一个 if 读的时候的有序性,防止了 instance 指令重排带来的隐患。

3. volatile 能保证原子性吗?

不能。举个例子:

// 定义
private volatile int a = 0;

private class IncreaseThread extends Thread {

    @Override
    public void run() {
        super.run();
        increase();
    }

    private void increase() {
        a++;
    }
}
    
// 执行
for (int i = 0; i < 100; i++) {
    new IncreaseThread().start();
}
Log.e("TAG", "a is " + a);

a 的值总小于 100,原因是因为自增不具备原子性,它由三个字操作构成:

  1. 读取原始值;
  2. 进行加1操作;
  3. 写入工作内存。

有可能出现线程 A 读取原始值之后,阻塞,线程 B 再读取原始值,然后线程 B 加 1 后写入,线程 A 加 1 后写入,俩次自增,实际结果只加了 1。

参考链接
https://www.zhihu.com/question/37601861
https://lotabout.me/2019/Java-volatile-keyword/
https://juejin.im/post/5a2b53b7f265da432a7b821c
//www.greatytc.com/p/b4d4506d3585

[TOC]

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,542评论 6 504
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,822评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,912评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,449评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,500评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,370评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,193评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,074评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,505评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,722评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,841评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,569评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,168评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,783评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,918评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,962评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,781评论 2 354

推荐阅读更多精彩内容