Java多线程并发(6)volatile(2)

478 阅读6分钟

volatile

在之前的文章中我们介绍了 volatile两个特性:

  1. 可见性
  2. 有序性

当写入一个 volatile变量的时候,工作内存的变量值立马刷新到主内存中;

当读取一个 volatile变量的时候,会将工作内存的设置无效,立马取新的变量值。

那么我们需要了解为什么volatile能够实现这两个特性?

内存屏障

内存屏障实际上是一种JVM指令,它是CPU或者编译器对内存随机访问操作的一个同步点。

在硬件层面上,内存屏障(Memory Barrier)是一种CPU指令,用于防止不同CPU核心之间发生缓存一致性问题,从而确保内存操作的有序性。它主要有以下两种类型:

  1. Load Barrier(读屏障):用于刷新本地CPU缓存并从内存中读取最新的数据。
  2. Store Barrier(写屏障):用于将本地CPU缓存的数据强制刷新到内存中并禁止后续操作改变该数据。

在软件开发中,内存屏障也被用作内存模型中的语义基础,包括编译器优化、代码重排和缓存同步等方面。它在多线程编程中用于确保一些关键操作的顺序性和正确性,例如:

  1. 编译器屏障(Compiler Barrier):用于禁止编译器对某些代码的重新排序或删除,以确保编译后的代码顺序与原始代码保持一致。
  2. 内存屏障(Memory Barrier):用于防止多线程环境下的数据竞争和内存一致性问题,并确保多线程之间数据的同步和可见性。
    1. 写屏障,在写指令之后插入写屏障,强制刷新主内存。
    2. 读屏障,读指令之前插入读屏障,让工作内存的数据失效,重新刷新内存变量。
  3. I/O屏障(I/O Barrier):用于确保I/O操作的顺序和正确性,例如确保写操作在读操作之后执行。

通过JVM指令,volatile实现了JMM中的可见性和有序性(禁重排),但是 volatile无法保证原子性,也就是说,当一个线程即将对变量进行自增,但是另一个线程突然完成了对它的操作,那么就会强制进行变量刷新,然后再进行自增,也有可能会出现操作失效。

源码分析

在其实现屏障的底层源码中,其实有四种屏障,分别是:

inline void OrderAccess::loadload(){acquire();}

inline void OrderAccess::storestore(){release();}

inline void OrderAccess::loadstore(){acquire();}

inline void OrderAccess::storeload(){fence();}

这四种分别插入在读读、写写、读写、写读操作中,保证代码不会对其重排。

比如 loadstore中,插入在读和写中,使得读一定发生在写之前,保证读的正确。

屏障设置策略

volatile变量读写的过程中,并不是遇见了 volatile就都会加上内存屏障,而是有策略的设定。

操作一操作二:普通读写操作二:volatile读操作二:volatile写
普通读写可重排可重排可重排
volatile读不可重排不可重排不可重排
volatile写可重排不可重排不可重排

从这里看,普通读写后面无论跟什么都可以重排。

volatile读后面无论跟什么都不可以重排。

volatile写后面的普通读写,是可以重排的。

volatile变量执行过程

一个完整的volatile变量在工作内存和主内存中的过程过程都有8个步骤:

  • 读取:从主内存将volatile变量读取到工作内存。
  • 加载:将读取的变量放入过程内存中的副本变量中。
  • 使用:变量交给执行引擎使用。
  • 赋值:将执行引擎的返回值赋给工作内存的变量。
  • 保存:将工作内存的变量值刷回主内存。
  • 写入:将刷回值保存到主内存的变量中。
  • 解锁:对volatile变量解锁。
  • 加锁:对volatile变量加锁,只在写操作时候加锁。

其中第七步和第八步并不是连续的,解锁后不会立马加锁,当线程对其进行修改的时候才会加锁。

首先我们需要解释为什么需要加锁。在多线程对主内存的volatile变量进行读取的时候,有些线程可能正在进行变量写回,造成各个线程对读取的变量值不会一样。而对其进行加锁处理,保证在进行写操作的时候,只有一个线程进行写操作。

这个过程就保证了volatile变量的可见性。

volatile无原子性解析

之前我们提到过原子性:当多个线程对共享变量进行操作的时候,每个线程对它的操作互不影响。

而为什么说volatile没有原子性保证呢?

graph TD
线程1 --> 主内存
线程2 --> 主内存

假设主内存有一个volatile变量值为5;两个线程对其进行了+1操作,在线程1执行+1的操作之时,如果线程2先完成了+1操作并写回了主内存,这时候,线程1会被告知你的工作内存中的变量值失效过时,然后线程1的本次操作就被放弃,然后重新读取变量值。


public class test {
    volatile static int a = 0;
    public static void main(String[] args){
        
        new Thread(() ->{
            while(a != 100000){
                a++;
            }
            if(a == 100000){
                System.out.println(a);
            }
        }).start();
        new Thread(() -> {
            while(a != 100000){
                a++;
            }
            if(a == 100000){
                System.out.println(a);
            }
        }).start();
        /*
        new Thread(() -> {
            while(a != 100010){
                a++;
            }
            if(a == 100010){
                System.out.println(a);
            }
        }).start();
        改为这样,会更加明显。
        */
    }
}

这里就有可能不会输出100000。

volatile变量不适用于运算类的过程,适合于状态变更类的运算,如布尔类型。

禁重排的实现

对于volatile写操作,他会在变量操作的前后加上storestore、storeload屏障

volatile读操作,在后面加上loadload、loadstore屏障

小结

volatile关键字的作用:

  • 将变量设置为可见的,禁止对变量的操作代码重排

    • 可见性是由volatile修饰的变量在内存操作的过程(实际上,也有内存屏障的原因)实现
  • 禁止重排是通过内存屏障实现

    • 但是volatile不能保证原子性,恰恰也是由于volatile在处理过程中的操作(内存屏障的强制操作)所导致的

内存屏障的作用:

  • 阻止指令重排
  • 读写操作前,进行强制数据刷回和刷新