12 Java 内存模型

Wu Jun 2020-01-09 20:44:20
05 Java > 01 Java 虚拟机

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 这样的同步工具类等。

线程、主内存和工作内存的关系如下所示: image

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 如何保证内存可见性:

  1. 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。
  2. 当读一个 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 的规则包括:

在多线程访问共享数据时, 至少有一条线程执行写入操作时, 如果读操作和写操作之间没有Happen-Before关系, 那么就会存在数据竞争问题.

时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准;

7 发布与逸出

7.1 发布

7.2 逸出

7.3 不安全的发布

当缺少 Happen-Before 关系时,就可能出现重排序问题。

除不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行

7.4 安全发布

通过使用一个由锁保护共享变量或者使用共享的volatile类型变量,可以确保对该变量的读取操作和写入操作按照Happens-Before关系来排序。

Happens-Before比安全发布提供了更强可见性与顺序保证。

Happens-Before排序是在内存访问级别上操作的,它是一种“并发级汇编语言",而安全发布的运行级别更接近程序设计。

1)不正确的发布: 正确的对象被坏
2)不可变对象与初始化安全性
3)安全发布的常用模式
4)事实不可变对象
5)可变对象

对象的发布需求取决于它的可变性

5)安全地共享对象

在并发程序中使用和共享对象时, 可以使用一些实用的策略

造成不正确发布的真正原因, 就是在“发布一个对象”与“另一个线程访问该对象”之间缺少一种 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 {}
}

8 初始化过程中的安全性

初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象各个 final 域设置的正确值,而不管采用何种方式来发布对象。而且对于可以通过被正确构造对象中某个 final 域到达的任意变量(例如某个 final 数组中的元素或者由一个 final 域引用的 HashMap 的键值)将同样对于其它线程是可见的。

初始化安全性只能保证 final 域可达的值从构造过程完成时开始的可见性。对于通过非 final 域可达的值,或者在构造过程完成后,可能改变的值,必须采用同步来确保可见性。

9 借助同步

有一项技术称之为“借助(Piggyback)”,是因为它使用了一种现有的 Happens-Before 顺序来保证 对象 X 的的可见性,而不是专门为了发布 X 而创建的一种 Happens-Before 顺序。

在类库中提供的其他 Happens-Before 排序包括: