3、Java 内存模型
3.1 Java 内存模型的基础
并发编程模型
- 在命令式编程 中,线程之间的通信机制有两种:共享内存和消息传递
- 共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态 进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消 息来显式进行通信
- 同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型 里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的
- Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行
Java内存模型的抽象结构
- Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享 变量的写入何时对另一个线程可见。
- 线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地 内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的 一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化
从源代码到指令序列的重排序
- 编译器和处理器常常会对指令做重排序
···编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句 的执行顺序。
··· 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
···内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上 去可能是在乱序执行。
写缓冲区
- 写缓冲区可以保证指令流水线 持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以 批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总 线的占用
- 写缓冲区仅对自己的处理器可见,现代的处理器都会允许对写-读操作进行重排序。
内存屏障类型
- StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果
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仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一 个操作按顺序排在第二个操作之前
3.2 重排序
数据依赖性
- 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间 就存在数据依赖性
- 编译器和处理器在重排序时,会遵 守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序
- 不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑
as-if-serial语义
- 不管怎么重排序(编译器和处理器为了提高并行度),(单线程) 程序的执行结果不能被改变
重排序对多线程的影响
- 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操 作重排序,可能会改变程序的执行结果。
3.3 顺序一致性
数据竞争
- 当程序未正确同步时,就可能会存在数据竞争
- 定义:
···在一个线程中写一个变量
··· 在另一个线程读同一个变量
··· 写和读没有通过同步来排序
顺序一致性内存模型
- 两大特性
······一个线程中的所有操作必须按照程序的顺序来执行。
······(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内 存模型中,每个操作都必须原子执行且立刻对所有线程可见。
- 概念:顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关 可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作
- 未同步程序在JMM 中不但整体的执行顺序是无序的,而 且所有线程看到的操作执行顺序也可能不一致
同步程序的顺序一致性效果
- 顺序一致性模型中,所有操作完全按程序的顺序串行执行。
- JMM中,临界区内的代码 可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)
未同步程序的执行特性
- 最小安全性:线程执行时读取到的 值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取 到的值不会无中生有的冒出来
- 实现:JVM在堆上分配对象 时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因 此,在已清零的内存空间分配对象时,域的默认初始化已经完成了
- 未同步程序在两 个模型中的执行特性有如下几个差异
···顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的 操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
···顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程 能看到一致的操作执行顺序。
···JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保 证对所有的内存读/写操作都具有原子性
······数据通过总线在处理器和内存之间传递,总线事务包括读事务和写事务,总线会同步试图并发使用总线的事 务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写
3.4 volatile 的内存定义
volatile 特性
把对volatile变量的单个读/写,看成是使用同一个锁对这 些单个读/写操作做了同步
- 锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对 一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
- volatile变量自身具有下列特性:
··· 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
···原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不 具有原子性
volatile写-读的内存语义
- volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和 锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。
volatile写-读的内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
volatile写和volatile读的内存语义总结:
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程 发出了(其对共享变量所做修改的)消息。
- 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile 变量之前对共享变量所做修改的)消息。
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过 主内存向线程B发送消息。
volatile内存语义的实现
volatile重排序规则
JMM采取保守策略,基于保守策略的JMM内存屏障插入策略
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
在功能上,锁比volatile更强大;在可伸缩性和执行 性能上,volatile更有优势
3.5 锁的内存语义
锁可以让临界区互斥执行
锁的释放和获取的内存语义
- 当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中
- 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的 临界区代码必须从主内存中读取共享变量
······线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A 对共享变量所做修改的)消息。
······线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共 享变量所做修改的)消息。
······线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
concurrent包的实现
- A线程写volatile变量,随后B线程读这个volatile变量。
- A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
- A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
- A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。
通用化的实现模式
- 首先,声明共享变量为volatile。
- 然后,使用CAS的原子条件更新来实现线程之间的同步。
- 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的 通信。
concurrent包的实现示意图
3.6 final域的内存语义
final域的重排序规则
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用 变量,这两个操作之间不能重排序。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能 重排序。
写final域的重排序规则
- JMM禁止编译器把final域的写重排序到构造函数之外。
- 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障 禁止处理器把final域的写重排序到构造函数之外。
读final域的重排序规则
- 在一个线程中,初次读对象引用与初次读该对象包含的final 域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。
- 编译器会在读final 域操作的前面插入一个LoadLoad屏障。
······读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final 域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经 被A线程初始化过了。
final域为引用类型
- 对于引用类型,写final域的重 排序规则对编译器和处理器增加了如下约束:
······在构造函数内对一个final引用的对象的成员域 的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之 间不能重排序。
问:为什么final引用不能从构造函数内“溢出”
- 写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该 引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实,要得到这个效果, 还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对 象引用不能在构造函数中“逸出”。
JSR-133为什么要增强final的语义
- 在旧的Java内存模型中,一个最严重的缺陷就是线程可能看到final域的值会改变。比如, 一个线程当前看到一个整型final域的值为0(还未初始化之前的默认值),过一段时间之后这个 线程再去读这个final域的值时,却发现值变为1(被某个线程初始化之后的值)。最常见的例子 就是在旧的Java内存模型中,String的值可能会改变。
- 通过为final域增加写和读重排序 规则,可以为Java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在 构造函数中没有“逸出”),那么不需要使用同步(指lock和volatile的使用)就可以保证任意线程 都能看到这个final域在构造函数中被初始化之后的值
3.7 happens-before
JMM把happens-before 要求禁止的重排序分为了下面两类
- 会改变程序执行结果的重排序。
- 不会改变程序执行结果的重排序。
不同的策略
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种 重排序)。
happens-before的定义
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作 可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照 happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系 来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
as-if-serial语义
- as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同 步的多线程程序的执行结果不被改变
happens-before规则
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的 读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的 ThreadB.start()操作happens-before于线程B中的任意操作。
······假设线程A在执行的过程中,通过执行ThreadB.start()来启动线 程B;同时,假设线程A在执行ThreadB.start()之前修改了一些共享变量,线程B在开始执行后会 读这些共享变量 - join()规则:如果线程A执行操作ThreadB.join() 并成功返回,那么线程B中的任意操作 happens-before于线程A从ThreadB.join() 操作成功返回。
······假设线程A在执行的过程中,通过执行ThreadB.join()来等待线 程B终止;同时,假设线程B在终止之前修改了一些共享变量,线程A从ThreadB.join()返回后会 读这些共享变量
3.8 双重检查锁定与延迟初始化
需要采用延迟初始化来降低初始化类和创建对象的开销。双 重检查锁定是常见的延迟初始化技术,但它是一个错误的用法
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
}
看起来,似乎两全其美
- 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
- 在对象创建好之后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象。
错误的优化!在线程执行到第4行,代码读 取到instance不为null时,instance引用的对象有可能还没有完成初始化
基于volatile的解决方案
-
把instance声明为volatile型
基于类初始化的解决方案 -
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在 执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
-
在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化。
···1)T是一个类,而且一个T类型的实例被创建。
··· 2)T是一个类,且T中声明的一个静态方法被调用。
··· 3)T中声明的一个静态字段被赋值。
···4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
···5)T是一个顶级类,而且一个断言语句嵌套在T 内部被执行。 -
如果确实需要对实例字段使用线程 安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案
-
如果确实需要对静 态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案
3.9 Java内存模型综述
处理器的内存模型
- 顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时通常会以顺 序一致性内存模型为参照。在设计时,JMM和处理器内存模型会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。
- 放松程序中写-读操作的顺序,由此产生了Total Store Ordering内存模型(简称为TSO)。
- 在上面的基础上,继续放松程序中写-写操作的顺序,由此产生了Partial Store Order内存 模型(简称为PSO)。
- 在前面两条的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了Relaxed Memory Order内存模型(简称为RMO)和PowerPC内存模型。
处理器内存模型的特征表
- 所有处理器内存模型都允许写-读重排序,它们都使用了写缓存区。写缓存区可能导致写-读操作重排序,处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区。由于写缓存区仅 对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己写缓 存区中的写
- 从上到下,模型由强变弱。越是追求性能的处理器,内 存模型设计得会越弱
- JMM屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为Java程序员呈现 了一个一致的内存模型
JMM的内存可见性保证
- 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确 保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
- 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行 结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限 制编译器和处理器的重排序来为程序员提供内存可见性保证。
- 未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取 到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。
问:JSR-133对旧内存模型的修补
- 增强volatile的内存语义。旧内存模型允许volatile变量与普通变量重排序。JSR-133严格 限制volatile变量与普通变量的重排序,使volatile的写-读和锁的释放-获取具有相同的内存语义
- 增强final的内存语义。在旧内存模型中,多次读取同一个final变量的值可能会不相同。为 此,JSR-133为final增加了两个重排序规则。在保证final引用不会从构造函数内逸出的情况下, final具有了初始化安全性