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 == null 为 false 后获取到了 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 过好久才停止甚至永远不停止的情况,原因就是线程运行时使用的寄存器上的线程缓存,stop 在 Thread1 一直为 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...
相关推荐
非优秀程序员5 小时前
第04章 邮箱大战,163 与 QQ 的暗战|《互联网产品恩仇录》
java·vue.js·架构
知已难寻6 小时前
Docker 入门:容器技术与 Docker 基础
java·运维·服务器·docker·云原生·容器
文丑颜不良啊8 小时前
LeetCode2481~2500题目解析
java·leetcode
Flynn Liang9 小时前
同一需求三套写法:用 Spring AI、LangChain4j、AgentScope Java 各实现一个带工具调用的 RAG 助手(实战对比)
java·人工智能·大模型·架构设计·spring ai·langchain4j·agentscope
聪明的笨猪猪9 小时前
实际工程问题:AI 生成 Spring Cache(@Cacheable / @CachePut / @CacheEvict)缓存注解踩坑
java·经验分享·面试·ai编程
愿有所息10 小时前
记一次 ARM64 + JDK8 上 fastjson2 的诡异数据错乱
java
秋饼10 小时前
MCP 2026-07-28 无状态协议大改版:Spring AI 2.1 多轮往返请求与网关路由落地
java·ai·技术分享·后端开发
SL_staff11 小时前
JVS-BI 实战:业务人员如何用零代码 ELT + 可视化配置构建实时经营分析闭环
java·spring·全栈
SL_staff12 小时前
企业AI落地难?JVS-AI渐进式实践:安全可控、小切口验证、标准化沉淀与规模化复制
java·开源·aigc
SL_staff13 小时前
JVS数字底座实践:构建企业级统一内容管理平台的技术路径
java·产品