JUC之 “volatile“

两大特性

  1. 可见性
  2. 有序性

注意:volatile不能保证原子性

volatile的作用(内存语义)

  1. 当对volatile变量写时,JMM会把当前线程对应的本地内存中的共享变量值立即刷新到主内存中。
  2. 当对volatile变量读时,JMM会把当前线程对应的本地内存中的共享变量置为无效,直接从主内存中读取;

所以volatile写的内存语义是将其数据立即从工作内存中刷新到主内存中,读的内存语义是直接从主内存中读取;

内存屏障(重点)

  1. 内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。
  2. 内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。
  3. 内存屏障之前的所有写操作都要回写到主内存,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。
  4. 因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读,也叫写后读。
    JUC之 “volatile“_第1张图片
    volatile凭什么可以保证可见性和有序性—内存屏障

内存屏障分类

粗分

读屏障

在读指令之前插入读屏障。让工作空间或cpu缓存当中的缓存数据失效,重新回到主内存中去读取数据

写屏障

在写指令后插入写屏障,强制写缓存区的数据刷回到内存中

细分

JVM中提供了四类的屏障指令
JUC之 “volatile“_第2张图片

happens-before 之 volatile 变量规则

JUC之 “volatile“_第3张图片

当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。
当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重排到volatile写之后。
当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。

volatile的有序性

在多线程的环境中,线程的执行是并发的,然而在并发的环境中可能会导致指令的顺序发生变化,如果一个线程的写操作需要被其他线程立即可见就需要保证写操作在其他线程的读操作之前执行,类似地,如果一个线程的读操作需要读取到最新的值,就需要保证读操作在其他线程的写操作之后执行。

“保证写操作在其他线程的读操作之前执行”
解释:
普通变量:A线程将共享变量写完后,在B线程读取共享变量时,可能A线程并没有将其刷新回主内存中,这就导致了B线程读取到的还是旧数据,也就是读在写之前执行(由于B线程并没有感知到A的写操作)
volatile变量:A线程将其共享变量写完后,会立即将其共享变量刷新回主内存中,这时如果B线程在读取volatile变量时会将其自己的工作内存中的共享变量置为无效然后在去主内存中读取最新值,这样就保证了写操作在读操作之前执行;

内存屏障的插入策略

  1. 在每个 volatile 写操作的前⾯插⼊⼀个 StoreStore 屏障;
  2. 在每个 volatile 写操作的后⾯插⼊⼀个 StoreLoad 屏障;

JUC之 “volatile“_第4张图片
JUC之 “volatile“_第5张图片

  1. 在每个 volatile 读操作的后⾯插⼊⼀个 LoadLoad 屏障;
  2. 在每个 volatile 读操作的后⾯插⼊⼀个 LoadStore 屏障;

JUC之 “volatile“_第6张图片

JUC之 “volatile“_第7张图片

可见性

保证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可见;

public class Demo {
    static boolean flag = true;       //不加volatile,没有可见性
    //static volatile boolean flag = true;       //加了volatile,保证可见性
    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println("线程开启。。");
            while (flag) { }
            System.out.println("线程退出。。。");
        }, "t1").start();

        //暂停2秒钟后让main线程修改flag值
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {}

        flag = false;
        System.out.println("main线程修改完成");
    }
}
  • 变量不加volatile修饰,线程无法退出;
    新线程第一次读取变量时将其flag拷贝到自己的工作内存中,再对其进行读操作,主线程同理,当主线程对flag修改时,将其写到自己的工作内存,或者后续将其写回主内存中,但是由于新线程无法感知到,导致线程陷入死循环无法结束!

  • 变量加volatile修饰,线程退出;
    变量加volatile后当新线程将其读取flag数据时,如果自己的工作内存中存在flag变量,会将其工作内存中的flag置为失效,然后从主内存中读取flag;当主线程写数据时会强制将其写回主内存中,这样保证了对变量flag的修改,其他线程会立即感知到;

volatile的读写过程

Java内存模型中定义的8种工作内存与主内存之间的原子操作
read(读取)→load(加载)→use(使用)→assign(赋值)→store(存储)→write(写入)→lock(锁定)→unlock(解锁)
JUC之 “volatile“_第8张图片

read: 作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存
load: 作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载
use: 作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
assign: 作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
store: 作用于工作内存,将赋值完毕的工作变量的值写回给主内存
write: 作用于主内存,将store传输过来的变量值赋值给主内存中的变量
由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令:
lock: 作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,锁了写变量的过程。对读变量的过程并不影响;(注意:这里只锁住了将其变量写回主内存的的操作)
unlock: 作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用

为什么说没有原子性

在这里就拿i++操作来说

public class DemoTo {

    volatile int num = 0;

    public void addNum() {
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        DemoTo demo = new DemoTo();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    demo.addNum();
                }
            }).start();
        }

        TimeUnit.SECONDS.sleep(3);
        System.out.println(demo.num);
    }
}
结果:28427

volatile的复合操作没有原子性!!!
JUC之 “volatile“_第9张图片
从字节码中我们可以看出其实i++操作被分为了三步;
原子性是指一个操作不可被总断,在多线程的环境下,一旦执行不可被其他线程影响!

