Java Volatile关键字作用

本文详细解释了Java中的volatile关键字,它如何保证可见性,防止指令重排序,以及在多线程环境中的应用,特别强调了在单例模式中的双重检查锁定中的使用。同时指出volatile不适合复杂操作,应谨慎使用以提高性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

volatile是 Java 中的一个关键字,是一种同步机制,当某个变量是共享变量,且这个变量是被 volatile 修饰的,那么在修改了这个变量的值之后,再去读取该变量的值时,可以保证获取到的值是修改后的最新的值,而不是过期的值。

相比于 synchronized 和lock, volatile 是更轻量的,因为 volatile 不会发生上下文切换等开销很大的情况,不会让线程阻塞。但正是由于它的开销相对比较小,所以它的效果也就是能力相对也小一些。虽然说 volatile 是用来保证线程安全的,但是它做不到像 synchronized 那样的同步保护, volatile 仅在很有限的场景中才能发挥作用,所以下面就让我们来看一下它的适用场景。

volatile主要有两个作用:保证可见性、禁止指令重排序。

可见性

在多线程环境中,每个线程都可能有自己的工作内存,用于存储使用的变量副本。非Volatile修饰的变量可能存在于每个线程的工作内存中,而且线程间的变量值可能不同步。这样,一个线程对这个变量的修改,其他线程可能无法立即看到。volatile关键字的主要作用就是确保变量的修改对所有线程立即可见。

可见性示例

public class MyThread extends Thread {
    private volatile boolean isRunning = true;

    public void run() {
        while (isRunning) {
            // 线程A执行任务
        }
    }
    //线程B停止任务
    public void stopRunning () {
        isRunning = false;
    }
}

在上述示例中,线程A在执行一个任务,并通过判isRunning字段为false来停止。

如果示例中isRunning字段没有使用volatile修饰,那么A线程可能无法立即看到该字段的最新值,从而继续执行循环,导致无法正常停止。

isRunning字段volatile修饰后,当线程B修改完volatile修饰的共享变量后会立即写回主内存,这时JVM就会向CPU发送LOCK#前缀指令,此时当前处理器的缓存行就会被锁定,然后更新对应的主存地址的数据,使其他线程中的缓存数据失效,当其他线程需要用到该数据时再去主内存中重新读取,保证读取到的数据是修改后的值,保证了数据的可见性和一致性。

禁止重排序

编译器和CPU为了优化执行效率,可能会对代码执行顺序进行调整(只要不影响单线程下的语义)。但volatile修饰的变量,在其前后的操作都不能随意重排,以确保多线程环境下的有序性。

示例:在单例模式中的“双重检查锁定”(Double-Check Locking)设计模式中,volatile发挥了关键作用

public class Singleton {
    private  static Singleton instance;
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

我们通过使用synchronized对Singleton.class进行加锁,可以保证同一时间只有一个线程可以执行到同步代码块中的内容,也就是说singleton=new Singleton()这个操作只会执行一次,这就是实现了一个单例。

但是,当我们在代码中使用上述单例对象的时候有可能发生空指针异常。这是一个比较诡异的情况。

我们假设Thread1 和 Thread2两个线程同时请求Singleton.getSingleton方法的时候.

Step1:Thread1执行到第9行,开始进行对象的初始化。

Step2:Thread2执行到第6行,判断singleton ==null,

Step3:Thread2经过判断发现singleton!=nul,所以执行第13行,返回singleton。

Step4:Thread2拿到singleton对象之后,开始执行后续的操作,比如调用singleton.call0)。

以上过程,看上去并没有什么问题,但是,其实,在Step4,Thread2在调用singleton.call(的时候,是有可能抛出空指针异常的。

之所有会有NPE抛出,是因为在Step3,Thread2拿到的singleton对象并不是一个完整的对象。

们这里来分析一下,singleton =new Singleton();这行代码到底做了什么事情,大致过程如下

a、JVM为对象分配一块内存M

b、在内存M上为对象进行初始化

c、将内存M的地址赋值给singleton变量

但是,以上过程并不是一个原子操作,并且编译器可能会进行重排序,如果以上步骤被重排成:

a、JVM为对象分配一块内存M

c、将内存的地址复制给singleton变量

b、在内存M上为对象进行初始化

这样的话,Thread1会先执行内存分配,在执行变量赋值,最后执行对象的初始化,那么,也就是说,在Thread1还没有为对象进行初始化的时候,Thread2进来判断singleton==null就可能提前得到一个false,则会返回一个不元整的siqleton对象,因为他还未完成初始化操作。

这种情况一旦发生,我们拿到了一个不完整的singleton对象,当尝试使用这个对象的时候就极有可能发生NPE异常。

那么,怎么解决这个问题呢?因为指令重排导致了这个问题,那就避免指令重排就行了。

所以,volatile就派上用场了,因为volatile可以避免指令重排。只要将代码改成以下代码,就可以解决这个问题:

private volatile static singleton singleton;

注意

可见性而非原子性: volatile主要保证了多线程间的内存可见性和禁止指令重排序,但它并不能提供原子操作。这意味着对于复合操作(如递增、递减等非原子操作)来说,即使变量声明为volatile,也不能确保其在并发环境下的线程安全性。例如,对于volatile int count;,直接执行count++操作不是线程安全的。

适合简单的状态标记: volatile更适合于作为简单布尔标志来表示状态变化,比如终止标志或者初始化完成标志。当某个线程修改了这个标志,其他线程可以立即看到这一变化。

避免过度使用: 由于volatile读写具有较高的成本,应谨慎使用,只在确实需要共享且不需要同步块提供的互斥控制时才使用它。对于大多数涉及复杂数据结构和逻辑的操作,应优先考虑synchronized、Lock或原子类(如AtomicInteger、AtomicLong等)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值