绕不开的并发编程--volatile原理

151 阅读22分钟

简单介绍

JMM的设计

JMM的设计意图

  • 我们程序员写代码时,是要求内存模型易于理解,易于编程。

    所以我们需要依赖一个强内存模型来编码。 也就是说像公理一样,定义好的规则,我们遵守规则写代码就完事了。

  • 对于编译器和处理器的实现来说,它们希望约束尽量少一些。

    毕竟你限制它们肯定影响它们的执行效率,不能让他们尽己所能的优化来提供性能。所以他们需要一个弱内存模型。

于是根据上面两点我们知道,作为程序员我们希望JMM提供给我们一个强内存模型,而底层的编译器和处理器又需要一个弱内存模型来提高自己的性能。

在计算机领域,这种需要权衡的场景非常多,比如内存和CPU寄存器,就引入了CPU多级缓存来解决性能问题,不过也引入了多核CPU并发场景下的各种问题。

所以这里也一样,我们需要找到一个平衡点,来满足程序员的需求,同时也尽可能满足编译器和处理器限制放松,性能最大化。

  • 因此JMM在设计时,定义了如下策略:

    • 对于会改变程序执行结果的重排序JMM要求编译器和处理器必须禁止这种重排序。
    • 对于不会改变程序执行结果的重排序JMM对编译器和处理器不做要求JMM允许这种重排序)。

    通过这样的策略JMM向程序员提供了足够强的的内存可见性保证,在不影响程序执行结果的情况下,有些可见性保证并一定存在。

    比如说下面的程序:A happens-before B 并不保证,因为其不影响程序执行结果:

     double pi = 3.14; // A
     double r = 1.0; // B
     double area = pi * r * r; // C
    

    这就引出了另一个方面,JMM为了满足编译器和处理器的约束尽可能少,它遵循的规则是:只要不改变程序的执行结果,编译器和处理器想怎么优化就怎么优化

    • 例子

      • 锁消除:如果编译器认定一个锁只有可能被单个线程访问,那么这个锁可以被消除。
      • 编译器认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。
      • ...

JMM的设计

JMM的设计分为两部分

  • 一部分是面向我们程序员提供的,也就是happens-before规则,它通俗易懂的向我们程序员阐述了一个强内存模型,我们只要理解 happens-before规则,就可以编写并发安全的程序了。
  • 另一部分是针对JVM实现的,为了尽可能少的对编译器和处理器做约束,从而提高性能,JMM在不影响程序执行结果的前提下对其不做要求,即允许优化重排序。

happens-before原则

happens-beforeJMM最核心的概念。对于Java程序员来说,理解happens-before是理解JMM的关键。

我们知道JMM是为了解决在并发环境下由于 CPU缓存、编译器和处理器的指令重排序导致的可见性、有序性问题。

通过学习volatile的内存语义实现原理我们会知道,JMM解决指令重排其实是因为它定义了一项happens-before原则。

如何理解happens-before原则呢?

happens-before 表达的并不是说前面一个操作发生在后面一个操作的前面,尽管从程序员编程角度来看也并不会出错,但它其实表达的是,前一个操作的结果对后续操作时可见的

JMM为程序员提供的视角就是按顺序执行的,且满足一个操作 happens-before 于另一个操作,那么第一个操作的执行结果将对第二个执行结果可见,而且第一个操作的执行顺序排在第二个顺序之前。 注意,这是 JMM向程序员做出的保证

但是其实JMM在对编译器和处理器进行约束时,如前面所说遵循的规则是在不改变程序执行的结果的前提下,编译器和处理器如何优化都可以。

意思就是说即使两个操作之间存在 happens-before原则,Java平台也不一定会按照规则定义的顺序来执行。这么做的原因是因为程序员并不关心两个操作是否被重排序,只要保证程序执行时语义不被改变就行了。

happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

