1 为什么需要内存模型
1.1 硬件的效率与一致性
1)高速缓存
绝大多数的运算任务不可能只靠处理器计算就能完成,处理器至少要与内存交互,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了;
2)缓存一致性
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性;为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等;
3)内存模型
内存模型可以理解在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象;不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型,并且这里介绍的内存访问操作与硬件的缓存访问具有很高的可比性;
4)乱序执行优化
除了增加高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的;
1.2 重排序
- 在编译器中生成的指令顺序,可以与源代码中的顺序不同
- 此外编译器还会把变量保存在寄存器而不是内存中;
- 处理器可以采用乱序或并行等方式来执行指令;
- 缓存可能会改变将写人变量提交到主内存的次序;
- 而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。
同步将限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏JMM提供的可见性保证。
1.3 Java 内存模型
Java 虚拟机规范中试图定义一种 Java 内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
为了使 Java 开发人员无须关心不同架构上内存模型之间的差异,Java 提供了自己的内存模型,并且 JVM 通过在适当的位置上插人内存栅栏来屏蔽在 JVM 与底层平台内存模型之间的差异。
主内存、工作内存与 JVM 内存不是同一个层次的内存划分,没有关联
2 主内存与工作内存
Java 内存模型的主要目标是定义程序中各个线程共享变量的访问规则
2.1 主内存
Java 内存模型规定了所有的变量都存储在主内存中,线程不能直接读写主内存中的变量;
2.2 工作内存
线程有自己的工作内存,保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行
2.3 内存栅栏
内存栅栏(Memory Barrier)就是从本地或工作内存到主存之间的拷贝动作:Java 并发 API 中很多操作都隐含有跨越内存栅栏的含义:volatile、synchronized、Thread 中的函数如 start() 和 interrupt()、ExecutorService 中的函数以及像 CountDownLatch 这样的同步工具类等。
线程、主内存和工作内存的关系如下所示:
3 内存间交互操作
Java 内存模型中定义了以下八种操作来完成主内存与工作内存之间具体的交互协议,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于 double 和 long 类型的变量的某些操作在某些平台允许有例外):
lock、unlock、read、load、use、assign、store、write
基于理解难度和严谨性考虑,最新的 JSR-133 文档中,已经放弃采用这八种操作去定义 Java 内存模型的访问协议了,后面将会介绍一个等效判断原则 – 先行发生原则,用来确定一个访问在并发环境下是否安全;
4 volatile 变量的特殊规则
关键字 volatile 是 JVM 提供的最轻量级的同步机制;
4.1 两种特性
volatile 变量具备两种特性:
- 可见性:当一个线程修改了这个变量的值,新的值对于其他线程来说是可以立即得知的,而普通的变量的值在线程间传递均需要通过主内存来完成;
- 有序性:禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致;
4.2 非线程安全
volatile 变量在各个线程的工作内存中不存在一致性问题,但是 Java 里面的运算并非原子操作,导致 volatile 变量的运算在并发下一样是不安全的;
在不符合以下两条规则的运算场景中,我们仍然要通过加锁来保证原子性:
- 运算结果并不依赖变量的当前值或者能够确保只有单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束;
4.3 性能
volatile 变量读操作没差别,写操作慢一些;大多数场景下总开销比锁低
在 volatile 与锁之中选择的唯一依据仅仅是 volatile 的语义能否满足使用场景的需求;
4.4 原理
Volatile 如何保证内存可见性:
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
5 long 和 double 型变量的特殊规则
5.1 非原子性协定
Java 内存模型要求, 变量的读取和写入操作都必须是原子操作, 但对于非 volatile 类型的 long 和 double 变量, JVM 允许将 64 位的读操作或写操作, 分解为两个 32 位的操作。
当读取一个非 volatile 类型的 long 变量时, 如果对该变量的读操作和写操作在不同的线程中执行, 那么很可能会读取到每个值的高 32 位和另一个值的第 32 位, 这被称为字撕裂。
通过 volatile 修饰或者用锁保护起来的 long 或 double 可以保证读写原子性。
5.2 原子性的操作
但允许虚拟机选择把这些操作实现为具有原子性的操作,目前各种平台下的商用虚拟机几乎都选择把 64 位数据的读写操作作为原子操作来对待;
6 原子性、可见性与有序性
6.1 原子性(Atomicity)
由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write;在 synchronized 块之间的操作也具备原子性;
6.2 可见性(Visibility)
当一个线程修改了共享变量的值,其他线程能够立即得知这个修改;除了 volatile 之外,Java 还有 synchronized 和 final 关键字能实现可见性;
- 加锁可以用于确保某个线程以一种可预测的方式来查看另一个线程执行结果.
- 加锁的含义不仅仅局限于互斥行为, 还包括内存可见性。为了确保所有线程都能看到共享变量的最新值, 所有执行读操作和写操作的线程都必须在同一个锁上进行同步。
6.3 有序性(Ordering)
如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的;Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性;
7 先行发生原则
如果说操作 A 发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到,影响包括了修改了内存中共享变量的值、发送了消息、调用了方法等
Happens-Before 的规则包括:
- 程序顺序规则
- 监视器锁规则
- volatile 变量规则
- 线程启动规则
- 线程终止规则
- 线程中断规则
- 终结器规则
- 传递性
在多线程访问共享数据时, 至少有一条线程执行写入操作时, 如果读操作和写操作之间没有Happen-Before关系, 那么就会存在数据竞争问题.
时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准;
7 发布与逸出
7.1 发布
- 发布一个对象是指该对象能够在当前作用域之外的代码中使用
- 当发布某个对象时, 如果在发布过程中确保线程安全性, 则可能需要使用同步
- 发布内部状态可能会破坏封装性, 并使得程序难以维持不变性条件
7.2 逸出
- 当某个不应该被发布的对象被发布时, 这种情况被称为逸出
- 发布某个对象时会间接发布其它对象
- 不要再构造过程中使 this 引用逸出
7.3 不安全的发布
当缺少 Happen-Before 关系时,就可能出现重排序问题。
- 如果无法确保发布共享引用的操作Happen-Before另一个线程加载该引用,可能看到一个被部分构造的对象.
- 错误的延迟初始化将导致不正确的发布
除不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行
7.4 安全发布
通过使用一个由锁保护共享变量或者使用共享的volatile类型变量,可以确保对该变量的读取操作和写入操作按照Happens-Before关系来排序。
Happens-Before比安全发布提供了更强可见性与顺序保证。
Happens-Before排序是在内存访问级别上操作的,它是一种“并发级汇编语言",而安全发布的运行级别更接近程序设计。
1)不正确的发布: 正确的对象被坏
2)不可变对象与初始化安全性
- 任何线程都可以在不需要同步的情况下安全地访问不可变对象, 即使发布对象时没有使用同步
- 如果 final 类型的域指向的是可变对象, 那么在访问这些域指向的对象的状态时仍然需要同步
3)安全发布的常用模式
- 在静态初始化函数中初始化一个对象引用
- 将对象的引用保存到 volatile 类型的域或者 AtomicReferance 对象中
- 将对象的引用保存到某个被正确构造对象的 final 类型域中
- 将对象的引用保存到一个由锁保护的域中
4)事实不可变对象
- 如果对象从技术上看是可变的, 但是状态在发布之后不会再改变, 这种对象称之为事实不可变对象
- 在没有额外同步的情况下, 任何线程都可以安全地使用被安全发布的事实不可变对象
5)可变对象
对象的发布需求取决于它的可变性
- 不可变对象可以通过任意方式来发布
- 事实不可变对象必须通过安全发布的方式来发布
- 可变对象必须通过安全发布的方式来发布, 并且必须是线程安全的类或者由某个锁保护起来
5)安全地共享对象
在并发程序中使用和共享对象时, 可以使用一些实用的策略
- 线程封闭: 线程封闭的对象只能由一个线程访问
- 只读共享: 不可变对象和事实不可变对象
- 线程安全共享: 线程安全的对象内部实现同步机制, 如 ConcurrentHashMap
- 保护对象: 通过持有特定的锁来访问
造成不正确发布的真正原因, 就是在“发布一个对象”与“另一个线程访问该对象”之间缺少一种 Happen-Before 关系。
7.5 安全初始化模式
@ThreadSafe
public class ResourceFactory {
private static class ResourceHolder {
public static Resource resource = new Resource();
}
public static Resource getResource() {
return ResourceFactory.ResourceHolder.resource;
}
private static class Resource {}
}
使用延长初始化占位类模式即可以达到延迟初始化, 又避免同步开销。
7.6 双重检查加锁
@NotThreadSafe
public class DoubleCheckedLocking {
private static Resource resource;
public static Resource getInstance() {
if (resource == null)
synchronized (DoubleCheckedLocking.class) {
if (resource == null)
resource = new Resource();
}
return resource;
}
private static class Resource {}
}
- 双重检查由于重排序的问题, 线程可能看到一个仅被部分构造的 resource.
- 可通过将 resource 声明为 volatile 来修正上述问题
- 不建议使用此种方式,较延迟初始化占位类模式相比,DCL 模式代码过于复杂且更脆弱。
8 初始化过程中的安全性
初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象各个 final 域设置的正确值,而不管采用何种方式来发布对象。而且对于可以通过被正确构造对象中某个 final 域到达的任意变量(例如某个 final 数组中的元素或者由一个 final 域引用的 HashMap 的键值)将同样对于其它线程是可见的。
初始化安全性只能保证 final 域可达的值从构造过程完成时开始的可见性。对于通过非 final 域可达的值,或者在构造过程完成后,可能改变的值,必须采用同步来确保可见性。
9 借助同步
有一项技术称之为“借助(Piggyback)”,是因为它使用了一种现有的 Happens-Before 顺序来保证 对象 X 的的可见性,而不是专门为了发布 X 而创建的一种 Happens-Before 顺序。
在类库中提供的其他 Happens-Before 排序包括:
- 将一个元素放人一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行
- 在 CountDownLatch 上的倒数操作将在线程从闭锁上的await方法中返回之前执行
- 释放 Semaphore 许可的操作将在从该 semaphore 上获得一个许可之前执行
- Future 表示的任务的所有操作将在从 Future.get 中返回之前执行
- 向 Executor 提交一个 Runnable 或 Callable 的操作将在任务开始执行之前执行。
- 一个线程到达 CyclicBarrier 或 Exchanger 的操作将在其他到达该棚栏或交换点的线程被释放之前执行。如果 CyclicBarrier 使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,而栅栏操作又会在线程从栅栏中释放之前执行。