线程之间的通信机制有两种:
- 共享内存:在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。
- 消息传递:在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信
重排序:
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序。
重排序分3种类型。
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。处理器将多条指令重叠执行。数据没有依赖性处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
源代码-----1编译器重排序-------2指令级重排序----------3内存系统的重排序
1属于编译器重排序,2和3属于处理器重排序
这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
happens-before 规则
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。如果前一个操作(A)必须要对后 一个操作(C)可见 ,那么这两个操作(A C) 指令不能重排。
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
顺序一致性模型
- 顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
- 顺序一致性模型有一个单一的全局内存,在任意时间点最多只能有一个线程可以连接到内存。在顺序一致性模型中,所有操作之间具有全序关系。
顺序一致性模型的同步与未同步
假设A线程有3个操作在程序中的顺序是:A1→A2→A3。B线程也有3个操作在程序中的顺序是:B1→B2→B3。
- 同步程序:A线程的3个操作执行后释放监视器锁,随后B线程获取同一个监视器锁。那么他们的执行顺序是 A1→A2→A3→B1→B2→B3。
- 未同步程序:他们的执行可能是A1→A2→B1→A3→B2→B3也可能是 B1→A1→A2→A3→B2→B3 整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序(线程a 看到的是 A1→A2→B1→A3→B2→B3 线程b 看到的也是 A1→A2→B1→A3→B2→B3 ),顺序一致性内存模型中的每个操作必须立即对任意线程可见。
JMM顺序一致性
未同步程序:
- 整体的执行顺序是无序,
- 所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之 前,这个写操作仅对当前线程可见;其他线程不可见。
同步程序:
jmm 执行结果将与该程序在顺序一致性模型中的执行结果相同。顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区( 存在并发的代码块 )内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理(比如加锁),虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
未同步顺序一致性 和 jmm 顺序一致性差异
- 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内按程序的顺序执行(会重排序)。
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
- JMM不保证对64位的long和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写都具有原子性。
为什么JMM 不保证64 位的long 和double 的原子性
在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(WriteTransaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存, 总线仲裁会确保所有处理器都能公平的访问内存在任意时
间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。
在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。
volatile
特性:
- 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量读写具有原子性,类似volatile++这种复合操作不具有原子性。
volatile内存:
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
volatile重排序:
是否可以重排序 | |
1普通读写 - 2 volatile 读 | 是 |
volatile 读 - 2 (volatile 读写 或 普通读写) | 否 |
volatile 写 - 2 (volatile 读写 或 普通读写) | 否 |
1 (volatile 读写 或 普通读写) - 2 volatile 写 | 否 |
在每个volatile写操作的前面插入一个StoreStore屏障。
·在每个volatile写操作的后面插入一个StoreLoad屏障。
·在每个volatile读操作的后面插入一个LoadLoad屏障。
·在每个volatile读操作的后面插入一个LoadStore屏障。
锁释放和锁获取的内存语义
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
锁释放-获取的内存语义的实现至少有下面两种方式。
- 利用volatile变量的写-读所具有的内存语义。
- 利用CAS所附带的volatile读和volatile写的内存语义。
查看ReentrantLock的源码可以看出 源码中 有一个volatile变量 state 通过对state 的cas 更新来实现锁。
final域的的重排序规则
写final域: 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序
读final域: 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
class FinalExample{
int i;//普通变量
final int j;//final变量
static FinalExample obj;
public FinalExample(){//构造函数
i = 1;//写普通域
j = 2;//写final域
}
public static void writer(){//线程A写执行
obj = new FinalExample();
}
public static void read(){//线程B读执行
FinalExample fe = obj;//读取包含final域对象的引用
int a = fe.i;//读取普通变量
int b = fe.j;//读取final变量
}
}
关于写final域排序如上图代码 处理器会对代码重排序 假设线程A执行 writer 然后线程 B执行 read 此时代码被重排序
//1 线程 a 执行 writer 然后 FinalExample中的 i=1; 普通变量被重排序到了 FinalExample构造函 数外
public FinalExample(){//构造函数
j = 2;//写final域编译器会在return 前为final域 插入一个StoreStore屏障 不会被重排序到方法外。
}
i = 1;//写普通域
//2 线程 b由于线程 a和b 是并发执行所以在a FinalExample() 构造函数后恰好 线程b 执行read 所以此时线程b 会读取不到普通域 i 而final 域j 不能重排序到构造方法外所以可以正确读写
public static void read(){//线程B读执行
FinalExample fe = obj;//读取包含final域对象的引用
int a = fe.i;//读取普通变量
int b = fe.j;//读取final变量
}
final域引用类型的排序问题
public class User {
final String[] arr;
public User() {
arr = new String[]{};
arr[0] = "0";
arr[1] = "1";
arr[2] = "2";//写final域编译器会在return 前为final域 插入一个StoreStore屏障 不会被重排序到方法外。 所以 arr[0] = "0" 也不会被重排序到方法外;
}
}
为什么final域的引用不能从构造函数溢出(final 域在步骤2 溢出)
写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。 要达到这个效果还要保证对象的引用不能在构造函数溢出
图代码中的 1和2 可能重排序 当A线程执行完了2 this 已经赋值给了 u 另一个线程 去执行 3 的时候u不为null str会为null
因为A线程还没有执行1
public class User {
private static User u;
final String str;
public User() {
str="1"; // 1
u=this; // 2
}
public static void sout(){ //3
if(u!=null){
System.out.println(u.str);
}
}
}