具体的happens-before规则定义

  • 程序顺序规则

    一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这个还是非常好理解的,比如上面那三行代码,A happens-before 于 B,这就是规则1的内容,比较符合单线程里面的逻辑思维,很好理解。

     double pi = 3.14; // A
     double r = 1.0; // B
     double area = pi * r * r; // C
    
  • 监视器锁规则

    对于一个锁的解锁,happens-before于随后对这个锁的加锁

    例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

     synchronized (this) { //此处自动加锁
       // x是共享变量,初始值=10
       if (this.x < 12) {
         this.x = 12; 
       }  
     } //此处自动解锁
    
  • volatile变量规则

    对一个volatile域的写,happens-before于任意后续对这个volatile域的读

    关于这一点我们可以关联下一点「传递性」来理解。

  • 传递性

    如果A happens-before B,且B happens-before C,那么A happens-before C。

    我们将传递性应用到我们下面的例子中

     class VolatileExample {
       int x = 0;
       volatile boolean v = false;
       public void writer() {
         x = 42;
         v = true;
       }
       public void reader() {
         if (v == true) {
           // 这里x会是多少呢?
         }
       }
     }
    

    可以看下面这幅图:

    image.png

    从图中我们可以看到:

    • 『x=42』 Happens-Before 写变量 『v=true』 ,这是规则 「程序顺序规则」 的内容;
    • 写变量『v=true』 Happens-Before 读变量 『v=true』,这是规则 「volatile变量规则」 的内容 。
    • 再根据这个传递性规则,我们得到结果:『x=42』 Happens-Before 读变量『v=true』。这意味着什么呢?

    如果线程 B 读到了『v=true』,那么线程 A 设置的『x=42』对线程 B 是可见的。

    也就是说,线程 B 能看到 『x == 42』 ,有没有一种恍然大悟的感觉?这就是 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)就是靠 volatile 语义来搞定可见性的。

  • start()规则

    这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程A在启动子线程 B 前的操作。

  • join()规则

    如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

通过上面6种happens-before规则的组合就能为我们程序员提供一致的内存可见性。最常用的就是「程序顺序规则」和其他规则结合,为我们编写并发程序提供可靠的内存可见性模型。

volatile原理

volatile有什么用?

  • 防重排序

    用一个经典的单例模式--双重检查加锁(DCL)实现来分析重排序问题:

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

    我们为什么要在变量singleton前面加上volatile关键字呢?

    我们知道实例化一个对象其实可以分成三个步骤:

    • 分配内存空间
    • 初始化对象
    • 将内存空间的地址赋值给对应的引用

    但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会编程如下过程:

    • 分配内存空间
    • 将内存空间的地址赋值给对应的引用
    • 初始化对象

    如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变脸设置成volatile类型的变量。

  • 实现可见性

    可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区--线程工作内存。volatile关键字能有效的解决这个问题。

happens-before原则的本质

通过上面我们的了解,我们知道了happens-before的语义本质是一种可见性,A Happens-before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。

于是通过上面的分析我们知道了volatile关键字主要是实现了并发编程中的可见性和有序性,于是接着我们就围绕这两个特性的实现来说明volatile原理

volatile可见性实现原理

volatile变量的内存可见性是基于内存屏障(Memory Barrier)实现的

  • 什么是内存屏障

    内存屏障,又称内存栅栏,是一个CPU指令(后面我们会知道就是lock指令)

    在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序

    JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止 + 特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序

  • lock指令

    我们写一段简单的Java代码,声明一个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工具可以获得编译后的汇编代码:

    image.png

    volatile修饰的共享变量进行写操作的时候会多出lock前缀的指令

    • 什么是lock指令?

      在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。

      后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证。

    • 什么是缓存一致性协议?

      缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。

      LOCK# 因为锁总线效率太低,因此使用了多组缓存。 为了使其行为看起来如同一组缓存那样。因而设计了「缓存一致性协议」。

      缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " 嗅探(snooping)" 协议。

      所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么

      当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。

    • lock指令有什么用?

      lock前缀的指令在多核处理器下会引发两件事情:

      • 将当前处理器缓存行的数据写回到系统内存

        为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存L1L2 或其它)后再进行操作,但操作不知道何时会写到内存。

        如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条lock前缀的指令,将这个变量所在的数据写回到系统内存。

      • 写回内存的操作会使在其它CPU里缓存了该内存地址的数据无效

        为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里

        所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

      volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。

