成神之路 第002期 JVM-Java内存模型
- 并发编程模型的分类
- 线程通信机制
- 共享内存(Java采用)
- 通过主内存和线程公共内存之间的信息同步来实现隐式通信
线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。
- 通过主内存和线程公共内存之间的信息同步来实现隐式通信
- 消息机制
- 线程之间的通信必须通过明确的发送消息来显式进行通信
- 共享内存(Java采用)
- 同步
- 程序用于控制不同线程之间操作发生相对顺序的机制
- 在共享内存并发模型中,同步是显式进行的。必须显式的指定某个方法或代码块需要在先出现之间互斥执行
- 在消息传递的并发模型中同步是隐式进行的。因为消息的发送必须要在消息的接受之前
- 线程通信机制
- Java内存模型抽象
- 概念
- JMM(java memory model)定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
- 抽象模型图
- 实例(线程A与线程B通信)
- 步骤一,线程A把本地内存A中更新过的共享变量刷新到主内存中去
- 步骤二,线程B到主内存中去读取线程A之前已更新过的共享变量
- 数据竞争
- 在一个线程中写一个变量
- 在另一个线程读同一个变量
- 而且写和读没有通过同步来排序
- JMM对正确同步的多线程程序的内存一致性做了如下保证
如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)。
即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。这里的同步是指广义上的同步,
包括对常用同步原语(lock,volatile和final)的正确使用。
- 顺序一致性内存模型
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
- 示例图
当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化。
- 顺序一致性内存模型和JMM区别
- 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序
- JMM不保证对64位的long型和double型变量的读/写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性
- 概念
- 重排序
- 重排序分类
- 编译器优化的重排序
- 编译器在不改变的语义的前提下,对执行语句的顺序做出调整
- 指令级并行的重排序
- 现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。若数据不存在依赖性,处理器可以改变语句对应的机器指令的执行顺序
- 内存系统的重排序
- 指令屏障
- 为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序
- 编译器优化的重排序
- 数据依赖性
- 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
- 上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
- 数据依赖性只是针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器和不同线程之间不做考虑。
- as-if-serial语义
- 不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。
- 所有的重排序都不会有数据依赖的操作做重排序,因为这样会改变最终的执行结果。
- 程序顺序规则
- 在不改变程序执行结果的前提下,尽可能的开发并行度。
- 重排序对多线程的影响
- 当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。
- 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
- 重排序分类
- volatile
- 自身特性
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
- 内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
- 内存语义总结
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所在修改的)消息。
- 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
- 内存语义实现
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
- 基于保守策略的JMM内存屏障插入策略
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
- 自身特性
- 锁
- 释放和获取的内存语义
- 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。
- 对比锁释放-获取的内存语义与volatile写-读的内存语义
- 锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。
- 总结
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
- 程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
- 锁释放-获取的内存语义的实现方式
- 利用volatile变量的写-读所具有的内存语义。
- 利用CAS(java的compareAndSet()方法)所附带的volatile读和volatile写的内存语义。
CAS如何同时具有volatile读和volatile写的内存语义
编译器不会对volatile读与volatile读后面的任意内存操作重排序
编译器不会对volatile写与volatile写前面的任意内存操作重排序
- concurrent包的实现
- java的CAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有四种方式:
- A线程写volatile变量,随后B线程读这个volatile变量。
- A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
- A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
- A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。
- java的CAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有四种方式:
- 释放和获取的内存语义
- final
- 遵守两个重排序规则:
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
- 写final域的重排序规则:
- JMM禁止编译器把final域的写重排序到构造函数之外。
- 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
- 读final域的重排序规则:
- 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
- final域是引用类型
- 示例代码
public class FinalReferenceExample {
final int[] intArray; //final是引用类型
static FinalReferenceExample obj;
public FinalReferenceExample () { //构造函数
intArray = new int[1]; //1
intArray[0] = 1; //2
}
public static void writerOne () { //写线程A执行
obj = new FinalReferenceExample (); //3
}
public static void writerTwo () { //写线程B执行
obj.intArray[0] = 2; //4
}
public static void reader () { //读线程C执行
if (obj != null) { //5
int temp1 = obj.intArray[0]; //6
}
}
} - 实例图片
- 上图,1是对final域的写入,2是对这个final域引用的对象的成员域的写入,3是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的1不能和3重排序外,2和3也不能重排序。
- JMM可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。即C至少能看到数组下标0的值为1。而写线程B对数组元素的写入,读线程C可能看的到,也可能看不到。JMM不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。
- 示例代码
- 遵守两个重排序规则: