《Java并发编程的艺术》学习之路-- $3 Java内存模型

《Java并发编程的艺术》 学习之路

第三章 Java内存模型

3.1 Java内存模型的基础

3.1.1 并发编程模型的两个关键问题

线程通信共享内存消息传递
共享内存:线程之间共享程序的公共状态,通过读写公共状态隐式通信。
消息传递:线程之间必须通过发送消息来显式进行通信。

线程同步:指程序中用于控制不同线程间操作发生相对顺序的机制
共享内存:同步是显式进行的需要显式指定某个方法或某段代码需要在线程之间互斥执行。
消息传递:消息的发送必须在消息的接收之前,同步是隐式进行的。

Java的并发采用的是共享内存模型:隐式通信

3.1.2 Java内存模型的抽象结构

实例域静态域数组元素都存储在堆内存中,堆内存在线程之间共享
局部变量方法定义参数异常处理器参数不会在线程之间共享,不会有内存可见性问题。
线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程 读/写 共享变量的副本。

3.1.3 从源代码到指令序列的重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型
编译器重排序

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

处理器重排序:

  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

JMM的编译器重排序规则会禁止特定类型的编译器重排序,对于处理器重排序插入特定类型的内存屏障来禁止特定类型的处理器重排序,用来提供一致的内存可见性。

3.1.4 并发编程模型的分类

写缓冲区仅对自己的处理器可见,处理器允许对写-读操作进行重排序,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。
通过4中内存屏障:LoadLoadBarriers StoreStoreBarriers LoadStoreBarriers StoreLoadBarriers 防止处理器重排序。

3.1.5 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仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前.

3.2 重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

3.2.1 数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性
《Java并发编程的艺术》学习之路-- $3 Java内存模型_第1张图片
上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序.
数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑.

3.2.2 as-if-serial语义

as-if-serial 语义的意思是:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
对于存在数据依赖关系的的操作不会进行重排序,对于不存在数据依赖关系的操作可以进行重排序

3.2.3 程序顺序规则

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 之间不存在依赖关系 可以进行重排序。

3.2.4 重排序对多线程的影响

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

3.3 顺序一致性

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照

3.3.1 数据竞争与顺序一致性

JMM对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

3.3.2 顺序一致性内存模型

顺序一致性内存模型
1)一个线程中的所有操作必须按照程序的顺序来执行。
2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内
存模型中,每个操作都必须原子执行且立刻对所有线程可见。

个人总结: 线程中的每一个操作都是按照代码里的顺序来执行的 ,多个线程之间不管同步与否都要保证每个操作的顺序性,非同步时也要保证每个线程内的每个操作按照顺序来,尽管可能多个线程之间的操作有先后交叉,如A线程 A1→A2→A3, B线程B1→B2→B3 非同步时 可能会B1A1A2B2A3B3

顺序一致性内存模型只是理想化的,在JMM中就没有这个保证,非同步的情况下会乱序!!!

3.4 volatile的内存语义

3.4.1 volatile的特性

对volatile变量的单个读/写,等价是使用同一个锁对这些单个读/写操作做了同步。
volatile变量 自身具有下列 特性

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

3.4.2 volatile写-读建立的happens-before关系

volatile的写-读与锁的释放-获取有相同的内存效果:volatile写锁的释放有相同的内存语义;volatile读锁的获取有相同的内存语义。

3.4.3 volatile写-读的内存语义

volatile写的内存语义
一个volatile变量时,JMM会把该线程对应的本地内存中共享变量值刷新到主内存。

volatile读的内存语义
一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存读取共享变量

volatile写volatile读的内存语义总结:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程
    发出了(其对共享变量所做修改的)消息。
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile
    变量之前对共享变量所做修改的)消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过
    主内存向线程B发送消息

3.5 锁的内存语义

锁释放volatile写 有相同的内存语义;锁获取volatile读 有相同的内存语义。
·线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息

