Java 中 volatile 用法

简介: 在 Java 并发编程中,volatile 是经常用到的一个关键字,它可以用于保证不同的线程共享一个变量时每次都能获取最新的值。volatile 具有锁的部分功能并且性能比锁更好,所以也被称为轻量级锁。下面具体分析 volatile 的用法及原理,涉及到内存模型、可见性、重排序以及伪共享等方面。

简介

在 Java 并发编程中,volatile 是经常用到的一个关键字,它可以用于保证不同的线程共享一个变量时每次都能获取最新的值。volatile 具有锁的部分功能并且性能比锁更好,所以也被称为轻量级锁。下面具体分析 volatile 的用法及原理,涉及到内存模型、可见性、重排序以及伪共享等方面。

常见说法

volatile 关键字和const对应,一样是一种类型修饰符,用它修饰的变量表示可以被某些编译器未知的因素更改,比如操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。

举例说明及作用

1、 举例说明作用

int i=10;
int j = i;//(1)语句
int k = i;//(2)语句
复制代码

这时候编译器对代码进行优化,因为在(1)、(2)两条语句中,i 没有被用作左值。这时候编译器认为i 的值没有发生改变,所以在(1)语句时从内存中取出 i 的值赋给 j 之后,这个值并没有被丢掉,而是在(2)语句时继续用这个值给 k 赋值。编译器不会生成出汇编代码重新从内存里取 i 的值,这样提高了效率。但要注意:(1)、(2)语句之间 i 没有被用作左值才行。

volatile int i=10;
int j = i;//(3)语句
int k = i;//(4)语句
复制代码

volatile 关键字告诉编译器 i 是随时可能发生变化的,每次使用它的时候必须从内存中取出 i 的值,因而编译器生成的汇编代码会重新从 i 的地址处读取数据放在 k 中。

所以说使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。精确地说就是,优化器在用到这个变量时必须每次都小心地重新读取这个变量的值,而不是使用保存在寄存器里的备份。

内存模型

在深入理解 volatile 之前,先了解一些计算机的内存模型。当 CPU 执行运算的时候,需要从内存中取数据,由于 CPU 的运算速度远远快于内存的读取速度,所以 CPU 需要等数据,这个过程就浪费了 CPU 的时间。为了提高效率, 在 CPU 和内存之间会有缓存(一般有三级缓存),缓存的读写速度高于内存,容量也会比内存小得多。当 CPU 读数据的时候会先从缓存中读,如果缓存未命中则会去内存读,并把数据放到缓存中,写数据的时候也会先写缓存,在适当的时候再将缓存中的数据刷新到内存中。

缓存的使用提高了 CPU 的运行效率,但是对于多核处理器会有一些问题。如果某个内存地址的数据同时被两个 CPU 缓存,其中一个 CPU 修改了这个地址的值,无论这个值是写入到了缓存中还是被刷新到了内存中,只要另一个 CPU 依然使用其缓存中的值,那还是旧值。因此对于多线程来说,需要一些手段来保证数据的一致性。

对于 Java 来说,程序运行在 JVM 上,JVM 提供了类似的内存抽象模型,如下图所示。

网络异常,图片无法展示
|

Java内存模型

每个线程有自己的工作内存,相当于缓存,所有的线程共享主内存,相当于系统中的内存。线程之间往往会有共享变量,为了保证共享变量的可见性,需要采用 java 提供的并发技术。对于单个变量的可见性来说,volatile 是一种有效的机制。

内存可见性

先看下面的一段代码:

int a = 1;
    boolean flag = false;
    int b = 3;
    // 线程1    
    a = 2;
    flag = true;
    // 线程2     
    if (flag) {
       b = a;
    }
复制代码

上面的代码如果线程 1 执行后,线程 2 中的 flag 能立刻看到 flag 的新值吗?根据上面介绍的 Java 内存模型可以知道,答案是不一定。那么如何保证当线程 1 更新 flag 之后,线程 2 能够读取到最新的值呢?其实很简单,只需要给 flag 添加 volatile 修饰符。

那么 volatile 是如何做到的呢? 我们想一想,根据 Java 内存模型,要实现这种功能该怎么做?应该是两步:1. 当线程 1 写 volatile 变量的时候,将这个值从缓存刷新到主内存中 2. 当线程 2 读取 volatile 变量的时候,将本地的工作内存置为无效,从主内存读取新值。