在来看看对volatile变量和普通变量的读写过程

普通变量

JUC之 “volatile“_第10张图片
从图中可以看出对于一个普通变量从read -> write的过程中随时可能被其他线程对当前变量发起操作!

volatile变量

JUC之 “volatile“_第11张图片
对于一个volatile修饰的变量,底层对 read -> load->use 和assign->store -> write做了特殊处理,成为了两个不可分割的原子操作;
JUC之 “volatile“_第12张图片

虽然对volatile修饰的变量做了特殊的处理但在对数据进行计算等一系列操作时还是会出现一段真空期,在这期间可能其他线程会对其变量读取等操作;

  • (i++操作代码为例)那么如果在线程A的这段真空期中线程B拿到这个数据并将其写回,这就会导致线程A写时发现数据已被修改从而造成丢失一次修改;因此对其addNum方法要加synchronized关键字来保证原子性;
  • 多线程环境下,"数据计算"和"数据赋值"操作可能多次出现,即操作非原子。若数据在加载之后,若主内存count变量发生修改之后,由于线程工作内存中的值在此前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致,对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步;

深度解析指令重排序

重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序不存在数据依赖关系,可以重排序; 存在数据依赖关系,禁止重排序但重排后的指令绝对不能改变原有的串行语义;

重排序分类和流程

image.png

  • 编译器优化的重排序: 编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序;
  • 指令级重排序: 处理器使用指令级并行技术来讲多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
  • 内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行(在多核的情况下由于核与核之间不能直接访问,就会导致它们之间无法感知到)

数据依赖性:若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性。

  • 不存在数据依赖关系,可以重排序
  • 存在数据依赖关系,禁止重排序=> 重排序发生,会导致程序运行结果不同。 编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在依赖关系的两个操作的执行,但不同处理器和不同线程之间的数据性不会被编译器和处理器考虑,其只会作用于单处理器和单线程环境;

扩展

计算机硬件系统
JUC之 “volatile“_第13张图片
多核cpu中高速缓存通过缓存一致性协议进行通信(也有一少部分比较旧的计算机通过总线进行通信,这里不做说明)寄存器等之间不能直接通信

我们所理解的
JUC之 “volatile“_第14张图片
这里的主内存在真实计算机中是缓存和主内存这两部分组成

单核cpu

在单核CPU的情况下,由于不存在多核处理器中的缓存一致性问题,volatile变量的可见性可以通过编译器和运行时环境的优化来实现。
当一个线程修改了一个volatile变量的值并写回主内存时,在单核CPU的情况下,编译器和运行时环境会确保该变量的最新值在其他线程中能够立即可见。具体的实现方式可以包括以下两个方面:

  1. 禁止重排序:编译器和运行时环境会根据volatile变量的特性,在生成指令序列时禁止对volatile变量的读写操作进行重排序。这样可以确保volatile变量的写操作在读操作之前完成,保证其他线程能够看到最新的值。
  2. 内存屏障:编译器和运行时环境会在volatile变量的读写操作之前或之后插入适当的内存屏障指令。这些内存屏障指令可以确保volatile变量的读写操作在指令序列中的顺序不会被打乱,而且对于其他线程而言,具有一定的同步效果,使得对volatile变量的修改在其他线程中能够立即可见。

需要注意的是,在单核CPU的情况下,由于不存在多核处理器中的缓存一致性问题,所以volatile变量的可见性可以通过编译器和运行时环境的优化来实现,而不需要通过总线嗅探机制来进行通知。这样可以提高程序的执行效率。

多核cpu

在多核处理器中,存在一种总线嗅探机制(bus snooping),通过这种机制,当一个核心(CPU)修改了一个volatile变量的值并写回主内存时,其他核心可以通过嗅探总线上的数据传输来检测到该变量的更新。
具体过程如下:

  1. 当一个核心修改了一个volatile变量的值并写回主内存时,这个写操作会在总线上产生一个写事务(write transaction)。
  2. 其他核心会通过总线嗅探机制监听总线上的数据传输,包括读事务和写事务。
  3. 当其他核心嗅探到有一个写事务发生时,会检查写事务中的地址信息和数据内容。
  4. 如果嗅探到的写事务中的地址与其他核心缓存中的某个数据地址匹配,并且数据内容有变化,那么其他核心会将自己的缓存中对应的数据置为无效(invalidation),表示自己的缓存中的数据已过期。
  5. 当其他核心需要读取这个volatile变量时,由于自己的缓存中的数据已被置为无效,它会从主内存中重新获取最新的值。

通过总线嗅探机制,其他核心可以检测到volatile变量的更新,并在需要的时候从主内存中获取最新的值。这样就保证了volatile变量的修改对其他线程是可见的。
需要注意的是,总线嗅探机制是一种硬件支持的机制,对于不同的处理器架构和实现细节可能有所差异。但无论具体的实现方式如何,总线嗅探机制的目的都是为了保证多核处理器中的缓存一致性和数据的可见性。

你可能感兴趣的:(JUC,JUC,java,volatile,内存屏障,高并发,指令重排序,面试)