ReentrantLock
《Java并发编程的艺术》学习之路-- $3 Java内存模型_第2张图片
ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(AQS), 使用一个整型的volatile变量来维护同步状态。

ReentrantLock分为 公平锁非公平锁

  • 公平锁和非公平锁释放时,最后都要写一个volatile变量state
  • 公平锁获取时,首先会去读volatile变量
  • 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义

锁释放-获取的内存语义的实现至少有下面两种方式。
1)利用volatile变量的写-读所具有的内存语义。
2)利用CAS所附带的volatile读和volatile写的内存语义。

3.5.4 concurrent包的实现

由于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包通用化的实现模式:

  • 声明共享变量为volatile
  • 使用CAS的原子条件更新来实现线程之间的同步
  • 配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信

3.6 final域的内存语义

3.6.1 final域的重排序规则

对于final域,编译器和处理器要遵守两个重排序规则:
1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

3.6.2 写final域的重排序规则

1)JMM禁止编译器把 final域 重排序到 构造函数 之外。
2)编译器会在 final域的写之后构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

3.6.3 读final域的重排序规则

1)JMM禁止处理器重排序 初次读对象引用初次读该对象包含的final域 (这两个操作之间存在依赖关系,编译器不会重排序,大多数处理器也不会重排序)
2)编译器会在读final域操作的前面插入一个LoadLoad屏障

3.6.4 final域为引用类型

对于引用类型的final域: 在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

3.6.5 为什么final引用不能从构造函数内“溢出”

在构造函数内部,不能让这个被构造对象的 引用 为其他线程所见,也就是对象引用不能在构造函数中“逸出”。

3.6.6 final语义在处理器中的实现

在X86处理器中,final域的读/写不会插入任何内存屏障。

3.7 happens-before

3.7.1 JMM的设计

JMM把happens-before要求禁止的重排序分为了下面两类。

  • 会改变程序执行结果的重排序。
  • 不会改变程序执行结果的重排序。
    JMM采用的策略:
  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种
    重排序)

3.7.2 happens-before的定义

  • as-if-serial 语义保证单线程内程序的执行结果不被改变,好似单线程程序是按程序的顺序来执行的。
  • happens-before关系保证正确同步的多线程程序的执行结果不被改变,好似正确同步的多线程程序是按happens-before指定的顺序来执行的。

3.8 双重检查锁定与延迟初始化

双重检查锁定(Double-Checked Locking)是常见的延迟初始化技术,用来降低初始化类和创建对象的开销。

3.8.1 双重检查锁定的由来

错误的示例!!!

	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引用的对象有可能还没有完成初始化

3.8.2 问题的根源

示例代码的第7行(instance=new Singleton();)创建了一个对象。这一行代码可以分解为如下的3行伪代码。

memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址

23之间,可能会被重排序,变为如下伪代码。

memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象

单线程下java语言规范约定可以对不改变执行结果的代码进行重排序,只要保证初始化对象在初次访问对象之前即可。
但是多线程下此处重排序存在问题:如下图两个线程A、B执行上述代码可能产生的时序表。
《Java并发编程的艺术》学习之路-- $3 Java内存模型_第3张图片
Java内存模型的intra-thread semantics将确保A2一定会排在A4前面执行,由此可以有两种解决办法:

1)不允许2和3重排序。
2)允许2和3重排序,但不允许其他线程“看到”这个重排序

3.8.3 基于volatile的解决方案

把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;
	}
}

3.8.4 基于类初始化的解决方案

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个阶段:

  1. 通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
  2. 线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。
  3. 线程A设置state=initialized,然后唤醒在condition中等待的所有线程
  4. 线程B结束类的初始化处理。
  5. 线程C执行类的初始化的处理

Java语言规范并没有硬性规定一定要使用condition和state标记。
Java语言规范允许Java的具体实现,优化类的初始化处理过程(对这里的第5阶段做优化

未完待续 持续更新ing。。。
本文整理内容出自《Java并发编程的艺术》一书 第二版, 大力推荐~~~

你可能感兴趣的:(#)