Java并发之volatile详解

带着问题阅读

1、为什么需要volatile,volatile能解决什么问题

2、volatile的实现原理是什么

3、什么是happen-before

4、volatile是否能保证线程安全

Java内存模型JMM

介绍volatile之前,首先对Java内存模型进行说明。在C\C++等语言中,内存管理直接使用物理硬件和操作系统的内存模型,也因此会导致程序无法在不同平台上完全兼容。《Java虚拟机规范》中试图定义Java内存模型(Java Memeroy Model)来屏蔽硬件和操作系统之间的内存访问差异,以达到Java程序的跨平台兼容性。

定义Java内存模型并非易事,模型必须定义严谨,不能让内存访问产生歧义;也必须足够宽松,便于虚拟机有足够的灵活度去利用硬件的特性来提升内存操作速度。经过长时间验证、修补,直到Jdk 5,Java内存模型才成熟起来。

JMM

Java内存模型规定所有变量都存储在主内存中(虚拟内存,非物理内存),每条线程都有自己的工作内存,工作内存中拷贝了线程所需变量的副本。线程对所有变量的操作都作用在工作内存的副本上,不能直接操作主内存。不同线程之间的工作内存互相隔离,无法直接访问其他工作内存中的变量。

volatile作用介绍

volatile是Java提供的最轻量级的同步操作,用于保障可见性和有序性

可见性

在JMM的介绍中可知,每个Java线程都拥有自己的工作内存,如果两个线程共享同一个变量, 那么每个线程都在自己的工作内存中拷贝了一份该变量。当A线程对变量做出修改后,B线程对变量的修改是不能立即可见的,只有当A线程将变量刷入主内存,并在B线程重新加载主内存变量时,B线程才能得到A线程修改的值。

变量添加volatile后,即可让修改立即同步到主存中,并要求在使用前立即从主内存重新读取,以保证变量的可见性。

synchronized释放锁后,同步块的修改都会同步到主存,因此synchronized也可保证可见性。

有序性

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指"线程内似表现为串行的寓意",后半句是指"指令重排"和"工作内存与主内存同步延迟"现象。

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

以标准的单例模式为例,创建单例对象在执行时实际分为三步:分配内存空间、初始化对象、将内存地址赋值给引用;在指令重排后可能会变为:分配空间、赋值引用、初始化对象。

考虑多线程环境,A线程执行了new Instance()赋值引用且尚未初始化,B线程进入方法判断instance == nullfalse,直接获取到一个未初始化的对象,可能引起一些不可预料的错误。通过添加volatile可确保insntace的创建过程中不被重排序。

synchronized由于同一时刻只允许一个线程进入代码块,因此synchronized也可保证有序性。

原子性

volatile只能保证读写的原子性,无法保证其他操作的原子性。

volatile int i = 0;

for (int n = 0; n < 1000; n++) {
    new Thread(() -> i++).start();
} 

如示例代码,常见的误区是i在多线程自增到1000,实际上volatile并不能保证i++的同步和原子性。因为i++会分解为三条指令:读取、加1、写入,volatile只有在读取和写入阶段可保证原子性,因此想要解决同步问题,还是要依靠synchronizedlock

volatile原理分析

可见性原理

public class Test {
    private volatile int a;
    public void update() {
        a = 1;
    }
    public static void main(String[] args) {
        Test test = new Test();
        test.update();
    }
}

通过hsdisjitwatch查看编译后的汇编代码。

....
0x000000000295158c: lock cmpxchg %rdi,(%rdx) // volatile写增加lock前缀
....

lock指令和缓存一致性协议

lock前缀指令的执行会触发两件事:

  • 将当前处理器缓存行的数据写回到主存
  • 将其他处理缓存行内的该内存地址的缓存失效

假设A、B两个线程都将变量var的指加载到了自己的工作内存,对var添加volatile修饰后,当A线程修改var的值,该修改会立即刷新到主内存中,并且B线程的var变量缓存会置为失效,当B线程读取该变量时,就需要重新到主内存中加载,如此便保持了变量的可见性。这种多缓存场景下的数据一致性通过缓存一致性协议(MESI)保证。

关于缓存一致性推荐参考第三篇文章,附MESI演示

可见性

lock指令的早期实现会对总线进行锁定,锁定期间其他处理器对内存的读写都被阻塞,直到锁定释放。由于锁总线的开销太大,因此后期使用缓存锁代替总线锁。

有序性原理

lock指令由于锁的存在,对缓存的读一定在写之后,lock也因此暗含了一定有序性的保证。而在整体代码指令上的有序性,是由内存屏障保证的,lock并不能提供内存屏障

JSR-133将读写分为:

  • 普通读:非volatile字段的读取如getField,getStatic或数组加载
  • 普通写:非volatile字段的写如setField,setStatic或数组存储
  • volatile读:多线程可达的volatile字段读取
  • volatile写:多线程可达的volatile字段

JMM对四种读写制定了重排规则:

volatile重排规则

