原子性、可见性和有序性是并发编程所面临的三大问题。 Java通过CAS操作已解决了并发编程中的原子性问题,本章为大家介绍Java如何解决剩余的另外两个问题——可见性和有序性。
CPU物理缓存结构
由于CPU的运算速度比主存(物理内存)的存取速度快很多,为了提高处理速度,现代CPU不直接和主存进行通信,而是在CPU和主存之间设计了多层的高速Cache(高速缓存),越靠近CPU的缓存越快,容量也越小。
每一级高速缓存中所储存的数据都是下一级高速缓存的一部分,越靠近CPU的高速缓存越快,容量也越小。L1高速缓存和L2高速缓存都只能被一个CPU单核使用, L3高速缓存可以被同一个插槽上的CPU内核共享,主存由全部插槽上的所有CPU核共享。 CPU读取数据时,先从L1高速缓存中读取,如果没有命中,再到L2、 L3高速缓存中读取,假如这些高速缓存都没有命中,它就会到主存中读取所需要的数据。
解决缓存一致性
缓存一致性:当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一样。MESI(Modified Exclusive Shared Or Invalid)是一种广泛使用的支持写回策略的缓存一致性协议,CPU 中每个缓存行(caceh line)使用 4 种状态进行标记(使用额外的两位 bit 表示):Modified 被修改的;Exclusive 独享的;Shared 共享的; Invalid 无效的;
并发编程的三大问题
原子性问题
原子操作就是“不可中断的一个或一系列操作”,是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何线程的切换。 前面讲到的i++操作不是原子性操作,因为操作系统底层有四个原子操作组成:
- 从主存中复制i的值并复制到CPU的工作内存中。
- CPU读取工作内存中的值,然后执行i++操作,完成后刷新到工作内存。
- 将工作内存中的值更新到主存。
可见性问题
一个线程对共享变量的修改,另一个线程能够立刻可见,我们称为该共享变量具备内存可见性。 JMM(Java Memory Model, Java内存模型)规定,所有的变量都存放在公共主内存中,当线程使用变量时会把主存中的变量复制到自己的工作空间(或者叫作私有内存)中,线程对变量的读写操作,是自己工作内存中的变量副本。可见性问题主要的两点是:变量共享、多线程!存在不可见问题的根本原因是由于缓存的存在,线程持有的是共享变量的副本,无法感知其他线程对于共享变量的更改,导致读取的值不是最新的。但是 final 修饰的变量是不可变的,就算有缓存,也不会存在不可见的问题。
main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
static boolean run = true; //添加volatile
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
}
原因:
-
初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存
-
因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
-
1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
**内存屏蔽机制保证可见性:**对于volatile读来说,本身的指令是load,如果需要保证可见性,只要后面的普通读、普通写,不重排到前面就可以了。 对于volatile写来说,本身的指令是store,保证其可见性,需要其前面的普通写、后面的普通读,不可以和自己重排。
有序性问题
所谓的程序的有序性,是指程序执行的顺序按照代码的先后顺序执行。如果程序执行的顺序与代码的先后顺序不同,并导致了错误的结果,即发生了有序性问题。CPU为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。只要两个指令之间不存在“数据依赖”,就可以对这两个指令乱序。 指令重排需要保证As-if-Serrial规则,As-if-Serial规则的具体内容为:不管如何重排序,都必须保证代码在单线程下运行正确。
**为什么指令重排?**因为有些指令很费时间,在保证单线程下运行正确的前提下,CPU会先去指向后面的指令再回过头来指向费时间的指令,前提不存在指令依赖。另外使用流水线机制需要指令重排。
volatile 的原理
面介绍过,为了解决CPU访问主存时读写性能的短板,在CPU中增加了高速缓存,但这带来了可见性问题。而Java的volatile关键字可以保证共享变量的主存可见性,也就是将共享变量的改动值立即刷新回主存。在正常情况下,系统操作并不会校验共享变量的缓存一致性,只有当共享变量被volatile关键字修饰了,该变量所在的缓存行才被要求进行缓存一致性的校验。 特性:
- 保证可见性
- 不保证原子性
- 保证有序性(避免指令重排)
性能:volatile 修饰的变量进行读操作与普通变量几乎没什么差别,但是写操作相对慢一些,因为需要在本地代码中插入很多内存屏障来保证指令不会发生乱序执行,但是开销比锁要小。
synchronized 无法禁止指令重排和处理器优化,为什么可以保证有序性可见性
- 加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞,所以同一时间只有一个线程执行,相当于单线程,由于数据依赖性的存在,单线程的指令重排是没有问题的
- 线程加锁前,将清空工作内存中共享变量的值,使用共享变量时需要从主内存中重新读取最新的值;线程解锁前,必须把共享变量的最新值刷新到主内存中(JMM 内存交互章节有讲)
有序性与内存屏障
内存屏障是一系列的CPU指令,它的作用主要是保证特定操作的执行顺序,保障并发执行的有序性。在编译器和CPU都进行指令的重排优化时,可以通过在指令间插入一个内存屏障指令,告诉编译器和CPU,禁止在内存屏障指令的前(或后)执行指令重排序。
- 写屏障:在指令后插入写屏障指令,将寄存器、高速缓存中的最新数据更新到主存,让其他线程可见。 并且不能对之前的指令重排。 将写屏障之前的共享值同步到主存中,且之前的指令不能重排。
- 读屏障:在指令前插入读屏障,让高速缓存中的数据全部失效,强制重新从主存加载最新的数据。并且不能对后续的指令重排。将读屏障之后的共享值从主存中加载最新的数据,且之后的指令不能重排
- 全屏蔽:是一种全能型的屏障,具备读屏障和写屏障的能力。
**内存屏蔽机制保证可见性与有序性:**对于volatile读来说,本身的指令是load,如果需要保证可见性,只要后面的普通读、普通写,不重排到前面就可以了。 对于volatile写来说,本身的指令是store,保证其可见性,需要其前面的普通写、后面的普通读,不可以和自己重排。
JMM(Java Memory Model,即Java内存模型)
JMM提供了合理的禁用缓存以及禁止重排序的方法,所以其核心的价值在于解决可见性和有序性。 本身是一种抽象的概念,实际上并不存在,物理都是存在内存条中。
- 主存:主要存储的是Java实例对象,所有线程创建的实例对象都存放在主存中,无论该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,因此多个线程对同一个变量进行访问可能会发现线程安全问题。 主内存直接对应于物理硬件的内存
- 工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储着主存中的变量副本),每个线程只能访问自己的工作内存,即线程中的本地变量对其他线程是不可见的,即使两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量。注意,由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。工作内存对应寄存器和高速缓存
JMM 作用:
- 屏蔽各种硬件和操作系统的内存访问差异,实现让 Java 程序在各种平台下都能达到一致的内存访问效果
- 规定了线程和内存之间的一些关系
Java内存模型的规定如下:1)所有变量存储在主存中。2)每个线程都有自己的工作内存,且对变量的操作都是在工作内存中进行的。3)不同线程之间无法直接访问彼此工作内存中的变量,要想访问只能通过主存来传递。
JMM将所有的变量都存放在公共主存中,当线程使用变量时,会把公共主存中的变量复制到自己的工作内存(或者叫作私有内存)中,线程对变量的读写操作是自己的工作内存中的变量副本。因此, JMM模型也需要解决代码重排序和缓存可见性问题。 JMM提供了一套自己的方案去禁用缓存以及禁止重排序来解决这些可见性和有序性问题。 JMM提供的方案包括大家都很熟悉的volatile、synchronized、 final等。 JMM主存与工作内存之间的交互协议的8个操作如下:
JMM 如何解决有序性问题
JMM提供了自己的内存屏障指令,要求JVM编译器实现这些指令,禁止特定类型的编译器和处理器重排序。
Happens-Before 规则介绍
- 程序顺序执行规则( as-if-serial规则):在同一个线程中,有依赖关系的操作按照先后顺序,前一个操作必须先行发生于后面一个操作 。
- 对volatile(修饰的)变量的写操作必须先行发生于对volatile变量的读操作。
- 传递性规则:如果A操作先行发生于B操作,而B操作又先行发生于C操作,那么A操作先行发生于C操作。
- 监视锁规则:解锁操作先行发生于后续对这个监视锁的加锁操作 。
- join规则:如果线程A执行了B.join()操作并成功返回,那么线程B中的任意操作先行发生于线程A所执行的ThreadB.join()操作。