as-if-serial
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个是写操作,此时这两个操作就存在数据依赖性。数据依赖分为一下三种:
名称 | 代码示例 |
---|---|
写后读 | a=1;b=a |
写后写 | a=1;a=2 |
读后写 | b=a;a=1 |
这里所说的数据依赖性仅针对单个处理器执行的指令序列和单个线程中执行的操作,不同处理器和线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial语义
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
- 编译器和处理器可能会对操作做重排序。
- 编译器和处理器在重排序时,会遵守数据依赖性。
- 编译器和处理器不会改变存在数据依赖关系的两个操作。
- as-if-serial语义指:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器,运行时和处理器必须遵守as-if-serial语义。
为了遵守as-if-serial,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种操作会改变执行结果,但是如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。
比如
double pai = 3.14; //A
double r = 1.0; //B
double area = r * r * pai; //C
A和B,A和C都存在数据依赖关系,因此在指令重排序时C不会被排到AB的前面,但是AB没有数据依赖关系,编译器和处理器可能会对AB进行重排序。
A—>B—>C
B—>A—>C
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime和处理器共同为编写单线程的程序员创建了一个幻觉:单线程程序是按程序的顺序执行的。
as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
指令重排序
在执行程序时为了提高性能,编译器和处理器往往会进行指令重排序,重排序分为三种类型:
- 1.编译器优化的重排序
- 2.指令级并行的重排序
- 3.内存系统的重排序
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行重排序
现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP),来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统重排序
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
指令重排序含义
从java源代码到最终执行的指令序列,可能会分别经历三种重排序:指令级并行的重排序和内存系统重排序属于处理器重排序;这些重排序都可能造成多线程程序出现内存可见性问题。
- 对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
对于处理器重排序,JMM的处理器重排序规则会要求JAVA编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止) - JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排和处理器重排,为程序员提供一致的内存可见性保证。
public class Test{
int a=0;
boolean flag = false;
public void writer(){
a=1; //1
flag = true; //2
}
public void reader(){
if(flag){ //3
int i = a * a;//4
}
}
}
以上代码模拟一个生产者/消费者模型,生产者writer,消费者reader,
- 假设操作1,2做了重排序,执行时,线程A先写flag,随后线程B读这个变量,由于判断条件为真,线程B将读取变量a,此时线程A还没将变量a赋值,多线程的语义就被破坏了。
- 程序中,3和4存在控制依赖关系,当代码中存在控制依赖关系时,会影响指令序列执行的并行度,为此,编译器会采用猜测(Speculation)执行来客服控制相关性对并行度的影响。
- 以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。接下来操作3的条件判断为真时,就把该计算结果写入变量i中。
- 猜测执行实质上对操作3和4做了重排序,破坏了多线程语义,单线程中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因),但是在多线程中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
volatile内存语义
语义
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。
在JVM底层volatile是采用内存屏障来实现的。
1.可见性,不保证原子性
以下代码说明不保证原子性
package com.hexy.thread.demo;
/*
* 并不能保证原子性
*/
public class VolatileDemo {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
final VolatileDemo test = new VolatileDemo();
for(int i=0;i<10;i++){
new Thread( ()->{
for (int j = 0; j < 1000; j++) {
test.increase();
}
}).start();
}
Thread.sleep(3000);
System.out.println(test.inc);
}
}
2.禁止指令重排序
说明
可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的工作内存。
volatile关键字能有效的解决这个问题。
JAVA中的happen-before规则JSR133:
- 程序顺序规则:一个线程中的每个操作,happen-before于该线程中的任意后续操作。
- 监视器锁规则:对一个监视器锁,happen-before于随后对这个监视器的加锁。
- volatile变量规则:对一个volatile域的写,happen-before任意后续对这个域的读。
- 传递性:Ahappen-beforeB,Bhappen-beforeC,则Ahappen-beforeC。
- 线程的start()方法happen-before该线程所有后续操作。
- 线程所有的操作happen-before其他线程在该线程上调用join()返回成功后的操作。
volatile原理
内存屏障
为了实现volatile可见性和happen-before语义。
JVM底层通过一个叫做内存屏障的东西来完成。
内存屏障是一组处理器指令,用于实现对内存操作的顺序限制。
是否可以重排序 | 第二个操作 | |||
第一个操作 | 普通读 | 普通写 | volatile读 | volatile写 |
普通读 | LoadStore | |||
普通写 | StoreStore | |||
volatile读 | LoadLoad | LoadStore | LoadLoad | LoadStore |
volatile写 | StoreLoad | StoreStore |
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | 确保Load1数据的装载之前于Load2及所有后续装载指令的装载 |
StoreStore | Store1; StoreStore; Store2 | 确保Store1数据对其他处理器可见(刷新到内存) ,之前与Store2及所有后续存储指令的存储 |
LoadStore | Load1; LoadStore; Store2 | 确保Load1数据装载之前于Store2及所有后续存储指令刷新到内存 |
StoreLoad | Store1; StoreLoad ; Load2 | 确保Store1数据对其他处理器的可见,之前与Load2及所有后续装载指令的装载,StoreLoad屏障会使该屏障之前的所有内存访问指令(存储和装载指令) 完成之后,才执行该屏障之后的内存访问指令 |