Skip to content

Latest commit

 

History

History
310 lines (242 loc) · 14.4 KB

File metadata and controls

310 lines (242 loc) · 14.4 KB
title Java volatile关键字解析
shortTitle volatile关键字
description 在 Java 中,volatile 是一种特殊的修饰符,主要用于处理多线程编程中的可见性和有序性问题。
category
Java核心
tag
Java并发编程
head
meta
name content
keywords
Java,并发编程,多线程,Thread,volatile

14.8 volatile关键字

今天这篇换个写作风格。

“三妹啊,这节我们来学习 Java 中的 volatile 关键字吧,以及容易遇到的坑。”看着三妹好学的样子,我倍感欣慰。

“好呀,哥。”三妹愉快的答应了。

volatile 变量的特性

volatile 可以保证可见性,但不保证原子性:

  • 当写一个 volatile 变量时,JMM 会把该线程本地内存中的变量强制刷新到主内存中去;
  • 这个写操作会导致其他线程中的 volatile 变量缓存无效。

volatile 会禁止指令重排

我们回顾一下,重排序需要遵守的规则:

  • 重排序不会对存在数据依赖关系的操作进行重排序。比如:a=1;b=a; 这个指令序列,因为第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
  • 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。比如:a=1;b=2;c=a+b 这三个操作,第一步 (a=1) 和第二步 (b=2) 由于不存在数据依赖关系,所以可能会发生重排序,但是 c=a+b 这个操作是不会被重排序的,因为需要保证最终的结果一定是 c=a+b=3。

使用 volatile 关键字修饰共享变量可以禁止这种重排序。如果用 volatile 修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止重排序,规则如下:

  • 当程序执行到 volatile 变量的读操作或者写操作时,在其前面操作的更改肯定已经全部进行,且结果对后面的操作可见;在其后面的操作肯定还没有进行;
  • 在进行指令优化时,不能将 volatile 变量的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。

“也就是说,执行到 volatile 变量时,其前面的所有语句都必须执行完,后面所有得语句都未执行。且前面语句的结果对 volatile 变量及其后面语句可见。”我瞅了了三妹一眼继续说。

先看下面未使用 volatile 的代码:

class ReorderExample {
  int a = 0;
  boolean flag = false;
  public void writer() {
      a = 1;                   //1
      flag = true;             //2
  }
  Public void reader() {
      if (flag) {                //3
          int i =  a * a;        //4
          System.out.println(i);
      }
  }
}

因为重排序影响,所以最终的输出可能是 0,具体分析请参考上一篇,如果引入 volatile,我们再看一下代码:

class ReorderExample {
  int a = 0;
  boolean volatile flag = false;
  public void writer() {
      a = 1;                   //1
      flag = true;             //2
  }
  Public void reader() {
      if (flag) {                //3
          int i =  a * a;        //4
          System.out.println(i);
      }
  }
}

这个时候,volatile 禁止指令重排序也有一些规则,这个过程建立的 happens before 关系 如下:

  1. 根据程序次序规则,1 happens before 2; 3 happens before 4。
  2. 根据 volatile 规则,2 happens before 3。
  3. 根据 happens before 的传递性规则,1 happens before 4。

在 Java 中,"happens-before" 是一个用于描述两个或多个操作之间的偏序关系的概念,它来自于 Java 内存模型 (上一篇有详细讲)。"happens-before" 关系可以确保在并发环境中的内存可见性,即一个线程中的写操作对于另一个线程中的读操作是可见的。

上述 happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个 happens before 关系:

  • 黑色箭头表示程序顺序规则;
  • 橙色箭头表示 volatile 规则;
  • 蓝色箭头表示组合这些规则后提供的 happens before 保证。

这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之前所有可见的共享变量,在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见。

volatile 不适用的场景

复合操作

下面是变量自加的示例:

public class volatileTest {
    public volatile int inc = 0;
    public void increase() {
        inc++;
    }
    public static void main(String[] args) {
        final volatileTest test = new volatileTest();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println("inc output:" + test.inc);
    }
}

测试输出:

inc output:8182

“为什么呀?二哥?” 看到这个结果,三妹疑惑地问。

“因为 inc++不是一个原子性操作(前面讲过),由读取、加、赋值 3 步组成,所以结果并不能达到 10000。”我耐心地回答。

“哦,你这样说我就理解了。”三妹点点头。

解决方法

采用 synchronized,把 inc++ 拎出来单独加 synchronized 关键字:

public class volatileTest1 {
    public int inc = 0;
    public synchronized void increase() {
        inc++;
    }
    public static void main(String[] args) {
        final volatileTest1 test = new volatileTest1();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println("add synchronized, inc output:" + test.inc);
    }
}

采用 Lock,通过重入锁 ReentrantLockinc++ 加锁:

public class volatileTest2 {
    public int inc = 0;
    Lock lock = new ReentrantLock();
    public void increase() {
        lock.lock();
        inc++;
        lock.unlock();
    }
    public static void main(String[] args) {
        final volatileTest2 test = new volatileTest2();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println("add lock, inc output:" + test.inc);
    }
}

采用原子类 AtomicInteger 来实现:

public class volatileTest3 {
    public AtomicInteger inc = new AtomicInteger();
    public void increase() {
        inc.getAndIncrement();
    }
    public static void main(String[] args) {
        final volatileTest3 test = new volatileTest3();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<100;j++)
                        test.increase();
                };
            }.start();
        }
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println("add AtomicInteger, inc output:" + test.inc);
    }
}

三者输出都是 1000,如下:

add synchronized, inc output:1000
add lock, inc output:1000
add AtomicInteger, inc output:1000

单例模式的双重锁与volatile

这是一个使用"双重检查锁定"(double-checked locking)实现的单例模式(Singleton Pattern)的例子。

public class penguin {
    private static volatile penguin m_penguin = null;
    // 避免通过new初始化对象
    private void penguin() {}
    public void beating() {
        System.out.println("打豆豆");
    };
    public static penguin getInstance() {      //1
        if (null == m_penguin) {               //2
            synchronized(penguin.class) {      //3
                if (null == m_penguin) {       //4
                    m_penguin = new penguin(); //5
                }
            }
        }
        return m_penguin;                      //6
    }
}

在这个例子中,penguin 类只能被实例化一次。

首先看代码解释:

  • 声明了一个类型为 penguin 的 volatile 变量 m_penguin,它是类的静态变量,用来存储 penguin 类的唯一实例。
  • penguin() 构造方法被声明为 private,这样就阻止了外部代码使用 new 来创建 penguin 实例,保证了只能通过 getInstance() 方法获取实例。
  • getInstance() 方法是获取 penguin 类唯一实例的公共静态方法。
  • if (null == m_penguin) 检查是否已经存在实例。如果不存在,才进入同步代码块。
  • synchronized(penguin.class) 对类的 Class 对象加锁,这是确保在多线程环境下,同时只能有一个线程进入同步代码块。在同步代码块中,再次检查实例是否已经存在,如果不存在,则创建新的实例。这就是所谓的"双重检查锁定"。
  • 最后返回 m_penguin,也就是 penguin 的唯一实例。

其中,使用 volatile 关键字是为了防止 m_penguin = new penguin() 这一步被指令重排序。实际上,new penguin() 这一步分为三个子步骤:

  • 分配对象的内存空间。
  • 初始化对象。
  • 将 m_penguin 指向分配的内存空间。

如果不使用 volatile 关键字,JVM 可能会对这三个子步骤进行指令重排序,如果步骤2和步骤3被重排序,那么线程A可能在对象还没有被初始化完成时,线程B已经开始使用这个对象,从而导致问题。而使用 volatile 关键字可以防止这种指令重排序。

伪代码代码如下:

amemory = allocate() //分配内存
bctorInstanc(memory//初始化对象
cinstance = memory   //设置instance指向刚分配的地址

上面的代码在编译运行时,可能会出现重排序从 a-b-c 排序为 a-c-b。在多线程的情况下会出现以下问题。

当线程 A 在执行第 5 行代码时,B 线程进来执行到第 2 行代码。假设此时 A 执行的过程中发生了指令重排序,即先执行了 a 和 c,没有执行 b。那么由于 A 线程执行了 c 导致 instance 指向了一段地址,所以 B 线程判断 instance 不为 null,会直接跳到第 6 行并返回一个未初始化的对象。

小结

“好了,三妹,我们来总结一下。”我舒了一口气说。

volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层 volatile 是采用“内存屏障”来实现的。

观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码就能发现,加入 volatile 关键字时,会多出一个 lock 前缀指令,lock 前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供 3 个功能:

  • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • 它会强制将对缓存的修改操作立即写入主存;
  • 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。

最后,我们学习了 volatile 不适用的场景,以及解决的方法,并解释了双重检查锁定实现的单例模式为何需要使用 volatile。

编辑:沉默王二,编辑前的内容主要来自于二哥的技术派团队成员楼仔,原文链接戳:volatile


GitHub 上标星 9000+ 的开源知识库《二哥的 Java 进阶之路》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:太赞了,GitHub 上标星 9000+ 的 Java 教程

微信搜 沉默王二 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 222 即可免费领取。