其实 volatile 的实现正是以上的原理,对于一个 volatile 变量的写操作会有一行以 lock 作为前缀的汇编代码。这个指令在多核处理器下会引发两件事:

  1. 将当前处理器缓存行的数据写回到主内存
  2. 这个写回内存的操作会使在其它 CPU 里缓存了该内存地址的数据无效

lock 前缀的指令会锁住系统总线或者是缓存,目的是保证在同一时间只有一个 CPU 会修改数据,使得修改具有原子性。根据 缓存一致性 协议, CPU 通过嗅探技术保证它的内部缓存、内存和其它处理器的缓存的数据的一致性。例如,一个处理器检测其它处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同的内存地址时,强制执行缓存行填充。

禁止重排序

volatile 除了保证内存可见性,还可以禁止重排序。在了解重排序之前,先看一段代码:

class Singleton {
    private static Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
复制代码

上面的代码一看就是单例模式,并且使用了双重加锁提高效率。稍微有经验的程序员还会发现,上面的写法是不正确的,应该给 instance 添加 volatile 修饰。那么为什么需要 volatile 呢?

其实问题出在 instance = new Singleton(); 这一行,这里是创建 Singleton 对象的地方,其实这里可以看成三个步骤:

  1. memory = allocate(); //1: 分配对象的内存空间
  2. ctorInstance(memory); //2: 初始化对象
  3. instance = memory; //3: 设置 instance 指向刚分配的内存地址

上面的伪代码可能会被重排序。什么是重排序?编译器以及处理器有时候会为了执行的效率改变代码的执行顺序,这个被称为重排序。上面的三个步骤可能会被重排序为下面的步骤:

  1. memory = allocate(); //1: 分配对象的内存空间
  2. instance = memory; //2: 设置 instance 指向刚分配的内存地址
    // 注意:此时对象还没有被初始化
  3. ctorInstance(memory); //3: 初始化对象

在这种情况下,当一个线程执行到 instance = memory; 的时候,对象还没有被初始化,另一个线程也调用了 getInstance 方法,发现 instance 引用不为 null,就会认为这个对象已经创建好了,从而使用了未初始化的对象。

为什么 volatile 可以避免上面的问题?其实是因为 volatile 会禁止重排序,方法是插入了内存屏障,具体原理较复杂,这里就不深入分析了。

伪共享

CPU 缓存是以缓存行为单位进行存取的,一般一个缓存行是 64 字节,如果两个 volatile 变量被缓存在同一个缓存行,并且有多个 CPU 缓存了同一行数据,那么会出现 伪共享 的问题,造成性能问题。

例如,CPU A 以及 CPU B 都在同一个缓存行缓存了共享变量 XY,如果 CPU A 修改了 X,那么 CPU B 中的缓存行也就失效了,如果 CPU 只是需要读取 Y ,却因为 X 使得整个缓存行都要重新读取,这就不划算了,这叫做伪共享。

应用场景

只能在有限的一些情形下使用 volatile 变量替代锁。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

