参考资料
- Java内存模型
- 享学课堂并发编程——mark老师
前置知识
计算机存储模型
计算机中,CPU的存取速度远大于内存的存取速度(相差几个数量级),所以计算机系统加入一层读写速度尽可能接近处理器运算速度的高速缓存作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,运算结束后再从缓存中同步到内存中,这样就避免处理器等待缓慢的内存读写
- 一般来说,L0(寄存器)、 L1、L2、L3都集成在CPU内部,而L1还分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据和执行数据的指令解码
- 每个核心拥有独立的运算处理单元、控制器、寄存器、L1、L2缓存,然后一个CPU的多个核心共享最后一层CPU缓存L3
存在的问题
缓存一致性问题
基于高速缓存的存储交互虽然能解决CPU和内存的速度差异问题,但是也因此产生了缓存一致性的问题
多处理器的系统中,每个处理器都有自己的高速内存,而它们又共享同一主内存;当多个处理器的运算任务都涉及同一块主内存,这就很可能导致各自的缓存数据不一致——缓存一致性问题
上面的这种情况,处理器A和处理器B缓存的数据明显不一致,所以执行操作的顺序不同,程序结果也不相同,为了解决缓存一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时根据这些协议进行操作,常见的缓存一致性协议有MSI、MESI、MOSI等
指令重排序问题
为了使处理器内部的运算单元能尽量的被充分使用,处理器可能会对输入代码的执行顺序进行乱序优化
- 处理器在计算之后将乱序执行的结果重组,保证该结果和顺序执行的结果一致,但并不保证程序各个语句执行的顺序和输入代码的顺序一致
- 如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的顺序来保证
- 类似处理器的乱序执行优化,JVM的即时编译器也有相应的指令重排序优化机制
伪共享
- CPU中有好几级缓存,CPU缓存以缓存行(cache line)为单位存储,目前主流的CPU缓存的缓存行大小都是64Bytes
- CPU不是按字节访问内存,而是以64字节为单位的块去取,当你读取一个特定的内存地址,整个缓存行将从主存缓存到内存
伪共享
一个缓存行可以存储多个变量,CPU对缓存的修改又是以缓存行作为最小单位的,所以多线程情况下,如果A、B在同一个缓存行中,一个线程修改一个共享变量A,另一个线程就无法同时修改共享变量B,这样会无意中影响批次性能,这就是伪共享
如何避免伪共享
- Jdk1.7之前可以采用数据填充的方式避免伪共享,即单个数据填充满整个缓存行
- Jdk1.8新增了@sun.misc.Contented注解(默认无效,需要jvm启动时设置-XX:-RestrictContended才会生效),可以自动补齐缓存行
避免伪共享可以提高程序运行效率,提高性能
JMM
- JMM屏蔽了不同系统、硬件的差异,让Java程序在不同平台运行达到的结果
- 定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量的底层细节
- 这里的变量指的是线程间的共享变量
- 线程之间共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存存储了该线程用来读写共享变量的副本
- 线程堆变量的所有操作都必须在本地内存中而不能直接操作主内存的变量,不同线程无法直接访问对方本地内存中的变量,线程之间变量值的传递需要通过主内存完成
- 从更低层次来说,主内存就说硬件的内存,为了更好的运行速度,JVM及硬件系统会让工作内存优先存储于寄存器和高速缓存中。JMM的工作内存是对CPU寄存器和高速缓存的抽象描述
JMM模型下线程间的通信
线程间通信必须要经过主内存 如下,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:
- 线程A把本地内存A中更新过的共享变量刷新到主内存中去
- 线程B到主内存中去读取线程A之前已更新过的共享变量
- 主内存和工作内存之间操作有8种,每一种操作都是原子性的
- 这些操作和字节码不是一个概念,这里应该说是JVM和物理内存与CPU打交道的操作
- 具体操作如下:
- lock:作用于主内存的变量,把一个变量标识为一个线程独占
- unlock:作用域主内存的变量,把锁定状态的变量释放出来,释放后的变量才能被其他线程锁定
- read:作用于主内存的变量,把一个变量的值从主内存传输到工作内存中
- load:作用于工作内存的变量,把read操作从主内存得到的变量值放入工作内存的变量副本中(不是拷贝对象)
- use:作用于工作内存的变量,把工作内存中的一个变量的值传递给执行引擎
- assign:作用于工作内存的变量,把从执行引擎接收到的值赋给工作内存中的变量
- store:作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存
- write:作用于主内存的变量,把store操作从工作内存得到的变量的值放入主内存的变量中
- 如果要把一个变量从主内存复制到工作内存,就要顺序地执行read和load,反之为store和write
- JMM规定这些操作必须遵守的规则:
- lock操作可以被同一个线程执行多次,且执行相同次数的unlock后变量才会解锁
- 对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值(保证加锁前后变量值都是最新状态)
- 对一个变量执行unlock之前,必须把此变量同步回主内存中(执行store,write操作)(保证加锁前后变量值都是最新状态)
- lock/unlock操作并没有直接开放给用户使用,提供了更高层次的字节码指令 monitorenter/monitorexit来隐式地使用这两个操作,对应到Java代码就是synchronized,因此synchronized块之间的操作也具备原子性
JMM带来的问题
可见性问题
- 线程2从主存中拷贝共享对象obj到自己的工作内存,把obj的count属性值修改为2
- 由于这个操作的更改还没有flush到主存中,所以对线程1是不可见的,线程1中count的值还是1
多线程环境下,因为工作内存中的值flush到主内存中的时机是不确定的,所以线程操作的更改在flush主存之前对其他线程来说是不可见的
竞争问题
- 线程A、线程B共享一个对象obj,假设线程A、线程B分别从主存中读取obj.count变量到自己的CPU缓存
- 同时对这个obj.count进行加1操作,线程A、线程B各自工作内存中的count值都是2,主存中count=1
- 如果这两个操作是串行执行的,最终结果是正确的
- 如果两个操作是并行的,就有可能两次操作只加了1
解决办法:synchronized
重排序
重排序类型
程序执行过程中,为了提高性能,编译器和处理器通常会对指令做重排序优化
分为三种类型
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以将语句执行顺序重新排列
- 指令级并行的重排序:在指令级别,让没有依赖关系的多条指令并行执行
- CPU内存的重排序:由于CPU存在自己的缓存,导致指令的执行顺序和写入主内存的顺序并不是完全一致的,使得加载和存储操作看起来可能是乱序执行的
数据依赖性
如果两个操作访问同一个变量,有一个操作是写操作,此时这两个操作之间就存在依赖性
数据依赖分为3种类型:
名称 | 示例 | 说明 |
---|---|---|
写后读 | a=1; b=a; | 写一个变量后,再读这个位置 |
写后写 | a=1; a=2; | 写一个变量后,再写这个变量 |
读后写 | a=b; b=1; | 读一个变量后,再写这个变量 |
- a = 1;
- b = a;
- a++;
- c = a;
步骤3和步骤2执行顺序的不同会导致不同的结果
重排序要求,不论如何重排序,单线程下运行必须是正确的,所以提出了as-if-serial
as-if-serial
不管如何重排序(编译器和CPU为了提高并行度),(单线程)程序的执行结果不能改变
- 编译器和CPU不会对存在数据依赖关系的操作重排序,因为这种重排序会改变执行结果
- 如果操作之间不存在数据依赖关系,这些操作就可能被编译器和CPU重排序
控制依赖性
前序操作是条件语句(if、while……),则后续操作和前序之间就产生了控制依赖关系
- 操作1和操作2没有数据依赖关系,可以重排序;操作3和操作3没有数据依赖关系,也可以重排序
- 操作3和操作4之间存在控制依赖关系
代码存在控制依赖关系时,会影响指令序列执行的并行度。为此,编译器和CPU会采用猜测执行来克服控制相关性对并行度的影响
- 执行线程B的处理器提前读取并计算a*a,然后将计算结果临时保存到名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中
- 当操作3判断为真时,就把该计算结果直接写入变量i中
猜测执行实质上对操作3和操作4做了重排序,问题在于,这个时候a的值还没被线程A赋值,还是0
1、单线程情况
- 操作3为真,那么temp = aa = 11 = 1的值确实是我们需要的值,没问题
- 操作3为假,那么执行结果存在缓存中并没有被使用,相当于没发生重排序
所以单线程程序中,存在控制依赖的操作重排序,并不会改变执行结果,这也是as-if-serial为什么允许对存在控制依赖的操作做重排序的原因
2、多线程情况
线程A先执行init(),随后线程B执行use(),线程B在执行操作4时,并不一定能看到线程A在操作1对共享变量a的写入 存在这种情况:操作1、2重排序,操作3、4重排序,线程A执行init()时先将flag赋值为true,随后线程B读取flag,校验通过,线程B读取a的值还是旧值,这就出现了错误 多线程环境下,对存在控制依赖的操作重排序,可能会改变程序的执行结果
内存屏障
Memory Barrier
- 保证特定的操作执行的顺序
- Java编译器在生成指令序列的适当位置插入内存屏障指令来禁止特定类型的CPU重排序,从而保证程序按照预想流程执行
- 影响某些数据的内存可见性
- 强制刷出各种CPU cache,如一个write-barrier(写入屏障)将刷出所有在屏障之前写入缓存的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本
内存屏障指令
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 确保Load1在Load2及后续所有装载指令之前完成数据装载 |
StoreStore | Store1;StoreStore;Store2 | 确保Store1数据对其他处理器可见(刷新到内存),且在Store2及后续所有存储指令前完成 |
LoadStore | Load1;LoadStore;Store2 | 确保Load1在Store2及所有后续存储指令刷新数据到内存之前完成数据装载 |
StoreLoad | Store1;StoreLoad;Load2 | 1. 确保Store1数据对其他处理器可见(刷新到内存) 1. Store1在Load2及所有后续装载指令前完成数据刷新 1. 该屏障之前所有内存访问指令(装载和存储)完成后,才执行该屏障后的内存访问指令 |
StoreLoad内存屏障是一个“全能型”的屏障,同时具有其他3个屏障的效果。现代的多处理大多支持该屏障
- 其他类型的屏障不一定被所有处理器支持
临界区
同一时间只有一个线程可以访问临界区的代码
- JMM会在进入临界区和退出临界区两个关键时间点做特别处理,使得多线程在这两个时间点按照某种顺序执行
- 临界区的代码可以重排序,这种重排序既提高了执行效率,又不影响程序的执行结果
happens-before
本质上和as-if-serial语义是一回事。只不过as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before保证正确同步的多线程程序的执行结果不被改变
定义
- 在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系
- 前一个操作的结果对后一个操作可见,但并不一定前一个操作必须在后一个操作之前执行
理解
- 从程序员的角度来看:JMM保证,如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
- 从编译器和CPU的角度来看:JMM允许,两个操作之间存在happens-before关系,不要求java平台的具体实现必须按照happens-before关系指定的顺序来执行。如果重排序后的执行结果,与按照happens-before关系执行的结果一致,那么这种重排序是允许的
happens-before规则
程序顺序规则:一个线程中的一段代码执行结果是有序的。无论怎么指令重排序,结果都是按照编写代码顺序执行的结果
监视器锁规则:不论单线程还是多线程环境,对于同一把锁来说,一个线程解锁后,另一个线程才能获取锁
volatile变量规则:一个线程写一个volatile变量,接着另一个线程读这个变量,这个写操作的结果对读的这个线程可见
线程启动规则:主线程A执行过程中,启动子线程B,线程A在启动线程B之前对共享变量的修改结果对线程B可见
线程终止规则:主线程A执行过程中,子线程B终止,线程B在终止之前对共享变量的修改结果在线程A中可见
线程中断规则:对线程interrupt方法的调用一定发生在检测到中断事件之前
join规则:线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作都发生在线程A从Thread.join()操作成功返回之前