Java并发之volatile关键字

volatile关键字的作用

关键作用

  • 保持内存可见性:所有线程都能看到共享内存的最新状态。
  • 防止指令重排:保证代码变成机器指令时顺序不变。
  • 保证对某些类型的操作具有原子性,如,longdouble,但是不保证排他性(可以被多个线程同时(并行)操作),
    • double是64位,cpu在32位的情况下,double会被拆成两个32位进行操作和存储。在并发的对double进行操作时,可能会出现double的两个32位被不同线程操作,导致数据不一致,加上volatile关键字即可保证对double操作的原子性

即volatile保证了并发编程的三大特性

  • 可见性: 当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。
  • 有序性: 即程序执行的顺序按照代码的先后顺序执行。
  • 原子性(部分):即一个或者多个操作作为一个整体,要么全部执行,要么都不执行,并且操作在执行过程中不会被线程调度机制打断;而且这种操作一旦开始,就一直运行到结束,中间不会有任何上下文切换(context switch)。
    • 部分原子性:volatile int a=b+1;就不能保证a=b+1的原子性,因为b可能会被多个线程操作,导致a的结果就不一致,即使b也被volatile修饰。如果非要用a=b+1请保证只能被一个线程使用,可能意义不大,但是可以保证a变量的可见性
    • 即如果要实现volatile写操作的原子性,那么在等号右侧的赋值变量中就不能出现被多线程所共享的变量,哪怕这个变量也被volatile修饰也不可以。所以最好是volatile int a=1;
    • volatile Date date=new Date();,也不能保证其原子性

volatile关键字大致原理

所需知识

计算机组成原理
java代码层面
java字节码层面
openjdk源码C++汇编层面
CPU层面
读volatile修饰的变量
写volatile修饰的变量

可见性样例

public class volatileT {
    public static volatile int found=0;
    public static void change(){
        found=1;
    }
    public static void main(String[] args) {
        Runnable wait = ()->{
            System.out.println("等基友送笔来");
            while (0==found){//found==0就死循环,等待基友线程将found=1
            }
            System.out.println("笔来了,开始写字。。");
        };
        Runnable give = ()->{
            try{
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("基友找到笔了,送过去。。。");
            change();//基友线程将found=1
        };
        new Thread(wait,"我线程").start();
        new Thread(give,"基友线程").start();
    }
}
复制代码

found被volatile修饰,导致found所有线程可见,当基友线程将found=1时,我线程会看见found=1,则while循环被打破程序结束
如果不用volatile修饰found,那么程序将无法结束,因为我线程看不见基友线程中found=1,while循环一直存在

volatile是如何保证线程是可见性的

  • 在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步,直接从内存中读取数据

image.png

缓存中数据都是以缓存行(Cache Line)为单位存储;

  • 多核CPU有多个缓存,如果有共享变量,为了保证CPU为每个核心(线程)缓存中的数据一致,遂引入了缓存一致性协议(MESI)
  • 为什么CPU有了缓存一致性协议,Java还要引入volatile
    • volatile屏蔽硬件层
    • 触发一致性协议(MESI)
    • volatile借助一致性协议(MESI),实现其关键字的功能

为了小小的解释一个可见性的实现,竟然要从应用层跨越到硬件层。程序员只是想单纯的进行多线程编程 。
为了不想了解硬件层(忽略底层硬件的实现),想要更好的进行多线程的编程,以及为了解决在多线程时,可能出现的问题,如数据一致性、正确性、安全性。
以及对多线程编程进行指导,再者让普通Java开发者和编译器、JVM工程师能达成共识,在硬件的基础上抽象出一个协议——Java内存模型(JMM),来忽略硬件,实现跨平台

JMM(Java内存模型)

JMM的意义

  • Java 内存模型描述了多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。它描述了程序中变量之间的关系以及在真实计算机系统中将它们存储到内存或寄存器中以及从内存或寄存器中检索它们的低级细节。它以一种可以使用各种硬件和各种编译器优化正确实现的方式来实现。 Java 包含多种语言关键字,包括 volatile、finalsynchronized,旨在帮助程序员向编译器描述程序的并发要求。 Java 内存模型定义了 volatile synchronized行为,更重要的是,确保正确同步的 Java 程序在所有处理器架构上正确运行。
  • 内存屏障仅与内存模型中描述的高级概念(例如“(acquire)获取”和“(release)释放”)间接相关。而内存屏障本身并不是“同步屏障”。并且内存屏障与某些垃圾收集器中使用的“写屏障”的种类无关。内存屏障指令仅直接控制 CPU 与其缓存的交互,
  • 多线程的运行结果都可以通过JMM来解释和预测
  • Java内存模型是为了Java语言的跨平台表现一致性,屏蔽硬件和操作系统实现提出的规范,例如规定了线程和主内存之间的抽象关系,既然是规范,只会规定概念,具体实现依赖不同平台的JVM虚拟机的实现。

image.png

上图就是JMM,根据其解释可见性

  • 每次读volatile修饰的共享变量都会从主内存中读取,然后在工作内存中生成一个副本

  • 深入来说:通过加入内存屏障和禁止指令重排序优化来实现的

  • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令。能让写入(工作内存)缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。

  • 对volatile变量执行读操作时,会在读操作后加入一条load屏障指令。可以让工作内存(高速缓存)中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。

  • 通俗地讲:volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存,这样任何时刻,不同的线程总能看到该变量的最新值

  • 线程写volatile变量的过程:

    • 改变线程工作内存中volatile变量副本的值。
    • 将改变后的副本的值从工作内存刷新到主内存。
  • 线程读volatile变量的过程:

    • 从主内存中读取volatile变量的最新值到线程的工作内存中。
    • 从工作内存中读取volatile变量的副本。

注: JVM内存模型和JMM Java内存模型不是一个概念

  • 后者是在多线程使用的概念

指令重排

  • 编译期指令重排
    • Java字节码层面没有重排
    • openjdk也就是中间代码(c++) 进行gcc编译时有指令重排
  • 运行期指令重排
    • cpu进行指令重排
      • 要遵循代码的语义和规则
      • as - if - serial(语义)
        • 无论顺序如何,结果一定是正确的,在单线程情况下
      • happens - before(规则)
        • 先行发生原则,有些代码执行的顺序JVM是提前内置的,如,new 和finalize()方法对象只有先 创建才能销毁,不能随意的指令重排
        • A happens before B,B happens before C,=> A happens before C

从双重检查(DCL)创建单例(double check lock)说起

双重检查

面试题:DCL的单例需不需要加volatile?答:要

public class SingleDCLTest {//双重检测单例模式
    private static volatile SingleDCLTest INSTANCE;
    int i= 13;
    public SingleDCLTest(){

    }
    public static SingleDCLTest getInstance(){
        if (INSTANCE ==null){//一重检查
            synchronized (SingleDCLTest.class){//加锁
                   if (INSTANCE ==null){//二重检查
                       INSTANCE = new SingleDCLTest();
                  }
            }
        }
        return INSTANCE;
    }

    public static void main(String[] args) {
        Runnable DCLTest = ()-> System.out.println(SingleDCLTest.getInstance().hashCode());
        IntStream.range(0,100).forEach(i -> new Thread(DCLTest).start());//100个线程试图创建单例并输出hashCode
    }
}
复制代码

new 不是原子操作

1、申请内存(零值),对象里面的成员变量是0或初始值
2、初始化的方法(构造方法)(零值 -> 赋予的值)
3、对象的引用指向(生成)。

  • 理论上(貌似用代码测不出来)
    • 步骤2和3,在cpu中执行时可能会先执行3,再执行2———指令重排,毕竟在多线程模式下先,其运行结果都一样
    • 在DCL单例模式,如果有多个线程,情况可能不对了:当一个线程指令被重排,即步骤3比步骤2先执行。 当对象的引用生成后步骤2还没开始,可能立刻被其他线程从堆中获取到,其他创建单例的线程会直接 return INSTANCE;,即返回了一个半初始化的对象,里面的成员变量还没有赋值,就会导致程序的结果不正确(空指针异常);
    • 所以需要禁止指令重排,来防止类似情况发生
  • 关键字volatile防止指令重排

指令, 发给计算机处理器(CPU)的命令就是“指令(instruction)”。

如何保证有序性的

  • 禁止指令重排

禁止指令重排通过内存屏障实现

  • happens before(规则),不是万能的,有些逻辑顺序是JVM无法预知的,为了方便程序员控制执行顺序,遂引入内存屏障
  • 解决运行期的指令重排问题
  • volatile关键字使用的是Lock指令,Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能
  • volatile关键字不是内存屏障却能完成类似内存屏障的功能,阻止屏障两边的指令重排序
  • lock前缀指令相当于一个内存屏障(也称内存栅栏)(既不是Lock中使用了内存屏障,也不是内存屏障使用了Lock指令),内存屏障主要提供3个功能:
    • 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

image.png

  • 强制将对缓存的修改操作立即写入主存,利用缓存一致性机制,并且缓存一致性机制 会阻止同时修改由两个以上CPU缓存的内存区域数据;(可见性的原因)
  • 如果是写操作,它会导致其他CPU中对应的缓存行无效。

image.png

参考文献