根据该表有以下语义:

  • 第一个操作为volatile读,后续任何读写都不得重排到该操作之前
  • 第二个操作为volatile读,前序volatile读写不得重排到该操作之后
  • 第一个操作为volatile写,后续volatile读写不得重排到该操作之前
  • 第二个操作为volatile写,前序任何读写都不得重排到该操作之后

为了实现有序性语义,JMM提供了四种内存屏障

内存屏障 指令序 说明
StoreStore屏障 Store1;StoreStore:Store2 确保Store1数据对其他处理器可见先于Store2及所有后续存储指令
StoreLoad屏障 Store1;StoreLoad;Load2 确保Store1数据对其他处理器可见先于Load2及后续加载指令
LoadLoad屏障 Load1;LoadLoad;Load2 确保Load1数据装载先于Load2及所有后续装载指令
LoadStore屏障 Load1;LoadStore;Store2 确保Load1数据装载先于Store2及后续所有存储指令

volatile基于内存屏障禁止指令重排序,实际上发现一个最小化插入内存屏障的总数几乎是不可能的,JMM采取了保守策略,主要遵循以下规则:

  • 在每个volatile写之前插入StoreStore屏障,确保volatile写之前的写操作不会被重排到volatile写之后
  • 在每个volatile写之后插入StoreLoad屏障,确保volatile写之后的读写操作不会重排到volatile写之前
  • 在每个volatile读之后插入LoadLoad屏障和LoadStore屏障,禁止下面所有读写操作重排到volatile读之前

为什么在volatile写之前没有加入LoadStore保证读不能重排在写后面呢?

个人理解是因为volatile的lock指令限制了写优先于读,因此省略了该屏障。

实际执行时,编译器会根据具体情况省略不必要的屏障,如下面的示例:

int a;
volatile int v, u;

void f() {
    int i, j;
    i = a;          // load a
    i = v;          // load v
                    // LoadLoad 因为store a不可能越过load u,可省略LoadStore
    j = u;          // load u
                    // LoadStore 因为下面没有读,可省略LoadLoad
    a = i;          // store a
                    // StoreStore
    v = i;          // store v
                    // 因为紧跟一个volatile写,省略StoreLoad交给store u添加
                    // StoreStore
    u = j;          // store u
                    // StoreLoad s
    i = u;          // load u
                    // LoadLoad 防止下面的load a重排
                    // LoadStore 防止下面的store a重排
    j = a;          // load a
    a = i;          // store a
}

先行发生原则 happen-before

如果Java中所有的有序性都靠添加volatilesynchronized来保证,会使得程序编写非常啰嗦。但我们在编写Java程序时并没有感受到这一点,是因为Java语言的先行发生原则happen-before

先行发生原则通俗解释即,如A操作产生的影响对B操作有效,那么A应当是先行发生于B。先行发生的概念不难理解,假如没有先行发生的约束,会出现什么问题呢?

boolean configured = false;

// Thread1
while (!configured) {}
doSometing();

// Thread2
loadConfig();
configured = true;

看一段示例代码,Thread1需等待Thread2加载配置完毕后,才能继续往下执行,于是逻辑上Thread2的操作应当先行发生于Thread1。在Thread2中,loadConfig应当先行发生于configured=true。如果没有先行发生原则,由于指令重排的存在,Thread2可能先执行了configured = true然后才加载配置,而Thread1已读取到configured:true,程序就会发生不可预知的错误。

Java内存模型定义了几条天然的先行发生关系,如两个操作不在以下范围内,则不保证操作间的顺序执行。

  • 程序次序规则。在一个线程内,书写在前面的代码先行发生于后面的。确切地说应该是,按照程序的控制流顺序,因为存在一些分支结构。

  • 管程锁定规则。一个unlock操作先行发生于后面对同一个锁的lock操作。

  • volatile变量规则对一个volatile修饰的变量,对他的写操作先行发生于读操作。

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

  • 线程终止规则。线程的所有操作都先行发生于对此线程的终止检测。

  • 线程中断规则。对线程interrupt()方法的调用先行发生于被中断线程的代码所检测到的中断事件。

  • 对象终止规则。一个对象的初始化完成(构造函数之行结束)先行发生于发的finilize()方法的开始。

  • 传递性A先行发生B,B先行发生C,那么,A先行发生C。

volatile boolean configured = false;

// Thread1
while (!configured) {} // 操作3
doSometing(); // 操作4

// Thread2
loadConfig(); // 操作1
configured = true; // 操作2

在上面给的例子中,为configured添加volatile修饰,则根据先行发生规则有:

  • 操作1先行发生于操作2(程序次序规则)
  • 操作3先行发生于操作4(程序次序规则)
  • 操作2先行发生于操作3(volatile变量规则)
  • 操作1先行发生于操作4(传递性规则)

如此,即可确保程序按照预定逻辑正常执行。

本文也发布于掘金:https://juejin.cn/post/6998148242928042021/

参考

posted @ 2021-08-19 22:22  拉夫德鲁  阅读(555)  评论(0编辑  收藏  举报