蜗牛大师

吴庆龙的学习笔记

导航

并发中的volatile

1. 概述

由于线程有本地内存的存在, 一个线程修改的共享变量不会及时的刷新到主内存中, 使得另一个线程读取共享变量时读取到的仍旧是旧值, 就导致了内存可见性问题. 现在volatile就可以解决这个问题, 为什么能解决内存可见性问题呢? 本文就来揭开volatile的神秘面纱.

2. volatile的特性

理解volatile特性的一个好方法就是把对volatile单个变量的读/写, 看成是使用同一个锁对单个变量的读/写做了同步.

锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性, 这意味着对一个volatile变量的读, 总是能看到(任意线程)对这个volatile变量最后的写入.

锁的语义决定了临界区代码的执行具有原子性. 这意味着, 即使是64位的long型和double型变量, 只要它是volatile变量, 对该变量的读/写就具有原子性. 如果是多个volatile操作或类似于volatile++这种复合操作, 这些操作整体上不具有原子性.

简言之, volatile变量自身具有以下特性.

  • 可见性: 对一个volatile变量的读, 总是能看到任意线程对这个volatile变量最后的写入.
  • 原子性: 对任意单个volatile变量的读/写具有原子性, volatile变量的复合操作不具有原子性.

3. volatile写-读的内存语义

volatile写的内存语义

当写一个volatile变量时, JMM会把该线程对应的本地内存中的共享变量值刷新到主内存中.

volatile读的内存语义

当读一个volatile变量时, JMM会把线程对应的本地内存中的共享变量值置为无效, 线程接下来将从主内存中读取共享变量.

volatile内存语义总结

  • 线程A写一个volatile变量, 实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息.
  • 线程B读一个volatile变量, 实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息.
  • 线程A写一个volatile变量, 随后线程B读这个volatile变量, 这个过程实质上是线程A通过主内存向线程B发送消息.

4. volatile内存语义的实现

前面提到过重排序分为编译器重排序和处理器重排序. 为了实现volatile语义, JMM会分别限制这两种类型的重排序类型.

JMM针对编译器制定的volatile重排序规则表

从图中可以看出:

  • 当第二个操作是volatile写时, 不管第一个操作是什么, 都不能重排序. 这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后.
  • 当第一个操作是volatile读时, 不管第二个操作是什么, 都不能重排序. 这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前.
  • 当第一个操作是volatile写, 第二个操作是volatile读时, 不能重排序.

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

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

关于内存屏障可以看 https://www.cnblogs.com/wuqinglong/p/9947786.html

上述内存屏障插入策略非常保守, 但它可以保证在任意处理器平台, 任意的程序中都能得到正确的volatile内存语义.

在实际执行时, 只要不改变volatile写-读的内存语义, 编译器可以根据具体情况省略不必要的屏障. 举个例子.

有如下代码:

public class Demo {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;
    void readAndWrite() {
      int i = v1; // 第一个volatile读
      int j = v2; // 第二个volatile读
      a = i + j; // 普通写
      v1 = i + 1; // 第一个volatile写
      v2 = j * 2; // 第二个 volatile写
    }
}

针对readAndWrite()方法, 理论上生成字节码时会如下:

int i = v1; // volatile读后面插入LoadLoad和LoadStore屏障
LoadLoad; // 确保v1的装载先于后续装载指令
LoadStore; // 确保v1的加载先于后续存储指令

int j = v2; // volatile读后面插入LoadLoad和LoadStore屏障
LoadLoad; // 确保v2的装载先于后续装载指令
LoadStore; // 确保v2的加载先于后续存储指令

a = i + j; // 普通读写无屏障

StoreStore; // 确保之前的存储指令要先于v1的存储
v1 = i + 1; // volatile写前加StoreStore屏障, 后加StoreLoad屏障
StoreLoad; // 确保v1的存储要先于后续的装载指令

StoreStore;  // 确保之前的存储指令要先于v1的存储
v2 = j + 1; // volatile写前加StoreStore屏障, 后加StoreLoad屏障
StoreLoad; // 确保v1的存储要先于后续的装载指令

由于不同的处理器有不同的"松紧度"的处理器内存模型, 内存屏障的插入还可以根据具体的处理器内存模型继续优化, 以X86处理器为例, 处理最后的StoreLoad屏障外, 其它的屏障都会被省略. X86处理器仅会对写-读操作做重排序, 不会对读-读, 读-写和写-写操作做重排序. 因此X86处理器会省略掉这3中操作类型对应的内存屏障. 所以在X86处理器中, JMM仅需在volatile写后面插入一个StoreLoad屏障即可实现volatile写-读的内存语义.

下面是X86处理器优化之后的内存屏障

int i = v1; // volatile读后面插入LoadLoad和LoadStore屏障
// LoadLoad; // 确保v1的装载先于后续装载指令
// LoadStore; // 确保v1的加载先于后续存储指令

int j = v2; // volatile读后面插入LoadLoad和LoadStore屏障
// LoadLoad; // 确保v2的装载先于后续装载指令
// LoadStore; // 确保v2的加载先于后续存储指令

a = i + j; // 普通读写无屏障

// StoreStore; // 确保之前的存储指令要先于v1的存储
v1 = i + 1; // volatile写前加StoreStore屏障, 后加StoreLoad屏障
StoreLoad; // 确保v1的存储要先于后续的装载指令

// StoreStore;  // 确保之前的存储指令要先于v1的存储
v2 = j + 1; // volatile写前加StoreStore屏障, 后加StoreLoad屏障
StoreLoad; // 确保v1的存储要先于后续的装载指令

5. JSR-133为什么要增强volatile的内存语义

JSR-133也就是在JDK1.5中加入的.

在JSR-133之前的旧Java内存模型中, 虽然不允许volatile变量之间重排序, 但旧的Java内存模型允许volatile变量与普通变量重排序.

在旧的内存模型中, 当1和2之间没有数据依赖关系时, 1和2之间就可能被重排序(3和4类似). 其结果就是: 读线程B执行4时, 不一定能看到写线程A在执行1时对共享变量的修改.

因此, 在旧的内存模型中, volatile的写-读没有锁的释放-获所具有的内存语义. 为了提供一种比锁更轻量级的线程之间通信的机制, JSR-133专家组决定增强volatile的内存语义: 严格限制编译器和处理器对volatile变量与普通变量的重排序, 确保volatile的写-读和锁的释放-获取具有相同的内存语义. 从编译器重排序规则和处理器内存屏障插入策略来看, 只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义, 这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止.

6. 总结

volatile能保证内存可见性正是通过内存屏障来实现的, 并且不同的编译器对内存屏障的支持不同, 但是由于大多数处理器都使用了写缓冲区, 所以大多数处理器都支持StoreLoad屏障.

posted on 2018-11-12 19:39  蜗牛大师  阅读(772)  评论(0编辑  收藏  举报