  • 对变量的写操作不依赖于当前值
  • 该变量没有包含在具有其他变量的不变式中

模式1:状态标志

这种情况下,volatile用来指定具有一个状态转换的标志变量。

模式2:独立观察(independent observation)

定期 “发布” 观察结果供程序内部使用。

模式3:一次性安全发布

某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(双重检查加锁问题)

模式4:“volatile bean” 模式

volatile bean 模式的基本原理是:很多框架为易变数据的持有者(例如 HttpSession)提供了容器,但是放入这些容器中的对象必须是线程安全的。在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须不包含约束。

模式5:开销较低的“读-写锁”策略

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

总结

volatile 作为一个轻量级的锁可以实现内存可见性以及禁止重排序,常用于修饰标记变量以及双重加锁的场景等。需要注意的是,volatile 用于保证一个变量的可见性,但是对于 i++ 这种复合操作是无法保证原子性的。另外,注意伪共享问题可以进一步提升性能。

相关文章
|
4月前
|
Java
Java中的equals()与==的区别与用法
【7月更文挑战第28天】
67 12
|
3月前
|
存储 SQL 缓存
揭秘Java并发核心:深度剖析Java内存模型(JMM)与Volatile关键字的魔法底层,让你的多线程应用无懈可击
【8月更文挑战第4天】Java内存模型(JMM)是Java并发的核心,定义了多线程环境中变量的访问规则,确保原子性、可见性和有序性。JMM区分了主内存与工作内存,以提高性能但可能引入可见性问题。Volatile关键字确保变量的可见性和有序性,其作用于读写操作中插入内存屏障,避免缓存一致性问题。例如,在DCL单例模式中使用Volatile确保实例化过程的可见性。Volatile依赖内存屏障和缓存一致性协议,但不保证原子性,需与其他同步机制配合使用以构建安全的并发程序。
70 0
|
22天前
|
存储 安全 Java
深入理解Java中的FutureTask:用法和原理
【10月更文挑战第28天】`FutureTask` 是 Java 中 `java.util.concurrent` 包下的一个类,实现了 `RunnableFuture` 接口,支持异步计算和结果获取。它可以作为 `Runnable` 被线程执行,同时通过 `Future` 接口获取计算结果。`FutureTask` 可以基于 `Callable` 或 `Runnable` 创建,常用于多线程环境中执行耗时任务,避免阻塞主线程。任务结果可通过 `get` 方法获取,支持阻塞和非阻塞方式。内部使用 AQS 实现同步机制,确保线程安全。
|
22天前
|
SQL 缓存 安全
[Java]volatile关键字
本文介绍了Java中volatile关键字的原理与应用,涵盖JMM规范、并发编程的三大特性(可见性、原子性、有序性),并通过示例详细解析了volatile如何实现可见性和有序性,以及如何结合synchronized、Lock和AtomicInteger确保原子性,最后讨论了volatile在单例模式中的经典应用。
27 0
|
2月前
|
Java
Java 正则表达式高级用法
Java 中的正则表达式是强大的文本处理工具,用于搜索、匹配、替换和分割字符串。`java.util.regex` 包提供了 `Pattern` 和 `Matcher` 类来高效处理正则表达式。本文介绍了高级用法,包括使用 `Pattern` 和 `Matcher` 进行匹配、断言(如正向和负向前瞻/后顾)、捕获组与命名组、替换操作、分割字符串、修饰符(如忽略大小写和多行模式)及 Unicode 支持。通过这些功能,可以高效地处理复杂文本数据。
|
2月前
|
存储 Java 数据处理
Java 数组的高级用法
在 Java 中,数组不仅可以存储同类型的数据,还支持多种高级用法,如多维数组(常用于矩阵)、动态创建数组、克隆数组、使用 `java.util.Arrays` 进行排序和搜索、与集合相互转换、增强 for 循环遍历、匿名数组传递以及利用 `Arrays.equals()` 比较数组内容。这些技巧能提升代码的灵活性和可读性,适用于更复杂的数据处理场景。
|
2月前
|
缓存 Java 编译器
JAVA并发编程volatile核心原理
volatile是轻量级的并发解决方案,volatile修饰的变量,在多线程并发读写场景下,可以保证变量的可见性和有序性,具体是如何实现可见性和有序性。以及volatile缺点是什么?
|
2月前
|
安全 Java
Java switch case隐藏用法
在 Java 中,`switch` 语句是一种多分支选择结构,常用于根据变量值执行不同代码块。除基本用法外,它还有多种进阶技巧,如使用字符串(Java 7 开始支持)、多个 `case` 共享代码块、不使用 `break` 实现 “fall-through”、使用枚举类型、使用表达式(Java 12 及以上)、组合条件以及使用标签等。这些技巧使代码更加简洁、清晰且高效。
|
3月前
|
安全 Java 编译器
Java 中的 volatile 变量
【8月更文挑战第22天】
27 4
|
3月前
|
Java 数据处理
Java IO 接口(Input)究竟隐藏着怎样的神秘用法?快来一探究竟,解锁高效编程新境界!
【8月更文挑战第22天】Java的输入输出(IO)操作至关重要,它支持从多种来源读取数据,如文件、网络等。常用输入流包括`FileInputStream`,适用于按字节读取文件;结合`BufferedInputStream`可提升读取效率。此外,通过`Socket`和相关输入流,还能实现网络数据读取。合理选用这些流能有效支持程序的数据处理需求。
46 2