Java volatile 关键字

volatile 关键字

volatile 是 java 提供的一种轻量级的同步机制,主要的作用就是对指令添加内存屏障(Memory barrier)确保变量的内存可见性和防止指令重排两点

  1. 确保变量的内存可见性

同一个成员变量在不同线程中处于各自独立的虚拟机栈帧中,A 线程修改了该变量,B 线程中读的值可能仍然是其内存缓存的值。通过 volatile 修饰,可以在读取该变量时强制刷出主内存中的值,以确保线程可以获取到最新的正确值

  1. 防止指令重排

不管是 cpu 还是虚拟机,都会对操作指令进行重新排序以到达最佳的执行效果,volatile 针对 JVM 指令禁止重排序,达到避免因指令重排在某些边界条件下出现的不符合预期的问题

volatile 使用场景

由上面对 volatile 的了解来看,多线程下访问相同变量的场景才会出问题,先来看个例子

csharp 复制代码
public class MySingletonTest {

    private static MySingletonTest mySingletonTest;

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

这是一个典型的双重校验的懒汉式模的单例模式,但这里隐藏着一个重大风险,就是在多线程访问下,在创建之初,某个线程会获取到一个没有执行 MySingletonTest 构造方法的对象,进而可能引发一系列问题。原因是 mySingletonTest 没有加上 volatile 关键字修饰,在调用 getInstance() 时可能发生了指令重排。

先来看下 MySingletonTest 类编译后生成的字节码,通过在终端执行命令

bash 复制代码
$javac MySingletonTest.java
$javap -c MySingletonTest

查看字节码的指令

java 复制代码
public class com.freeman.mygradletest.MySingletonTest {
  public com.freeman.mygradletest.MySingletonTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static com.freeman.mygradletest.MySingletonTest getInstance();
    Code:
       0: getstatic     #2                  // Field mySingletonTest:Lcom/freeman/mygradletest/MySingletonTest;
       3: ifnonnull     37
       6: ldc           #3                  // class com/freeman/mygradletest/MySingletonTest
       8: dup
       9: astore_0
      10: monitorenter
      11: getstatic     #2                  // Field mySingletonTest:Lcom/freeman/mygradletest/MySingletonTest;
      14: ifnonnull     27
      17: new           #3                  // class com/freeman/mygradletest/MySingletonTest
      20: dup
      21: invokespecial #4                  // Method "<init>":()V
      24: putstatic     #2                  // Field mySingletonTest:Lcom/freeman/mygradletest/MySingletonTest;
      27: aload_0
      28: monitorexit
      29: goto          37
      32: astore_1
      33: aload_0
      34: monitorexit
      35: aload_1
      36: athrow
      37: getstatic     #2                  // Field mySingletonTest:Lcom/freeman/mygradletest/MySingletonTest;
      40: areturn
    Exception table:
       from    to  target type
          11    29    32   any
          32    35    32   any
}

重点看 mySingletonTest = new MySingletonTest(); 的指令

java 复制代码
      17: new           #3                  // class com/freeman/mygradletest/MySingletonTest
      20: dup
      21: invokespecial #4                  // Method "<init>":()V
      24: putstatic     #2                  // Field mySingletonTest:Lcom/freeman/mygradletest/MySingletonTest;

可以发现,创建一个对象并赋值给引用经历了四个指令(参考对象创建指令),我们逐一分析

csharp 复制代码
1. new 分配内存,创建 MySingletonTest 实例但还未执行构造方法,并把地址引用 ref 压入操作数栈顶
2. dup 复制操作数栈顶元素,此时栈顶有两个一样的地址引用, ref ref
3. invokespecial 执行 MySingletonTest 的构造方法,用掉栈顶一个引用 ref,栈顶元素此时为 ref
4. putstatic 将栈顶地址引用 ref 赋值给静态变量 mySingletonTest

由于指令3和指令4之间没有严格依赖关系,虚拟机指令重排可能先执行指令4再执行指令3,此时 mySingletonTest 被赋值了引用,指向了堆内存中 MySingletonTest 对象,但是 MySingletonTest 却还没有执行构造方法

这样就在多线程场景中存在线程A先执行到 mySingletonTest = new MySingletonTest();,接着线程B调用 getInstance() 判断 mySingletonTest == nullfalse 后获取到了 mySingletonTest,但实际上 mySingletonTest 并没有被调用构造方法进行初始化,在后续的业务中调用 MySingletonTest 的其他成员方法就有可能出现报错等预期外的结果

解决方法就是对 mySingletonTest 变量加入 volatile 关键字进行修饰,禁止对其创建时进行指令重排,这样其他线程在等待锁释放时就可以获取到一个完全合法的对象地址

arduino 复制代码
    private static volatile MySingletonTest mySingletonTest;

上面介绍了 volatile 在禁止指令重排上的使用场景,接下来再看看确保变量的内存可见性的场景,在MySingletonTest上添加一个两个线程访问一个变量的场景

vbnet 复制代码
    private boolean stop = false;
    public void test1() {
        new Thread(() -> {
            LogUtil.info("MySingletonTest", "Thread1 start");
            while (!stop) { // 等待 Thread2 刷新 stop 为 true 后停止线程
            }
            LogUtil.info("MySingletonTest", "Thread1 finish");
        }, "Thread1").start();

        new Thread(() -> {
            LogUtil.info("MySingletonTest", "Thread2 start");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            stop = true; // Thread2 sleep 3秒后,刷新 stop 值为 true,期望 Thread1 可以停止
            LogUtil.info("MySingletonTest", "Thread2 finish and stop Thread 1");
        }, "Thread2").start();
    }

正常情况下,期望结果打印如下

yaml 复制代码
2025-03-10 00:13:56.999    MySingletonTest: Thread1 start
2025-03-10 00:13:56.999    MySingletonTest: Thread2 start
2025-03-10 00:13:59.999    MySingletonTest: Thread2 finish and stop Thread 1
2025-03-10 00:14:00.000    MySingletonTest: Thread1 finish

但在某些情况下,可能会出现 Thread1 过好久才停止甚至永远不停止的情况,原因就是线程运行时使用的寄存器上的线程缓存,stopThread1 一直为 false,没有刷新到主内存中 stop 已经变为 true

解决方法就是对 stop 变量加入 volatile 关键字进行修饰。原因是 volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值

arduino 复制代码
    private volatile boolean stop = false;

volatile 不适合的场景

volatile 只能保证内存可见性,确保每个线程读到的值都是最新,但却无法保证原子性。以一个 int 类型执行 ++ 为例

csharp 复制代码
    private volatile int count = 0;
    public void test2() {
        new Thread(() -> {
            LogUtil.info("MySingletonTest", "Thread3 start");
            for (int i = 0; i < 100000; i++) {
                count++;
            }
            LogUtil.info("MySingletonTest", "Thread3 finish");
        }, "Thread3").start();

        new Thread(() -> {
            LogUtil.info("MySingletonTest", "Thread4 start");
            for (int i = 0; i < 100000; i++) {
                count++;
            }
            LogUtil.info("MySingletonTest", "Thread4 finish");
        }, "Thread4").start();

        new Thread(() -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            LogUtil.info("MySingletonTest", "count = " + count);
        }, "Thread5").start();
    }

两个线程分别对count执行100000的自增,期望结果是200000,事实上打印的结果是

ini 复制代码
2025-03-10 00:44:45.040    MySingletonTest: count = 197136

这是因为自增操作不是原子性的,从字节码指令上看

yaml 复制代码
         2: getfield      #3                  // Field count:I
         5: iconst_1
         6: iadd
         7: putfield      #3                  // Field count:I

一次自增有三个步骤,1.取出 count 值; 2.自增; 3.赋值给count。这就导致了在多线程访问时,拿到的值是自增前的值,然后又被覆盖回去。

上面例子确保原子性可以用 AtomicInteger 或者加锁

和 synchronized 的区别

synchronized 同一个锁可以保证内存可见性和原子性;volatile 只能保证内存可见性

参考文档

  1. blog.csdn.net/binbinxyz/a...
  2. blog.csdn.net/qq_43417581...
  3. huaweicloud.csdn.net/6707b628e2c...
相关推荐
Kevinyu_8 分钟前
Maven
java·maven
nickxhuang12 分钟前
【基础知识】回头看Maven基础
java·maven
日月星辰Ace1 小时前
jwk-set-uri
java·后端
xiao--xin1 小时前
LeetCode100之二叉搜索树中第K小的元素(230)--Java
java·算法·leetcode·二叉树·树的统一迭代法
钢板兽1 小时前
Java后端高频面经——Spring、SpringBoot、MyBatis
java·开发语言·spring boot·spring·面试·mybatis
钢板兽1 小时前
Java后端高频面经——JVM、Linux、Git、Docker
java·linux·jvm·git·后端·docker·面试
awonw2 小时前
[java][基础] 悲观锁 vs 乐观锁
java·开发语言
嘵奇2 小时前
10个实用IntelliJ IDEA插件
java·ide·intellij-idea
Trouvaille ~2 小时前
【Java篇】数据类型与变量:窥见程序的天地万象
java·开发语言·青少年编程·面向对象·数据类型·基础知识·入门必看
A仔不会笑2 小时前
MySQL面试篇——性能优化
java·数据库·mysql·面试·性能优化