volatile 关键字详解


Java并发编程是Java开发中不可或缺的一部分,而正确地理解和使用 volatile 关键字是实现高效并发编程的关键之一。

什么是volatile

在Java语言中,volatile 是一个修饰符,它可以用来修饰变量。使用 volatile 关键字的主要目的是使变量在多个线程中可见,这意味着一个线程写入的新值能够被其他线程立即得知。

volatile的保证(可见性和有序性)

如何保证变量的可见性?

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile 关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile 修饰,这就指示编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取

tip:volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

如何禁止指令重排序?

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
在 Java 中,Unsafe 类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:

public native void loadFence();
public native void storeFence();
public native void fullFence();

理论上来说,你通过这个三个方法也可以实现和volatile禁止重排序一样的效果,只是会麻烦一些。

下面我们通过一个简单的例子来说明 volatile 是如何实现有序性的:

class VolatileExample {
    private volatile boolean ready = false;
    private int number;

    public void writer() {
        number = 42;    // 步骤1
        ready = true;   // 步骤2
    }

    public void reader() {
        if (ready) {    // 步骤3
            System.out.println(number); // 步骤4
        }
    }
}

在这个例子中,假设有两个线程:线程A执行 writer() 方法,线程B执行 reader() 方法。
在 writer() 方法中,有两个操作,步骤1是 number 变量被赋值为42,步骤2是 ready 变量被赋值为 true。 ready 是被声明为 volatile 的。
在 reader() 方法中,步骤3是判断 ready 是否为 true,如果是则在步骤4中输出 number 的值。
由于 ready 是 volatile 变量,当线程A更新 ready 变量的值时,JVM将禁止步骤1和步骤2发生重排序。这意味着在执行步骤2(将 ready 设置为 true)之前,步骤1(将 number 设置为42)必须已经发生。同样地,当线程B读取到 ready 为 true 时,可以确保 number 已经被初始化为42。
如果 ready 不是 volatile 类型,则JVM允许这两个操作重排序,这可能会导致线程B在 ready 为 true 时读取到 number 变量的值不是42,破坏了程序的语义。
因此,在 volatile 关键字的帮助下,我们可以确保在一系列的操作中,写入到 volatile 变量后的操作不会被重排序到写入之前,这保证了在某个线程写入 volatile 变量后,其他的线程能读取到正确的值,实现了操作的有序性。

volatile 可以保证原子性么?

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。
想保证原子性,利用 synchronized、Lock或者AtomicInteger原子类都可以。

volatile的应用场景

状态标志

volatile 经常被用作线程之间的状态标志。例如,一个线程中的标志变量可以用来控制另一个线程什么时候开始或结束执行。

public class SharedObject {
    private volatile boolean ready = false;

    public void setReady(boolean status) {
        ready = status;
    }

    public boolean isReady() {
        return ready;
    }
}

在这个简单的例子中,如果我们没有将 ready 变量声明为 volatile,那么在一个线程中改变了变量的值,其他线程可能不会立即看到改变后的值。

在Java的并发包java.util.concurrent(JUC)中,volatile 关键字用作状态标志的一个典型应用场景可以在很多地方找到,其中最显著的是在实现锁和其他同步类的内部机制时。下面是一些具体的例子:

  1. ConcurrentHashMap
    在 ConcurrentHashMap 的实现中,用到了 volatile 变量来控制表的结构变化。这允许在不完全锁定整个表的情况下,安全地进行部分更新。volatile 变量的使用确保了对这些关键变量修改的可见性,允许线程安全地检查数据结构的状态并作出决策。
  2. ThreadPoolExecutor
    ThreadPoolExecutor 是Java中用于管理线程池的一个类。它使用了 volatile 变量来维护线程池的当前状态和工作线程的数量。这些 volatile 变量确保了对线程池状态的更改在多线程环境中立即对所有线程可见,从而使得线程池的管理更加高效和安全。
  3. AtomicInteger 和 AtomicBoolean等原子类
    虽然 AtomicInteger 和 AtomicBoolean 等原子类的实现细节对于大多数开发者来说是透明的,但它们利用了 volatile 变量来确保操作的可见性。在这些原子类中,volatile 关键字帮助实现了无锁的线程安全编程模型,使得对数值的更新操作能够被其他线程即时看到。
  4. FutureTask
    在 FutureTask 的实现中,volatile 被用来控制任务的状态。FutureTask 是一个实现 Future 的类,它代表异步计算的结果。使用 volatile 变量来跟踪计算的状态(例如,是否已完成或已取消)允许系统高效地进行状态检查,无需借助于重量级的同步机制。
  5. ReentrantLock 的内部实现 AbstractQueuedSynchronizer (AQS)
    虽然 ReentrantLock 更多地依赖于其内部的 AbstractQueuedSynchronizer (AQS) 来处理同步,而 AQS 在其设计中广泛使用了 volatile 关键字来确保其状态的可见性和有序性。AQS 使用了一个 volatile 的状态变量(state)来控制同步状态,该变量的变化对所有线程即时可见,这是实现锁定机制的关键。

通过上述例子可以看出,volatile 关键字在Java并发包的实现中发挥了重要作用,特别是在需要快速、线程安全地处理状态变更时。理解volatile在这些上下文中的应用是深入掌握Java并发编程的一部分。

单例模式

在实现单例模式时,volatile 可以确保对象的正确构建。双重检查锁定模式(Double-Checked Locking)中就需要用到 volatile,否则可能出现某些线程得到的是一个未完全构造的对象。

public class Singleton {
    private volatile static Singleton uniqueInstance;

    // 私有构造函数,防止外部通过new创建实例
    private Singleton() {
    }

    public  static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

在这个例子中,volatile 关键字确保 instance 在被初始化后,构造方法中设置的所有变量值都对其他线程可见。
uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
1. 为 uniqueInstance 分配内存空间
2. 初始化 uniqueInstance
3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

volatile的局限性

虽然 volatile 对可见性和有序性有保证,但它并不能保证操作的原子性。如果你需要执行复合操作,例如自增,仅仅依靠 volatile 是不够的。在这样的情况下,你需要使用额外的同步机制,例如 synchronized 或 java.util.concurrent 包下的原子类。

总结

理解和适当地使用 volatile 是编写线程安全的并发程序不可或缺的一步。它为变量访问提供一种轻量级的同步机制,保证变量的可见性和有序性。然而,它并不是万能的,了解其局限性并结合其他同步技术使用是关键。希望本文能够帮助你更好地理解 volatile,从而写出更稳健的并发程序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值