线程通信:共享内存和消息传递
共享内存:线程之间共享程序的公共状态,通过读写公共状态隐式通信。
消息传递:线程之间必须通过发送消息来显式进行通信。
线程同步:指程序中用于控制不同线程间操作发生相对顺序的机制
共享内存:同步是显式进行的需要显式指定某个方法或某段代码需要在线程之间互斥执行。
消息传递:消息的发送必须在消息的接收之前,同步是隐式进行的。
Java的并发采用的是共享内存模型:隐式通信
实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享
局部变量、方法定义参数、异常处理器参数不会在线程之间共享,不会有内存可见性问题。
线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程 读/写 共享变量的副本。
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型
编译器重排序:
处理器重排序:
JMM的编译器重排序规则会禁止特定类型的编译器重排序,对于处理器重排序插入特定类型的内存屏障来禁止特定类型的处理器重排序,用来提供一致的内存可见性。
写缓冲区仅对自己的处理器可见,处理器允许对写-读操作进行重排序,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。
通过4中内存屏障:LoadLoadBarriers StoreStoreBarriers LoadStoreBarriers StoreLoadBarriers 防止处理器重排序。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前.
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性
上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序.
数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑.
as-if-serial 语义的意思是:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
对于存在数据依赖关系的的操作不会进行重排序,对于不存在数据依赖关系的操作可以进行重排序。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
分析具体ABC 依赖关系 根据程序顺序原则 A happens-before B B happens-before C 根据传递性原则 A happens-before C 但AB 之间不存在依赖关系 可以进行重排序。
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照
JMM对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
顺序一致性内存模型:
1)一个线程中的所有操作必须按照程序的顺序来执行。
2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内
存模型中,每个操作都必须原子执行且立刻对所有线程可见。
个人总结: 线程中的每一个操作都是按照代码里的顺序来执行的 ,多个线程之间不管同步与否都要保证每个操作的顺序性,非同步时也要保证每个线程内的每个操作按照顺序来,尽管可能多个线程之间的操作有先后交叉,如A线程 A1→A2→A3, B线程B1→B2→B3 非同步时 可能会B1→A1→A2→B2→A3→B3。
顺序一致性内存模型只是理想化的,在JMM中就没有这个保证,非同步的情况下会乱序!!!
对volatile变量的单个读/写,等价是使用同一个锁对这些单个读/写操作做了同步。
volatile变量 自身具有下列 特性:
volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。
volatile写的内存语义:
当 写 一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义:
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
volatile写和volatile读的内存语义总结:
锁释放 与 volatile写 有相同的内存语义;锁获取 与 volatile读 有相同的内存语义。
·线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息
ReentrantLock:
ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(AQS), 使用一个整型的volatile变量来维护同步状态。
ReentrantLock分为 公平锁 和 非公平锁:
锁释放-获取的内存语义的实现至少有下面两种方式。
1)利用volatile变量的写-读所具有的内存语义。
2)利用CAS所附带的volatile读和volatile写的内存语义。
由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信现在有了下面4种方式。
1)A线程写volatile变量,随后B线程读这个volatile变量。
2)A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
3)A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
4)A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量
concurrent包通用化的实现模式:
对于final域,编译器和处理器要遵守两个重排序规则:
1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
1)JMM禁止编译器把 final域 的 写 重排序到 构造函数 之外。
2)编译器会在 final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
1)JMM禁止处理器重排序 初次读对象引用 与 初次读该对象包含的final域 (这两个操作之间存在依赖关系,编译器不会重排序,大多数处理器也不会重排序)
2)编译器会在读final域操作的前面插入一个LoadLoad屏障
对于引用类型的final域: 在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
在构造函数内部,不能让这个被构造对象的 引用 为其他线程所见,也就是对象引用不能在构造函数中“逸出”。
在X86处理器中,final域的读/写不会插入任何内存屏障。
JMM把happens-before要求禁止的重排序分为了下面两类。
- as-if-serial 语义保证单线程内程序的执行结果不被改变,好似单线程程序是按程序的顺序来执行的。
- happens-before关系保证正确同步的多线程程序的执行结果不被改变,好似正确同步的多线程程序是按happens-before指定的顺序来执行的。
双重检查锁定(Double-Checked Locking)是常见的延迟初始化技术,用来降低初始化类和创建对象的开销。
错误的示例!!!
public class DoubleCheckedLocking { // 1
private static Instance instance; // 2
public static Instance getInstance() { // 3
if (instance == null) { // 4:第一次检查 <===================== 此处
synchronized (DoubleCheckedLocking.class) { // 5:加锁
if (instance == null) // 6:第二次检查
instance = new Instance(); // 7:问题的根源出在这里
} // 8
} // 9
return instance; // 10
} // 11
}
在线程执行到第4行,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。
示例代码的第7行(instance=new Singleton();)创建了一个对象。这一行代码可以分解为如下的3行伪代码。
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址
2 和 3之间,可能会被重排序,变为如下伪代码。
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象
单线程下java语言规范约定可以对不改变执行结果的代码进行重排序,只要保证初始化对象在初次访问对象之前即可。
但是多线程下此处重排序存在问题:如下图两个线程A、B执行上述代码可能产生的时序表。
Java内存模型的intra-thread semantics将确保A2一定会排在A4前面执行,由此可以有两种解决办法:
1)不允许2和3重排序。
2)允许2和3重排序,但不允许其他线程“看到”这个重排序
把instance声明为volatile型即可 (禁止2和3的重排序)
这个解决方案需要JDK 5或更高版本(因为从JDK 5开始使用新的JSR-133内存模型规范,这个规范增强了volatile的语义
public class SafeDoubleCheckedLocking {
private volatile static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null){
instance = new Instance(); // instance为 volatile,现在没问题了
}
}
}
return instance;
}
}
JVM执行类的初始化期间,会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化。(允许2和3重排序,但不允许非构造线程“看到”这个重排序)。
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
return InstanceHolder.instance ; // 这里将导致InstanceHolder类被初始化
}
}
补充:类或接口类型T将被立即初始化的情况:
1)T是一个类,而且一个T类型的实例被创建。
2)T是一个类,且T中声明的一个静态方法被调用。
3)T中声明的一个静态字段被赋值。
4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
5)T是一个顶级类(Top Level Class,见Java语言规范的§7.6),而且一个断言语句嵌套在T内部被执行。
Java语言规范规定,对于每一个类或接口C,都有一个唯一的 初始化锁 LC与之对应。
JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了
类初始化的处理过程分为了5个阶段:
Java语言规范并没有硬性规定一定要使用condition和state标记。
Java语言规范允许Java的具体实现,优化类的初始化处理过程(对这里的第5阶段做优化
未完待续 持续更新ing。。。
本文整理内容出自《Java并发编程的艺术》一书 第二版, 大力推荐~~~