通过内存屏障当前处理器的工作内存的变量内容会写回到主内存中,这样一来就保证了可见性。

volatile有序性实现原理

volatile和happens-before原则的关系

happens-before规则中有一条是volatile变量规则:

对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

为了具体描述这个规则,我们编写一段代码,假设线程A执行writer方法,线程B执行reader方法:

 // 假设线程A执行writer方法,线程B执行reader方法
 public class VolatileExample2 {
     int a = 0;
     volatile boolean flag = false;
 ​
     public void writer() {
         a = 1;              // 1 线程A修改共享变量
         flag = true;        // 2 线程A写volatile变量
     }
 ​
     public void reader() {
         if (flag) {         // 3 线程B读同一个volatile变量
             int i = a;          // 4 线程B读共享变量
         // ……
         }
     }
 }

根据happens-before规则,上述过程会建立3类happens-before关系:

  • 根据程序次序规则:1 happens-before 2 且 3 happens-before 4
  • 根据 volatile 规则:2 happens-before 3
  • 根据 happens-before 的传递性规则:1 happens-before 4

image-20221107135010073

因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。

volatile禁止重排序

为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。

Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。

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

image.png

这里的『NO』表示禁止重排序。表示的是第一个操作是XX,第二个操作是YY的时候会不会插入内存屏障来禁止重排序。

  • 内存屏障分类

    内存屏障说明
    StoreStore 屏障禁止上面的普通写和下面的 volatile 写重排序
    StoreLoad 屏障防止上面的 volatile 写与下面可能有的 volatile 读/写重排序
    LoadLoad 屏障禁止下面所有的普通读操作和上面的 volatile 读重排序
    LoadStore 屏障禁止下面所有的普通写操作和上面的 volatile 读重排序

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

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

    • 对于 volatile 写操作

      image.png

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

      image-20221107142630049

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

    volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。

    基本上就是这几类的内存屏障中和普通读写的规则再加上 volatile 读/写操作互相禁止重排序,就是这几个操作的顺序规则了。

于是volatile有序性基于内存屏障来禁止指令重排,再加上JMM本身制定的happens-before保证了有序性。

volatile使用

volatile关键字的两层语义

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  • 保证了不同线程对这个变量进行操作时的可见性

    即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

  • 禁止进行指令重排序。

我们不难发现这两个语义也就是我们上面说的可见性和有序性。但是将语义赋给共享变量更符合我们编程的时候的思考方式。

为了描述这两个语义更加具体,我们会看两个例子

  • 可见性语义场景

    假如线程1先执行,线程2后执行:

     //线程1
     boolean stop = false;
     while(!stop){
         doSomething();
     }
      
     //线程2
     stop = true;
    

    很多人中断线程时可能都会采用这种标记办法。

    • 但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?

      不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

      每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。

      那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

    • 那么加上volatile修饰会有什么不同?

      分析这个问题实际就是给这个共享变量增加上述的描述可见性的语义,然后加以推导:

      • 使用volatile关键字会强制将修改的值立即写入主存;

      • 使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);

        由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

      那么线程1读取到的就是最新的正确的值。

  • 禁止指令重排语义场景

    volatile关键字禁止指令重排序的语义再扩展一下的话有两层意思:

    • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

      (相当于对程序员的承诺)

    • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

      (相当于对编译器的要求)

    举一个简单的例子:

     //x、y为非volatile变量
     //flag为volatile变量
      
     x = 2;        //语句1
     y = 0;        //语句2
     flag = true;  //语句3
     x = 4;         //语句4
     y = -1;       //语句5
    

    由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

    并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

    再来一个例子,这次真正的用禁止重排序解决问题:

    这里线程1先执行,线程2也随后立刻开始执行

     //线程1:
     context = loadContext();   //语句1
     inited = true;             //语句2
      
     //线程2:
     while(!inited ){
       sleep()
     }
     doSomethingwithconfig(context);
    
    • 指令重排让语句2在语句1前执行会有什么问题?

      有可能语句2会在语句1之前执行,那么就可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。

    • 用了volatile会有什么不同?

      这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。(就像一个屏障一样)

使用volatile必须具备的条件

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。

通常来说,使用volatile必须具备以下几个条件:

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。
  • 只有在状态真正独立于程序内其他内容时才能使用 volatile

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

事实上,上面的几个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

volatile的应用场景

  • 一. 状态标志

    也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。

     volatile boolean shutdownRequested;
     ......
     public void shutdown() { shutdownRequested = true; }
     public void doWork() { 
         while (!shutdownRequested) { 
             // do stuff
         }
     }
    

    这里即使用shutdownRequested来做状态标志位,一旦被其他线程修改(shutdown),doWork程序就会结束。

  • 二. 一次性安全发布(one-time safe publication)

    缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。

    在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。 (这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。

     public class BackgroundFloobleLoader {
         public volatile Flooble theFlooble;
      
         public void initInBackground() {
             // do lots of stuff
             theFlooble = new Flooble();  // this is the only write to theFlooble
         }
     }
      
     public class SomeOtherClass {
         public void doWork() {
             while (true) { 
                 // do some stuff...
                 // use the Flooble, but only if it is ready
                 if (floobleLoader.theFlooble != null) 
                     doSomething(floobleLoader.theFlooble);
             }
         }
     }
    

    doWork在真正用floobleLoader.theFloobledoSometing之前先进行一次对象引用是否为空的检查。

    只有被另一个线程修改并发布(volatile强制修改后写回主存的语义),才会执行,保证了操作的安全。(不会出现对象引用的更新值new Flooble()和旧值null同时存在的可能)

  • 三. 独立观察(independent observation)

    安全使用 volatile 的另一种简单模式是定期 发布 观察结果供程序内部使用。

    例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

     static class EnvironmentalSensor{
         private static volatile double temperature=0;
     ​
         public static void setTemperature(double value){
             temperature=value;
         }
     ​
         public static double getTemperature(){
             return temperature;
         }
     }
     ​
     public void readAndModifyTemperature(){
         for (int i = 0; i < 100; i++) {
             try {
                 Thread.sleep(5 * 1000); //设置暂停的时间 5 秒
                 EnvironmentalSensor.setTemperature(i);
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }
     }
     ​
     public void watchNewTemperature(){
         System.out.println(EnvironmentalSensor.getTemperature());
     }
    
  • 四. volatile bean 模式

    volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 gettersetter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。

    此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。

     @ThreadSafe
     public class Person {
         private volatile String firstName;
         private volatile String lastName;
         private volatile int age;
      
         public String getFirstName() { return firstName; }
         public String getLastName() { return lastName; }
         public int getAge() { return age; }
      
         public void setFirstName(String firstName) { 
             this.firstName = firstName;
         }
      
         public void setLastName(String lastName) { 
             this.lastName = lastName;
         }
      
         public void setAge(int age) { 
             this.age = age;
         }
     }
    
  • 五. 开销较低的读 - 写锁策略

    volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。

    如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。

    安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性

    如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

    (总的来说就是读交给volatile,写交给synchronized

     @ThreadSafe
     public class CheesyCounter {
         // Employs the cheap read-write lock trick
         // All mutative operations MUST be done with the 'this' lock held
         @GuardedBy("this") private volatile int value;
      
         public int getValue() { return value; }
      
         public synchronized int increment() {
             return value++;
         }
     }
    
  • 六. 双重检查

    单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。

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

小结

本章我们详细的介绍了volatile关键字,这么一个开销小于synchronized却能为我们提供并发安全的可见性和有序性的关键字。

相信学习了本篇文章后能对happens-before原则有着更深的理解,并且能够使用volatile关键字来保证happens-before原则,写出并发安全的程序。

本章参考: