优秀的编程知识分享平台

网站首页 > 技术文章 正文

「清晰易懂」volatile关键字实现原理

nanyue 2024-09-01 20:39:30 技术文章 8 ℃

本章内容

volatile关键字

volatile关键字是Java中用来修饰变量的关键字之一,它的作用是保证变量在多线程之间的可见性,即:当一个线程修改了被volatile修饰的变量的值时,其他线程能够立即看到这个修改。

volatile关键字具备两层语义:

  • 1)通过缓存一致性协议(MESI)禁用CPU缓存,保证不同线程之间对共享变量操作的可见性。
  • 2)通过内存屏障禁止编译器和CPU指令重排序,保证不同线程之间对共享变量操作的有序性。

JDK1.5之后,对volatile关键字语义进行了增强,增加了hapens-before原则,保证不同线程之间对共享变量操作的有序性。

volatile可见性保证

在对volatile可见性进程说明之前,需要先了解Java内存模型(JMM)。

Java内存模型(JMM)

Java内存模型定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程读/写共享变量的副本。

如图所示:

图中,线程A与线程B之间通信需要经历以下两个步骤:

  • 1)线程A将本地内存A中更新过的共享变量刷新到主内存中。
  • 2)线程B从主内存中读取线程A已更新过的共享变量。

注意:本地内存又称工作内存,它是JMM中的一个抽象概念,涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

volatile可见性保证

JMM规范了主内存和线程工作内存的数据交换操作(原子性操作)。如图所示:

其中:

  • lock(锁定):作用于主内存中的变量,一个变量在同一时间只能被一个线程锁定。该操作表示该线程独占锁定的变量。
  • unlock(解锁):作用于主内存中的变量,表示这个变量的状态由处于锁定状态被释放,其他线程可以对该变量进行锁定。
  • read(读取):作用于主内存中的变量,表示把一个主内存变量的值传输到线程的工作内存中,供后续的load操作使用。
  • load(载入):作用于线程的工作内存中的变量,表示把从主内存中读取的变量的值放到工作内存的变量副本中。
  • use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时,就会执行该操作。
  • assign(赋值):作用于线程的工作内存中的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时,就会执行该操作。
  • store(存储):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给主内存,供后续的write操作使用。
  • write(写入):作用于主内存中的变量,表示把从工作内存中得到的变量的值放入主内存的变量中。

Java中的volatile关键字由C语言实现,操作volatile关键字修饰的变量时,最终会在解析的汇编指令前加上lock前缀指令来保证工作内存中读取到的数据是主内存中最新的数据。具体是通过缓存一致性协议(MESI)来实现:当多个CPU从主内存读取数据到高速缓存时,如果其中一个CPU更新了共享变量的数据,则会通过总线将该共享变量的数据回写到主内存中,其他CPU会通过总线嗅探机制感知到缓存中共享变量数据的变更,并将工作内存中的共享变量数据失效,重新从主内存中读取共享变量的数据到缓存中。

IA32架构软件开发者手册对lock前缀指令的解释:

  • 1)会将当前处理器缓存行的数据立即回写到系统内存中。
  • 2)这个写回内存的操作会导致其他CPU中缓存了该内存地址的数据失效(MESI协议)。

代码示例:

/**
 * @author 南秋同学
 * volatile可见性保证示例
 */
@Slf4j
public class VolatileExample {
    /**
     * 定义volatile修饰的变量
     */
    public volatile static boolean flag = false;

    public static void main(String[] args) {
        // 线程A
        new Thread(() -> {
            while (!flag) {
                log.info("线程A执行业务处理");
            }
            log.info("线程A业务处理完成");
        }).start();

        // 线程B
        new Thread(() -> {
            try {
                // 睡眠1s
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                log.warn("线程B执行异常");
            }
            flag = true;
            log.info("线程B设置flag变量值为true");
        }).start();
    }
}
执行结果:
06:53:49.923 [Thread-0] INFO com.nq.order.VolatileExample - 线程A执行业务处理
06:53:49.923 [Thread-0] INFO com.nq.order.VolatileExample - 线程A执行业务处理
06:53:49.923 [Thread-0] INFO com.nq.order.VolatileExample - 线程A执行业务处理
06:53:49.923 [Thread-0] INFO com.nq.order.VolatileExample - 线程A执行业务处理
06:53:49.923 [Thread-0] INFO com.nq.order.VolatileExample - 线程A业务处理完成
06:53:49.923 [Thread-1] INFO com.nq.order.VolatileExample - 线程B设置flag变量值为true

以上代码中:

  • 线程A不断检查flag的值,直到flag的值为true时才停止循环,并输出"线程A业务处理完成"。
  • 线程B在休眠1秒后将flag的值设置为true。
  • 由于flag被volatile修饰,因此,当线程B将flag的值设置为true后,线程A能够立即看到flag修改后的值,从而停止循环并输出相应的结果。

volatile不能保证原子性

假如两个线程同时读取(read)主内存中同一共享变量count的值,并将读取到的值加载(load)到线程的工作内存中,两个线程的CPU又同时使用(use)count值进行计算,并将计算结果赋值(assign)返回到工作内存中,其中一个线程通过总线先将更新后的count值存储(store)到主内存中,另一个线程会通过总线嗅探机制感知到缓存中count值的变更,并将工作内存中的count值失效。

代码示例:

/**
 * @author 南秋同学
 * volatile不能保证原子性示例
 */
@Slf4j
public class VolatileExample {
    /**
     * 定义volatile修饰的变量
     */
    private volatile int count;

    public void add(){
        count++;
    }

    @SneakyThrows
    public static void main(String[] args) {
        VolatileExample volatileExample = new VolatileExample();
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                try {
                    // 睡眠10ms
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    log.warn("执行异常");
                }
                volatileExample.add();
            }).start();
        }
        Thread.sleep(1000);
        log.info("count值:{}", volatileExample.count);
    }
}
执行结果:
07:22:40.101 [main] INFO com.nq.order.VolatileExample - count值:990

其中,count++是一个复合操作,包括三个步骤:

  • 读取主内存中count的值。
  • 将count的值+1。
  • 将变更后的count值写回主内存。

volatile无法保证以上三个操作的原子性。

处理流程:

  • 1)线程A读取count变量值(如:100),进入阻塞(10ms)。
  • 2)线程B读取count变量值(此时count变量的值仍然为100),进入阻塞(10ms)。
  • 3)线程A阻塞结束,将count+1后赋值给count变量(11),此时线程A还未将count变量的值回写到主内存中。
  • 4)线程B阻塞结束,将count+1后赋值给count变量(11),此时线程B还未将count变量的值回写到主内存中。
  • 5)线程A将count变量的结果回写到主内存中,线程B通过总线嗅探机制感知到缓存中count变量值的变更,并将工作内存中的count变量值失效,重新从主内存中读取线程A回写的count变量值(11)到缓存中。
  • 6)线程B将count变量的值(此时count值为线程A回写的值)回写到主内存中。

最终,经过线程A和线程B分别对count变量的值进行+1操作后,主内存中count变量的值只增加了1,从而说明volatile不能保证原子性。

volatile有序性保证

volatile有序性通过内存屏障禁止编译器和CPU指令重排序以及happens-before原则来保证。

指令重排序

在程序执行过程中,为了提高性能,编译器和处理器通常会对指令进行重排序。

重排序主要分为三类:

  • 编译器优化重排序:编译器在不改变单线程语义的情况下,会对执行语句进行重新排序。
  • 指令集重排序:现代操作系统中的处理器都是并行的,如果执行语句之间不存在数据依赖性,处理器可以改变语句的执行顺序。
  • 内存重排序:由于处理器会使用读/写缓冲区,出于性能的原因,内存会对读/写进行重排序。

内存屏障

内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,volatile修饰的变量在读写操作前后会插入读写屏障来禁止指令重排序,保证读写操作的有序性。

内存屏障主要功能:

  • 1)禁止屏障两边的指令重排序。
  • 2)刷新处理器缓存(保证内存可见性)。

内存屏障类型

内存屏障类型:

禁止指令重排序

通过内存屏障禁止指令重排序,如图所示:

写屏障处理步骤:

  • 1)执行普通读写操作。
  • 2)在每个volatile写操作前面插入一个StoreStore屏障,禁止StoreStore屏障前面的普通写和StoreStore屏障后面的volatile写重排序。
  • 3)在每个volatile写操作后面插入一个StoreLoad屏障,禁止StoreLoad屏障前面的volatile写与StoreLoad屏障后面可能的volatile读/写重排序。

读屏障处理步骤:

  • 1)在每个volatile读操作后面插入一个LoadLoad屏障,禁止LoadLoad屏障后面所有的普通读和volatile读重排序。
  • 2)在每个volatile读操作后面插入一个LoadStore屏障,禁止LoadStore屏障后面所有的普通写和volatile读重排序。
  • 3)执行普通读写操作。

注意:内存屏障另一个作用是强制更新一次CPU缓存。例如:写屏障会将这个屏障前写入的数据刷新到CPU缓存,这样任何试图读取该数据的线程将得到最新值。

happens-before原则

JDK1.5之后,对volatile关键字增加了hapens-before原则。happens-Before指的是前面一个操作的结果对后续操作可见。happens-Before约束了编译器的优化行为,允许编译器优化,但是要求编译器优化后一定遵守happens-Before原则。

happens-before的原则:

  • 程序次序规则:在一个线程内,按照程序代码顺序,写在前面的操作happens-before于写在后面的操作。注意:该规则指的是控制流顺序而不是程序代码顺序(因为要考虑分支、循环等结构)。
  • volatile变量规则:对一个volatile变量的写操作happens-before于后面对该变量的读操作。注意:该规则顺序指的是时间上的先后顺序。
  • 传递性规则:如果A happens-before B且B happens-before C,则A happens-before C。
  • 管程锁定规则:一个解锁操作happens-before于后面对同一个锁的加锁操作。注意:该规则顺序指的是时间上的先后顺序。
  • 线程启动规则:线程对象的start()方法happens-before于该线程的每一个动作。
  • 线程终止规则:线程中的所有操作happens-before于对此线程的终止检测,可以通过Thread.join()和Thread.isAlive()等方法的返回值检测线程是否已终止。
  • 线程中断规则:对线程interrupt()方法的调用hapens-before于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否发生中断。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)hapens-before于它的finalize()方法的开始。

volatile关键字使用

volatile相对于synchronized更加轻量级,但是volatile不具备原子性,因此,volatile不是真正意义上线程安全性的一种工具。

volatile需谨慎使用,一般情况下,只有在状态独立于程序内其他内容时才能使用volatile。

状态标识

代码示例:

/**
 * @author 南秋同学
 * 状态标识
 */
@Slf4j
public class VolatileExample {
    /**
     * 定义volatile修饰的状态标识
     */
    private volatile boolean flag;

    /**
     * 关闭
     */
    public void shutdown() { flag = true; }

    /**
     * 工作
     */
    public void work() {
        while (!flag) {
            log.info("业务处理");
        }
    }
}

其中,使用volatile修饰状态标识flag时,当执行shutdown()方法将flag设置为true时,work()方法能够立刻感知flag值的变化,并结束业务处理。

双重检查锁

使用volatile和synchronized实现双重检查锁的单例模式。

代码示例:

/**
 * @author 南秋同学
 * 双重检查锁单例模式
 */
public class Singleton {
    private volatile static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() { // ①
        if(instance == null) {  // ②
            synchronized (Singleton.class) {    // ③
                if(instance == null) {  // ④
                    instance = new Singleton(); // ⑤
                }
            }
        }
        return instance;    // ⑥
    }
}

其中,第⑤步instance = new Singleton()可分为三个步骤(伪代码):

  • a)分配内存:memory = allocate()。
  • b)初始化对象:initInstance(memory)。
  • c)instance变量指向对象内存地址:instance=memory。

在并发情况下,如果instance变量没有使用volatile关键字修饰,则第⑤步会出现问题:代码在编译运行时,可能发生指令重排序(即:执行顺序由a->b->c重排序为a->c->b)。例如:当线程A执行到第⑤步时,线程B执行到第②步,假设此时线程A执行过程中发生了指令重排序,执行了步骤a和步骤c,但没执行步骤b。由于线程A执行步骤c后,instance变量会指向一段内存地址,因此,线程B判断instance是否为null时,instance不为null,会跳过第⑥步直接返回一个未初始化的对象。

【阅读推荐】

更多精彩内容,如:

  • Redis系列
  • 数据结构与算法系列
  • Nacos系列
  • MySQL系列
  • JVM系列
  • Kafka系列
  • 并发编程系列

请移步【南秋同学】个人主页进行查阅。内容持续更新中......

【作者简介】

一枚热爱技术和生活的老贝比,专注于Java领域,关注【南秋同学】带你一起学习成长~

Tags:

最近发表
标签列表