volatile域浅析

内存模型的相关概念

计算机中执行程序时,每条指令都是在CPU中执行,执行指令的过程必然会涉及到数据的读取和写入。而程序运行时的数据是存放在主存(物理内存)中,由于CPU的读写速度远远高于内存的速度,如果CPU直接和内存交互,会大大降低指令的执行速度,所以CPU里面就引入了高速缓存。

脑补当初学习OS时的图 CPU->内存 CPU->寄存器->内存

​ 也就是说程序运行时,会将运算所需要的数据从主存中复制一份到高速缓存,CPU进行计算的时候可以直接从高速缓存读取和写入,当运算结束时,在将高速缓存中的数据刷新到主存。

​ 但是如果那样必须要考虑,在多核CPU下数据的一致性问题怎么保证?比如i=i+1,当线程执行这条时,会先从主存中读取i的值,然后复制一份到高速缓存,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。在单线程下这段代码运行不会存在问题,但如果在多线程下多核CPU中,每个CPU都有自己的高速缓存,可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量

为了解决缓存不一致性问题,通常来说有以下2种解决方法:

  1. 通过在总线加LOCK#锁的方式

  2. 通过缓存一致性协议

    在这里插入图片描述

原子性可见性有序性

并发编程中,通常会考虑的三个问题原子性问题、可见性问题、有序性问题。

(1)原子性:程序中的单步操作或多步操作要么全部执行并且执行的过程中不能被打断,要么都不执行。

如果程序中不具备原子性会出现哪些问题?

转账操作就是一个很好的代表,如果转账的过程中被中断,钱转出去了,由于中断,收账方却没有收到。

(2)可见性:内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。

//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;

倘若线程1从主存中读取了i的值并复制到CPU高速缓存,然后对i修改为10,这时CPU高速缓存中的i值为10,在没有将高速缓存中的值刷新到主存中时,线程2读取到的值还是0,它看不到i值的变化,这就是可见性问题。

Java提供了Volatile关键字来保证可见性,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

  而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

  另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

(3)有序性:程序执行的顺序按照代码的先后顺序执行。

实际是这样吗?

int i = 0;	//[1]
int a,b;	//[2]

[2]一定会在[1]之后执行吗?不一定,在JVM中,有可能会发生指令重排序(Instruction Reorder)。如果[1]、[2]中有相互依赖,比如[2]中的数据依赖于[1]的结果,那么则不会发生指令重排序。

什么是指令重排序?

一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

​ 指令重排对于提⾼CPU处理性能⼗分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。

指令重排可以保证串⾏语义⼀致,但是没有义务保证多线程间的语义也⼀致。所以在多线程下,指令重排序可能会导致⼀些问题。

Java内存模型的抽象结构

JVM可以看做是一个有OS架构的处理机,他也有自己的内存和处理器,它的内存和之前讨论的没有什么太大的差异。

Java运行时内存的划分如下:

在这里插入图片描述

对于每⼀个线程来说,栈都是私有的,而堆是共有的。也就是说在栈中的变量(局部变量、⽅法定义参数、异常处理器参数)不会在线程之间共享,也就不会有内存可⻅性(下⽂会说到)的问题,也不受内存模型的影
响。⽽在堆中的变量是共享的,本⽂称为共享变量。所以内存可见性针对的是共享变量

1、既然堆是共享的,为什么在堆中会有内存不可⻅问题?

Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。

线程之间的共享变量存在主内存中,每个线程都有⼀个私有的本地内存,存储了该线程以读、写共享变量的副本。本地内存是Java内存模型的⼀个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。

Java线程之间的通信由Java内存模型(简称JMM)控制,从抽象的⻆度来说,JMM定义了线程和主内存之间的抽象关系。JMM的抽象示意图如图所示:

在这里插入图片描述

从图中可以看出:

  1. 所有共享变量都存在主内存中。
  2. 每个线程都保存了一份该线程使用到的共享变量的副本。
  3. 线程A与线程B之间的通信必须通过主存。

2、JMM与Java内存区域划分的区别与联系

  • 区别

    JMM是抽象的,他是⽤来描述⼀组规则,通过这个规则来控制各个变量的访问⽅式,围绕原⼦性、有序性、可⻅性等展开的。⽽Java运⾏时内存的划分是具体的,是JVM运⾏Java程序时,必要的内存划分。

  • 联系

    都存在私有数据区域和共享数据区域。⼀般来说,JMM中的主内存属于共享数据区域,他是包含了堆和⽅法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地⽅法栈、虚拟机栈。

原子性、可见性、有序性

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。那么Java内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。

volatile的内存语义

在Java中,volatile关键字有特殊的内存语义。volatile主要有以下两个功能:

  • 保证变量的内存可⻅性
  • 禁⽌volatile变量与普通变量重排序(JSR133提出,Java 5 开始才有这个“增强的volatile内存语义“)

内存可见性

所谓内存可见性,指的是当一个线程对volatile修饰的变量进行过写操作时,JMM会立即把线程对应的本地内存中的共享变量的值刷新到主内存;当一个线程对volatile修饰的变量进行读操作时,JMM会立即把该线程对应的本地内存置为无效,从内存中从新读取共享变量的值。

禁止重排序

JMM是通过内存屏障来限制处理器对指令的重排序的。

什么是内存屏障?硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。内存屏障有两个作用:

  1. 阻止屏障两侧的指令重排序
  2. 强制把写缓冲区/⾼速缓存中的脏数据等写回主内存,或者让缓存中相应的数据 失效。

通俗说,通过内存屏障,可以防止指令重排序时,不会将屏障后面的指令排到之前,也不会将屏障之前的指令排到之后。

Volatile关键字的应用场景

单例模式下的Double-Check(双重锁检查)

public class Singleton {
    public static Singleton instance;
    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) { 			//[1]
            synchronized (Singleton.class) {
                instance = new Singleton(); //[2]
            }
        }
        return instance;
    }
}

如果这里的变量没有使用volatile关键字,那么有可能就会发生错误。

[2]实例化对象的过程可以分为分配内存、初始化对象、引用赋值。

instance = new Singleton(); // [1]
// 可以分解为以下三个步骤
1 memory=allocate();// 分配内存 相当于c的malloc
2 ctorInstanc(memory) //初始化对象
3 s=memory //设置s指向刚分配的地址
// 上述三个步骤可能会被重排序为 1-3-2,也就是:
1 memory=allocate();// 分配内存 相当于c的malloc
3 s=memory //设置s指向刚分配的地址
2 ctorInstanc(memory) //初始化对象

如果一旦发生了上述的重排序,当程序执行了1和3,这时线程A执行了if判断,判定instance不为空,然后直接返回了一个未初始化的instance。

参考:Java并发编程:volatile关键字解析

posted @ 2020-09-20 21:30  Wonkey  阅读(296)  评论(0编辑  收藏  举报