详解-Volatile-底层原理

470 阅读14分钟

Volatile的作用

  • 代码演示

  • 不使用 Volatile 关键字

  • 使用 Volatile 关键字

  • volatile 可以使得在多处理器环境下保证了共享变量的可见性。

那么到底什么是可见性呢?

在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性为了实现跨线程写入的内存可见性,必须使用到一些机制来实现。而volatile就是这样一种机制。

volatile 关键字是如何保证可见性的

我们可以使用【hsdis】这个工具,来查看前面演示的这段代码的汇编指令。然后在输出的结果中,查找下lock指令,会发现,在修改带有volatile修饰的成员变量时,会多一个lock指令。lock 是一种控制指令,在多处理器环境下,lock 汇编指令可以 基于总线锁或者缓存锁的机制来达到可见性的一个效果。

什么是可见性

软件工程中,是指对象间的可见性,含义是一个对象能够看到或者能够引用另一个对象的能力。

从硬件层面了解可见性的本质

  • 我们先从CPU、内存层面来了解计算机的底层是如何保证可见性的

一台计算机中最核心的组件是CPU、内存、以及I/O设备。

在整个计算机的发展历程中,除了CPU、内存以及I/O设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU的计算速度是非常快的,内存次之、最后是IO设备比如磁盘。

而在绝大部分的程序中,一定会存在内存访问,有些可能还会存在I/O设备的访问为了提升计算性能,CPU从单核升级到了多核甚至用到了超线程技术最大化提高 CPU 的处理性能,但是仅仅提升CPU性能还不够,如果后面两者的处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。

为了平衡三者的速度差异,最大化的利用CPU提升性能,从硬件、操作系 统、编译器等方面都做出了很多的优化

  1. CPU增加了高速缓存。
  2. 操作系统增加了进程、线程。通过CPU的时间片切换最大化的提升CPU的使用率。
  3. 编译器的指令优化,更合理的去利用好CPU的高速缓存。

每一种优化,都会带来相应的问题,而这些问题也是导致线程安全性问题的根源。为了了解前面提到的可见性问题的本质,我们有必要去了解这些优化的过程。

CPU 高速缓存

线程是CPU调度的最小单元,线程设计的目的最终仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器“计算”就能完成,处理器还需要与内存交互,比如读取运算数据、存储运算结果,这个 I/O 操作是很难消除的。而由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。

  • 通过任务管理器可以看到 CPU 的核数和高速缓存空间。

  • 高速缓存分为3部分缓存
  1. L1缓存负责存放指令和数据。
  2. L2是每个 CPU 独有的缓存。
  3. L3缓存是所有 CPU 共有的。
  • CPU 寄存器也负责存放一些指令。

通过高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题,缓存一致性。

缓存一致性

首先,有了高速缓存的存在以后,每个CPU的处理过程是,先将计算需要用到的数据缓存在CPU高速缓存中,在CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。

由于在多CPU种,每个线程可能会运行在不同的CPU内,并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。

  • CPU 提供了两种解决方案
  1. 总线锁

总线锁,简单来说就是,在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的。

  1. 缓存锁

缓存锁,它核心机制是基于缓存一致性协议来实现的。

缓存一致性协议

为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有 MSI,MESI,MOSI等。最常见的就是MESI协议。

MESI 协议

MESI表示缓存中的数据的四种状态

  1. M(Modify) 表示共享数据只缓存在当前 CPU 缓存中, 并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
  2. E(Exclusive) 表示缓存的独占状态,数据只缓存在当前 CPU缓存中,并且没有被修改
  3. S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
  4. I(Invalid) 表示缓存已经失效,缓存失效只能从主内存中拿了
  • 在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它Cache的读写操作





  • 对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
  1. CPU从缓存中读数据:缓存处于M、E、S状态都可以被读取,I状态CPU只能从主存中读取数据
  2. CPU向缓存中写数据:缓存处于M、E状态才可以被写。对于S状态的写,需要将其他CPU中缓存行置为无效才可写使用总线锁和缓存锁机制之后。

MESI 协议的问题

  • 根据 MESI 协议我们的缓存一致性问题可以说已经解决了,但是现在的问题是如何让一个 CPU 通知其他 CPU 的缓存数据的状态。

  • 最先想到的办法就是通知其他 CPU 修改缓存数据的状态,并且要等到他们的确认回执,再把该数据写入到缓存中。

  • 这样做在等待回执的过程中 CPU 会处于阻塞状态,这么做肯定不合适。
  • 换一种方式,采用异步通知的思想,所以 CPU 引入了Store Bufferes。

CPU0只需要在写入共享数据时,直接把数据写入到store bufferes中,同时发送invalidate消息,然后继续去处理他指令。当收到其他所有CPU发送了invalidate acknowledge消息时,再将 store bufferes 中的数据数据存储至 cache line 中。最后再从缓存行同步到主内存。

  • 但是这种优化存在两个问题
  1. 数据什么时候提交是不确定的,因为需要等待其他 cpu 给回复才会进行数据同步。这里其实是一个异步操作
  2. 引入了storebufferes后,处理器会先尝试从storebuffer中读取值,如果storebuffer中有数据,则直接从storebuffer中读取,否则就再从缓存行中读取。
  • 举一个例子
  • 既然 CPU 为了不阻塞,在更新主内存的值之前执行其他的命令,如果这些命令又对该值进行访问,那么拿到的还是脏值。

乱序执行问题的解决

这种问题 CPU 是处理不了的,因为 CPU 他无法知道指定是不是一定要按照顺序执行,如果默认按照顺序执行,那么 CPU 又要阻塞,这还是不合适。

CPU 层面的内存屏障

内存屏障就是将 store bufferes 中的指令强制写入到内存,从而使得其他访问同一共享内存的线程的可见性。

  1. Store Memory Barrier(写屏障) 告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对 屏障之后的读或者写是可见的
  2. Load Memory Barrier(读屏障) 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
  3. Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作

总的来说,内存屏障的作用可以通过防止CPU对内存的乱序访问来保证共享数据在多线程并行执行下的可见性。

再回到 volatile 关键字

  • volatile 关键字实际上就是使用了内存屏障。
  • 再讲JMM之前你需要明白 JAVA 的所有数据都是存储再 JVM 中,而 JVM 是一个虚拟机,他是运行在实际计算机上的一个程序,但是他有着完整的结构包括处理器、堆栈等,还具有相应的指令系统。

JMM

JMM属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节,他是JVM的一部分。

他通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。

需要注意的是,JMM并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在JMM中,也会存在缓存一致性问题和指令重排序问题。只是JMM把底层的问题抽象到 JVM 层面,再基于 CPU 层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题。

  • JMM抽象模型分为主内存、工作内存
  1. 主内存:相当于于计算机的内存;
  2. 工作内存:相当于CPU中的寄存器和缓存等等,而由于有多核的存在,这个工作内存也会有多个存在。这个概念和线程私有的内存还是不一样的,线程私有的内存,其实是栈内存。

我个人理解jvm主内存和工作内存是和实际cpu工作模式对应的(对cpu的一种抽象)。cpu是无法直接操作内存的,必须先读入寄存器再操作,所以jvm同样如此设计。jvm主内存对应内存,jvm工作内存对应寄存器(还包括缓存)。

  • 用流程图来更好地理解

Java 内存模型底层实现可以简单的认为:通过内存屏障(memory barrier)禁止重排序,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。 比如,对于 volatile,编译器将在 volatile 字段的读写操作前后各插入一些内存屏障。

  • 也就是说真正交给 CPU 执行的指令实际上是经过了添加内存屏障的指令,并且缓存都存在,但是在JVM中经过了一系列处理,对 CPU 的高速缓存的数据没有更新。

JMM 是如何解决可见性有序性问题的

简单来说,JMM提供了一些禁用缓存以及进制重排序的方法,来解决可见性和有序性问题。这些方法大家都很熟悉: volatile、synchronized、final。

JMM 解决重排序问题

为了提高程序的执行性能,编译器和处理器都会对指令做重排序,其中处理器的重排序在前面已经分析过了。所谓的重排序其实就是指执行的指令顺序。编译器的重排序指的是程序编写的指令在编译之后,指令可能会产生重排序来优化程序的执行性能。从源代码到最终执行的指令,可能会经过三种重排序。 2 和 3 属于处理器重排序。这些重排序可能会导致可见性问题。

处理器重排序,JMM会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序

  • volatile 增加 CPU 层面的指令

Happen-Before 规则

它的意思表示的是前一个操作的结果对于后续操作是可见的,所以它是一种表达多个线程之间对于内存的可见性。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在 happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程

除了使用volatile,JMM还有其他的一些加内存屏障的规则。

  1. 程序顺序规则 :一个线程中的指令顺序一定是不可变的。

  2. volatile 规则:对加了 volatile 声明的变量的每次写操作,都要到后续的读操作可见。

  3. 传递性规则:如果 1 happens-before 2; 2 happensbefore 3 那么传递性规则表示: 1 happens-before 3

  4. start规则:如果一个线程中的某一条指令是开启另一个线程,那么他执行过的变量对于开启的线程都是可见的

  5. join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A从ThreadB.join()操作成功返回。

  6. 监视器锁的规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。也就是说上一个线程对x的修改,下一个线程看得到x修改后的值。

总结

Volatile:其实就是让线程中的值对其他线程可见。我们毕竟不是搞硬件的,大体了解一下 CPU 的底层即可。