一. 基础
1.java线程间以共享内存的方式交换信息而达到通信目的
2.同步:程序用于控制不同线程之间操作发生相对顺序的机制
3.Java 内存模型的抽象
在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量 (Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
java线程之间的通信由Java内存模型(简称JMM)控制。JMM 决定一个线程对共享变量的写入何时对另一个线程可见
线程之间的共享变量存储在主内存中,每个线程都有一个私有本地内存。本地内存中存储了该线程已读/写共享变量的副本。
线程间通信如图:
4.重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性(包含数据间接依赖),处理器可以改变语句对应机器指令的执行顺序。
3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
JMM的编译器规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序
5.处理器重排序与内存屏障指令
6.happens-before
在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系,规则如下:
程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意
后续操作。
监视器锁规则:对一个监视器的解锁,happens- before 于随后对这个监视器 的加锁。
volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对 这个 volatile 域的读。
传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。
注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行。happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前
二.重排序
1.数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
2.as-if-serial
指:不管怎么重排序(编译器和处理器为了提高并行度), (单线程)程序的执行结果不能被改变。
编译器,runtime 和处理器都必须遵守 as-if-serial 语义。
单线程程序如果操作之间不存在数据依赖关系,可能会被重排序。
3.程序顺序规则
JMM允许不改变程序执行结果的重排序。
4.重排序对多线程的影响
三.顺序一致性
1、数据竞争与顺序一致性保证
JMM规范对数据竞争的定义如下:
在一个线程中写一个变量,
在另一个线程读同一个变量,
而且写和读没有通过同步来排序。
JMM 对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)–即程序的执行结果与该程序在顺序一致性内存模型中的执行结 果相同。马上我们将会看到,这对于程序员来说是一个极强的保证。这里的同 步是指广义上的同步,包括对常用同步原语(synchronized,volatile 和 final) 的正确使用。
2.顺序一致性内存模型(理想化模型)
顺序一致性内存模型有两大特性:
一个线程中的所有操作必须按照程序的顺序来执行。
(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见
根据 JMM 规范,该程序的执行结果将与该程序在 顺序一致性模型中的执行结果相同。
1.同步程序的顺序一致性效果
JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外, 那样会破坏监视器的语义)。JMM 会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。
2.未同步程序的执行特性
JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false), JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。为了 实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。
JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一 致。
四.volatile
1.volatile 的特性
可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++这 种复合操作不具有原子性。
2.volatile 写-读建立的 happens before 关系
volatile 写和锁的释放有相同的内存语义;volatile 读与锁的获取有相同的内存语义。
3.volatile 写-读的内存语义
volatile 写的内存语义如下:
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile 读的内存语义如下:
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
4.volatile 内存语义的实现
1.第二个操作是 volatile 写时,不能重排序;
2.第一个操作是 volatile 读时,不能重排序;
3.第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。
为确保volatile语义,JMM 内存屏障插入策略:
在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
五、final
对于 final 域,编译器和处理器要遵守两个重排序规则:
a. 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
b. 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。
1.写 final 域的重排序规则
a.基础类型
JMM 禁止编译器把 final 域的写重排序到构造函数之外。
编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。
b.引用类型,增加如下规则:
在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
2.读 final 域的重排序规则
在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。
只要对象是正确构造的 (被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指 lock 和 volatile 的使用),就可以保证任意线程都能看到这个 final域在构造函数中被初始化之后的值。
Java 程序的内存可见性保证按程序类型可以分为下列三类:
1. 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理 器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果 相同。
2. 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程 序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见 性保证。
3. 未同步/未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程 执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null, false)。