Java并发基础1-指令重排序与JVM内存模型
并发是Java重要的一个知识点,在编码的过程中,经常会遇见这样一种场景:单线程下的代码在多线程下出现了和预期不一样的结果,其中一个原因就是JVM对我们的代码做了指令重排序的优化。
一、指令重排序
为什么需要对指令进行重排序?其目的就是对我们的代码进行运行上的优化,我们会默认期望这些语句的实际运行顺序和写的代码顺序一致。但实际上,编译器、JVM 或者 CPU 都有可能出于优化等目的,对于实际指令执行的顺序进行调整。
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。编译器、处理器也遵循这样一个目标。注意是单线程。多线程的情况下指令重排序就会给程序员带来问题。
1.1 as-if-serial
指令重排序必须满足as-if-serial
语义:即无论怎么样重排序,都不能够改变单线程程序运行的结果。例如:
double a = 1.0; // 1 |
经过指令重排序后,可能被优化成以下:
double b = 2.0; // 2 |
1和2的执行顺序被调换,但是不会影响单线程下,执行3获得的结果。
1.2 数据依赖性
数据依赖性:如果两个操作同时访问一个变量,且这两个操作中有一个为写操作,那么这两个操作具有数据依赖性。
数据依赖性:即无论怎么样重排序,都不能够改变具有数据依赖性的两个操作的执行顺序。
double a = 1.0; // 1 |
例如上述代码,不会被重排序调换1和2的位置,因为这两条指令都对a进行了操作,且1对a进行了写操作,存在数据依赖性。
1.3 三种重排序场景
1、编译器重排序
针对程序代码语而言,编译器可以在不改变单线程程序语义的情况下,可以对代码语句顺序进行调整重新排序。
2、指令集并行的重排序
这个是针对于CPU指令级别来说的,处理器采用了指令集并行技术来讲多条指令重叠执行,如果不存在数据依赖性,处理器可以改变主句对应的机器指令执行顺序。
3、内存重排序
内存系统内不存在真正的重排序,但是内存会带来看上去和重排序一样的效果,所以这里的“重排序”打了双引号。由于内存有缓存的存在,在 JMM 里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以这也会导致程序表现出乱序的行为。从表面结果上来看好像指令的顺序被改变了,内存重排序其实是造成可见性问题的主要原因所在。
举个例子,线程 1 修改了 a 的值,但是修改后没有来得及把新结果写回主存或者线程 2 没来得及读到最新的值,所以线程 2 看不到刚才线程 1 对 a 的修改(也就是说读写操作并不是原子的,这也就是volatile关键字无法保证多线程情况下一致性的原因),此时线程 2 看到的 a 还是等于初始值。但是线程 2 却可能看到线程 1 修改 a 之后的代码执行效果,表面上看起来像是发生了重顺序。
1.4 重排序对多线程的影响
单线程的重排序很简单,因为可以通过语义分析就能知道前后代码的依赖性,但是多线程就不一样了,多线程环境里编译器和CPU指令优化根本无法识别多个线程之间存在的数据依赖性,比如说下面的程序代码如果两个方法在两个不同的线程里面调用就可能出现问题。
private static int value; |
根据上面代码,如果程序代码运行都是按顺序的,那么getValue() 中打印的value值必定是等于8的,不过如果init()方法经过了指令重排序,那么结果就不一定了。根据as-if-serial 原则,init()方法是允许进行指令重排序,因为语句1和语句2之间没有依赖关系。 进行重排序后代码执行顺序可能如下。
flag=true; //语句2 |
如果init()方法经过了指令重排序后,这个时候两个线程分别调用 init()和getValue()方法,那么就有可能出现下图的情况,导致最终打印出来的value数据等于0。
1.5 禁止重排序
在复杂的多线程环境下,编译器和处理器是根本无法通过语义分析来知道代码指令的依赖关系的,所以这个问题只交给能写代码的人才能清楚的知道,这个时候编写代码的人就需要通过一种方式显示的告诉编译器和处理器哪些地方是存在逻辑依赖的,这些地方不能进行重排序。
所以在编译器层面和CPU层面都提供了一套内存屏障来禁止重排序的指令,不过在Java中为了简化开发人员的工作,避免开发人员需要对底层的系统原理的深度理解,所以封装了一套规范,把这些复杂的指令操作与开发人员隔离开来,这就是我们常说的Java 内存模型(JMM),JMM定义了几个happens before原则来指导并发程序编写的正确性。程序员可以通过Volatile、synchronized、final几个关键字告诉编译器和处理器哪些地方是不允许进行重排序的。
二、Java内存模型JMM
Java 内存模型实际上就是规范了 JVM 如何提供按需禁用缓存和重排序优化的方法。其核心就包括volatile、synchronized 和 final 三个关键字,以及几项Happens-Before 规则。有了JMM 作为java的开发人员只需要使用几个关键字(sychronized,volatile,final),并且理解几个happens before规则,就能根据自己的需要来禁用缓存优化和指令重排序,从而避免并发问题。
2.1 Happens Before 原则
作为开发人员来说,如果不想太深入底层去了解计算机底层的原理,又想编写出正确的并发程序,那么就必须对Happens Before原则加以理解,理解这些原则才能帮助我们避免并发程序的BUG,在出现并发问题后也能马上发现问题的所在。Happens Before原则就像我们的JAVA并发程序的开发手册,这个手册中一共包含了X条的原则,下面我们来一一了解。
- as-if-serial语义保证了单线程内程序的执行结果不会被重排序改变。
- happens-before关系保证了正确同步的多线程程序的执行结果不会被改变。
- happens-before不是规范,而是一种指导思想,我们需要使用各种手段如加锁等实现happens-before,这样才能保证并发环境下结果的正确性。
2.1.1 规则一:程序顺序原则
定义:在一个线程中,按照程序代码的执行流顺序,先执行的操作happen—before后执行的操作。
说明:这个规则的意思就是 前面的写操作对于后面的读操作来说是可见的,按下面的代码来说,x=1的写入对于flag=true是可见的。
int x; |
2.1.2 规则二:volatile变量规则
定义:对一个volatile变量的写操作happen—before后面对该变量的读操作。
说明:这个规则的是说,如果一个线程先修改了volatile的变量,那么这个操作对于后续其他线程对这个volatile变量读操作是可见的。 如下代码,线程1调用write() 修改了共享变量 x,然后线程2调用了read() 读取x,这个时候线程1 操作 x=1 对于线程2是可见的。
volatile int x; //共享变量 |
2.1.3 规则三:管程中锁的规则
定义:一个锁的unlock(解锁)操作happen—before后面对该锁的lock(加锁)操作。
说明:如果线程1解锁了A对象,然后线程2对A进行了加锁操作,那么线程1对共享变量的所有写操作对于线程2是可见的。
这个逻辑对应到代码里面就如下, write()方法 synchronized 代码块执行完之后(也就是对this对象的解锁操作,synchronized代码执行完自动解锁)的结果,对于 read()方法 进入synchronized 代码块(也就是对this对象的加锁操作)是可见的,也就是 代码2 会看到 x=x+1 的结果。
int x; //共享变量 |
2.1.4 规则四:线程启动规则
定义:Thread.start()方法happen—before调用用start的线程前的每一个操作。
说明:如果A线程调用 B线程的start()方法,那么A线程 在调用B.start()之前对共享变量的所有写操作对于B线程来说都是可见的。
如下代码,当先运行的线程为线程A,A线程先对共享变量v进行赋值,然后A线程调用B线程start()方法,那么B线程是可以看到v=10的这个操作的。
Thread threadB=new Thread(()->{ |
2.1.5 规则五:线程终止规则
定义:线程中的所有操作都happen-before于对此线程的终止检测,我们可以通过Thread.join()
方法结束、Thread.isAlive()
的返回值等手段检测到线程已经终止执行。
说明:以join()为例,如果A线程调用B线程的Join()方法,那么当B线程的Join方法返回后,A线程可以看到B线程对共享变量的所有写操作。 以下面代码为例,当前线程调用了threadB的join()方法并返回后,线程设置x=1的操作对于当前线程是可见的。
int x; //共享变量 |
2.1.6 规则六:线程中断规则
定义:对线程interrupt()的调用 happen—before 发生于被中断线程的代码检测到中断时事件的发生。
说明:线程A调用了线程B的interrupt()方法,那么当线程B触发interrupt之后,线程A对所有共享变量的写操作对于线程B来说都是可见的。
int x; //共享变量 |
2.1.7 规则七:对象终结规则
定义:一个对象的初始化完成(构造函数执行结束)happen—before它的finalize()
方法的开始。
说明:调用对象finalize()
方法时,对象初始化完成的任意操作,对于调用finalize()
线程来说都是可见的。
public class Test { |
2.1.8 规则八:传递性规则
定义:如果操作A happen—before操作B,操作B happen—before操作C,那么可以得出A happen—before操作C。
传递性规则要与其他规则组合理解,以volatile规则+传递性规则为例,下面代码中
1、因为 y=1 hanpen-before x=2(顺序性原则)
2、而 x = 2 hanpen-before x = 3(volatile变量 规则);
3、 而x = 3又 hanpen-before z=4(顺序性原则)。
4、所以最终得出 y=1 happen-before z=4(传递性原则);
int x; |
2.2 Java内存模型
Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。
- 所有的变量都存储在主内存中
- 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
Java内存模型的作用是控制Java线程之间的通信,它决定了一个线程对共享变量的写入什么时候对另一个线程可见;而JVM内存模型指的是Java虚拟机运行时的内存分区。这里大家不要混淆。
Java内存模型约定线程之间共享的变量存储在主内存中,而每个线程又有自身私有的本地内存(工作内存),本地内存是Java线程直接能读/写到的区域。
两条规定:
- 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读取
- 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
如下图所示:
可以看出,多线程工作的时候,是会从主线程中copy一份副本变量到自己的工作内存,那么如何保证其他线程修改的变量能够被自己的线程读取到,这就是一个缓存可见性的问题。
2.3 缓存的可见性
可见性:一个线程对共享变量值的修改,能够及时地被其他线程看到。
共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。
要实现共享变量的可见性,必须保证两点:
- 线程修改后的共享变量值能够及时从工作内存中刷新到主内存中
- 其他线程能够及时把共享变量的最新值从主内存更新到自己的工作内存中
产生并发问题的一个核心的原因就是多个CPU都有各自的缓存区域,而且它们各自之间无法感知的,当一个CPU对共享数据进行修改了之后,其它CPU并不知道内容已经被修改了,还是从自己缓存里读取到旧的数据,那么其实解决这个问题的根本其实就是需要一种机制来保证一个人修改了内存数据后另外几个缓存了该共享变量的人可以感知到,那么就可以保证各个缓存之间的数据一致性了。
2.3.1 sychronized
synchronized 底层实际上通过JVM来实现的,同一时间只能有一个线程去执行synchronized 中的代码块。
既然同一时间只有一个线程去运行里面的代码,那么这个操作就是不能被其它线程打断的,所以这里天然就具有原子性了。自然,线程对变量的修改也会被其他线程读取到,可以这里是将并发强行转为了串行。
2.3.2 volatile
volatile起到了如下的作用
- 在对变量进行写入时,会先写入到本地内存然后立即刷新到主内存中;在对变量读取时,直接从主内存读取。
- volatile标识的共享变量不会被指令重排序
为了达到这两个目的,volatile做了两件事情:
- 禁止编译器的优化和重排
- 通过内存屏障限制处理器重排
2.3.3 内存屏障
无论是sychronized还是volatile,都是通过内存屏障保证了缓存的可见性。
为什么会有内存屏障?
- 每个CPU都会有自己的缓存(有的甚至有三级缓存),缓存的目的就是为了提高性能,避免每次都要向内存取,但是这样的弊端也是很明显:不能实时和内存发生信息交换,分在不同CPU执行的不同线程对同一变量的缓存值不同。
- 用Volatile关键字修饰变量可以解决上述问题,Volatile通过内存屏障来实现,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样的,java通过屏蔽这些差异,统一由jvm来生成内存屏障指令。
内存屏障分为两种
- Load Barrier 读屏障
- Store Barrier 写屏障
内存屏障的两个作用
- 阻止屏障两侧的指令重排序
- 写的时候,强制把缓冲区/高速缓存中的数据写回主内存,并让缓冲中的数据失效;读的时候直接从主内存中读取对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据
对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见
java的内存屏障通常所谓的四种即LoadLoad,StoreStore,LoadStore,StoreLoad实际上也是上述两种的组合,完成一系列的屏障和数据同步功能
- LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
volatile与内存屏障
volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;
由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。
final语义中的内存屏障
对于final域,编译器和CPU会遵循两个排序规则:
新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;(废话嘛)
初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序;(晦涩,意思就是先赋值引用,再调用final值)
总之上面规则的意思可以这样理解,必需保证一个对象的所有final域被写入完毕后才能引用和读取。这也是内存屏障的起的作用:
写final域:在编译器写final域完毕,构造体结束之前,会插入一个StoreStore屏障,保证前面的对final写入对其他线程CPU可见,并阻止重排序。
读final域:在上述规则2中,两步操作不能重排序的机理就是在读final域前插入了LoadLoad屏障。
X86处理器中,由于CPU不会对写-写操作进行重排序,所以StoreStore屏障会被省略;而X86也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略。