Java内存模型(JMM)-这一篇全部了解

Java 内存模型基础

什么是 Java 内存模型(JMM-共享内存模型)

  • 内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节。(共享变量是存放在堆内存中,对于局部变量等不会在线程之间共享)
  • Java内存模型定义了多线程之间共享变量的可见性以及如何在需要的时候对共享变量进行同步。原始的Java内存模型效率并不是很理想,因此Java1.5版本对其进行了重构,现在的Java8仍沿用了Java1.5的版本。
  • Java 线程之间的通信由 Java 内存模型控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。

并发编程的两个关键问题

线程之间如何通信:通信指线程之间以何种机制来交换信息

  • 共享内存:线程之间共享程序的公共状态,通过写 - 读内存中的公共状态进行隐式通信
  • 消息传递:在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式通信

线程之间如何同步:同步指不同线程间操作发生相对顺序的机制

  • 共享内存:同步是显式进行的。必须显式指定某个方法或某段代码需要在线程之间互斥执行
  • 消息传递:由于消息的发送必须在消息的接收之前,因此同步是隐式进行

Java 内存模型的抽象


从抽象角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读 / 写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在


  • 线程的通信步骤
  • 线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去
  • 线程 B 到主内存中去读取线程 A 之前已更新过的共享变量

内存可见性保证

JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证

重排序

什么是重排序

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

重排序的类型

1 属于编译器重排序, 2 和 3 属于处理器重排序


编译器优化的重排序

编译器在不爱彼岸单线程程序语义的前提下,可以重新安排语句的执行顺序

指令级并行的重排序

现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

内存系统的重排序

由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

重排序的禁止

  • 对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)
  • 对于处理器,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序

内存屏障

内存屏障类型表

  • 为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序
  • StoreLoad Barriers 是一个“全能型”屏障,它同时具有其他 3 个屏障的效果,现代的多处理器大多支持该屏障
  • 执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中

数据依赖性


  • 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性
  • 编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序
  • 这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑

as-if-serial 语义

  • 含义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变
  • 编译器、runtime 和处理器都必须遵守 as-if-serial 语义
  • 编译器和处理器不会对存在数据依赖关系的操作重排序,因为这种重排序会改变执行结果
  • 如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序

重排序对多线程的影响

  • 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作重排序的原因)
  • 在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果

写缓冲区

什么是写缓冲区

现代处理器使用写缓冲区临时保存向内存写入的数据(CPU有l1,l2,l3三级缓存)

写缓冲区的作用

保证指令流水的持续运行。可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟减少对内存总线的占用。通过批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。

产生的影响

每个处理器的写缓冲区仅仅对它所在的处理器可见,这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致

与重排序的关系

由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写 - 读操作进行重排序

顺序一致性

定义

顺序一致性是一个理论参考模型,JMM 和处理器内存模型在设计时通常会以顺序一致性内存模型为参考,它为程序员提供了极强的内存可见性保证。

两大特性

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

理解

顺序一致性内存模型中,所有操作完全按程序的顺序串行执行,每个操作必须立即对任意线程可见。在 JMM 中就没有这个保证,未同步程序在 JMM 中不但整体的执行顺序是无须的,而且所有线程看到的操作执行顺序也可能不一致。

同步程序的执行特性

  • 根据 JMM 规范,正确同步程序的执行与该程序在顺序一致性模型中的执行结果相同
  • JMM 在具体实现上的基本方针:在不改变(正确同步)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

未同步程序的执行特性

  • JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致
  • 未同步程序在 JMM 中的执行,整体上是无序的,其执行结果无法预知

未同步程序在顺序一致性模型和 JMM 中的执行特性有如下几个差异

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(只保证结果一致)
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序
  • JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性
  • 在 JSR-133 之前的旧内存模型中,一个64位 long/double 型变量的读 / 写操作可以被拆分为两个 32 位的读 / 写操作来执行。从 JSR-133 内存模型开始(即从 JDK5 开始),仅仅只允许把一个 64 位 long / double 型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在 JSR-133 中都必须具有原子性(即任意读操作必须要在单个读事务中执行)

volatile 内存语义

volatile 的特性

  • 理解 volatile 特性的一个办法是对 volatile 变量的单个读 / 写,看成是使用同一个锁对这些单个读 / 写操作做了同步
  • 可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入
  • 原子性。对任意单个 volatile 变量的读 / 写具有原子性,但类似于 volatile++ 这种符合操作不具有原子性

volatile 写 - 读的内存语义

写的内存语义

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

读的内存语义

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

写读内存语义总结

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

volatile 内存语义的实现

  • 重排序分为编译器重排序和处理器重排序,为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。
  • volatile 重排序规则表


  • 为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
  • 基于保守策略的 JMM 内存屏障插入策略
  1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
  2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
  3. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
  4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障
  • 上述 volatile 写和 volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile 写 - 读的内存语义,编译器可以根据具体情况省略不必要的屏障

JSR-133 对 volatile 内存语义的增强

  • 原因:在 JSR-133 之前的旧 Java 内存模型中,虽然不允许 volatile 变量之间重排序,但旧的Java 内存模型允许 volatile 变量与普通变量重排序。在旧的内存模型中,volatile 的写- 读没有锁的释放 - 获取所具有的内存语义
  • 目的:为了提供一种比锁更轻量级的线程之间通信的机制
  • 增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写 - 读和锁的释放 - 获取具有相同的内存语义

锁的内存语义

锁的释放和获取的内存语义(与Volatile相似)

释放

当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中

获取

当线程获取锁时,JMM 会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量

锁释放与锁获取内存语义总结

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

锁的作用

  • 让临界区互斥执行。
  • 释放锁的线程向获取同一个锁的线程发送消息。

锁内存语义的实现

  • ReentrantLock 的实现依赖于 Java 同步器框架 AbstractQueuedSynchronizer(AQS)。AQS 使用一个整型的 volatile 变量(名为 state)来维护同步状态,这个 volatile 变量是Reentrantlock 内存语义实现的关键
  • 类图

公平锁

加锁方法lock()的方法调用轨迹

  • 1、ReentrantLock : lock()
  • 2、FairSync : lock()
  • 3、AbstractQueuedSynchronizer : acquire(int arg)
  • 4、ReentrantLock : tryAcquire(int acquires)
  • 第 4 步真正开始加锁,加锁方法先读 volatile 变量 state

解锁方法unlock()的方法调用轨迹

  • 1、ReentrantLock : unlock()
  • 2、AbstractQueuedSynchronizer : release(int arg)
  • 3、Sync : tryRelease(int releases)
  • 第 3 步真正释放锁,在释放锁的最后写 volatile 变量 state

非公平锁

加锁方法lock()的方法调用轨迹

  • 1、ReentrantLock : lock()
  • 2、NonfairSync : lock()
  • 3、AbstractQueuedSynchronizer : compareAndSetState(int expect, int update)
  • 第 3 步开始真正加锁,该方法是以原子操作的方式更新 state 变量。通过调用compareAndSet() 方法(CAS)

非公平锁的释放与公平锁的释放完全一样

公平锁和非公平锁内存语义的总结

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

CAS

1)具体实现是为cmpxchg指令加lock前缀。

2)缓存锁定:加了lock前缀能使指令执行期间缓存行呗锁定,其他cpu无法读写该内存区域,从而保证指令原子性。

3)禁止指令前后操作的重排序

4)将写缓存区的所有数据刷新到内存中

锁释放 - 获取的内存语义的实现至少有下面两种方式

1)利用 volatile 变量的写 - 读所具有的内存语义
2)利用 CAS 所附带的 volatile 读和 volatile 写的内存语义

concurrent 包的实现

Java线程之间的通信四种方式

  • A 线程写 volatile 变量,随后 B 线程读这个 volatile 变量。
  • A 线程写 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
  • A 线程用 CAS 更新一个 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
  • A 线程用 CAS 更新一个 volatile 变量,随后 B 线程读这个 volatile 变量。

concurrent 包得以实现的基石

  • volatile 变量的读 / 写和 CAS 可以实现线程之间的通信
  • Java的 CAS 会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读 - 改 - 写操作,这厮在多处理器中实现同步的关键

通用化的实现模式

  1. 首先,声明共享变量为 volatile
  2. 然后,使用 CAS 的原子条件更新来实现线程之间的同步
  3. 同时,配合以 volatile 的读 / 写和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent 包中的基础类都是使用这种模式来实现的,而 concurrent 包中的高层类又是依赖于这些基础类来实现的。

concurrent 包的实现示意图


final 域的内存语义

final 域的重排序规则

1)在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给以额引用变量,这两个操作之间不能重排序
2)初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序
3)final 域为引用类型时,增加了如下约束:在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序

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

在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的 final 域可能还没有初始化。在构造函数返回后,任意线程都将保证能看到 final 域正确初始化之后的值

JSR-133 对 final 内存语义的增强

  • 原因:在旧的 Java 内存模型中,一个最严重的缺陷就是线程可能看到 final 域的值会改变
  • 增加 final 内存语义:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指 lock 和 volatile 的使用)就可以保证任意线程都能看到这个final 域在构造函数中被初始化之后的值

JSR-133

理解

JSR-133 是一种内存模型的规范,JSR133为Java语言定义了一个新的内存模型,它修复了早期内存模型中的缺陷

JSR-133 对旧内存模型的修补(主要的两个)

  • 增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写 - 读和锁的释放 - 获取具有相同的内存语义
  • 增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同,为此,JSR-133 为 final 增加了两个重排序规则。在保证 final 引用不会从构造函数内逸出的情况下, final 具有了初始化安全性。

happens-before

概述:JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性,指定两个操作之间的执行顺序。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这两个操作既可以是在一个线程之内,也可以是在不同线程之间, JMM 可以通过happens-before 关系向程序员提供跨线程的内存可见性保证

happens-before 定义

1)JMM 对程序员的承诺:如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前(the first isvisible to and ordered before the second)
2)JMM 对编译器和处理器重排序的约束规则:两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种排序并不非法(JMM 允许这种重排序)

JMM 把 happens-before 要求的禁止重排序分了两类,并且采取了不同的策略

会改变程序执行结果的重排序

对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序

不会改变程序执行结果的重排序

对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不做要求(JMM 允许这种重排序)

JMM 的设计示意图并且得出两点结论

JMM 向程序员提供的 happens-before 规则能满足程序员的需求。向程序员提供了足够强的内存可见性保证

JMM 对编译器和处理器的束缚已经尽可能少。JMMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。


happens-before 与 as-if-serial 的关系

  • as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不变
  • as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 happens-before 指定的顺序来执行的。
  • as-if-serial 和 happens-before都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度

happens-before 规则(可见性规则)

1)程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作
2)监视器锁规则:对一个锁的解锁,happens-before 与随后对这个锁的加锁
3)volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile域的读
4)传递性:如果 A happens-before B,且 B happens-before C,那么 A happensbefore C
5)start() 规则:如果线程 A 执行操作 ThreadB.start()(启动线程B),那么 A 线程的ThreadB.start() 操作 happens-before 于线程 B 中的任意操作
6)join() 规则:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作happens-before 于线程 A 从 ThreadB.join() 操作成功返回

happns-before 与 JMM 关系图


  • 如图,一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则

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

  • 在 Java 多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。
  • 如果对实例对象没有添加 volatile 关键字,在双重检查锁定中,由于 JIT 中发生的重排序,可能出现 instance 已经被分配了内存地址,但是还没有完成初始化,此时另一个线程访问的是一个分配了空间但是还未初始化的对象
  • 两个方法实现线程安全的延迟初始化
            1)不允许重排序
                        使用 volatile 修饰实例变量,可以防止初始化动作和设置内存空间重排序
            2)允许重排序,但不允许其他线程看到这个重排序
                        基于类初始化的解决方案:JVM 在类的初始化阶段(即在 Class 被加载后,且被线程使用之前),会执行类的初 始化。在执行类的初始化期间,JVM 会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化
  • 总结
  1. 基于类初始化的方案的实现代码更简洁,但基于 volatile 的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化
  2. 字段延迟初始化确实降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要由于延迟初始化。
  3. 如果确实需要对实例字段使用线程安全的延迟初始化,使用 volatile 的延迟初始化方案;如果确实需要对静态字段使用线程安全的延迟初始化,使用基于类的初始化方案。

Java 内存模型综述

各种内存模型之间的关系

  • JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型
  • 处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计得会越弱

JMM 的内存可见性保证

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器回共同确保但现场程序的执行结果与该程序在顺序一致性模型中的执行结果相同
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性模型中的执行结果相同)。JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证
  • 未同步 / 未正确同步的多线程程序。JMM 为它们提供最小安全性保障:线程执行时读取到的值,要么是之间某个线程写入的值,要么是默认值(0、null、false)。最小安全性并不保证线程读取到的值,一定是某个线程写完后的值,只保证读取到的值不会无中生有冒出来,并不保证读取到的值一定正确。

并发编程的问题

关于并发编程的问题有所了解,比如原子性问题,可见性问题和有序性问题。

其实,原子性问题,可见性问题和有序性问题。是人们抽象定义出来的。而这个抽象的底层问题就是前面提到的缓存一致性问题、处理器优化问题和指令重排问题等。

原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

有序性即程序执行的顺序按照代码的先后顺序执行。

缓存一致性问题其实就是可见性问题。而处理器优化是可以导致原子性问题的。指令重排即会导致有序性问题

CPU内存模型

共性:

所有CMM都允许更早读取到当前CPU的写,所有CMM都允许Store-Load重排序;CPU具有缓存区(三级)

异性:

TSO

PSO:允许store-store重排序

PMO:load-load,load-store重排序

PowerPC:允许更早读取其他CPU重排序

TSO->PSO->PMO->PowerPc内存束缚从强到弱排序

越弱内存模型,cpu越能进行优化提高并行度,提高性能。


参考资料:

《深入理解Java虚拟机》

《Java并发编程的艺术》

链接:[12.17日更新]《成神之路系列文章》-HollisChuang's Blog

链接:细说Java多线程之内存可见性-慕课网链接:The Java Community Process(SM) Program - JSRs: Jav...

链接:Java内存模型FAQ | 并发编程网 – ifeve.com

链接:深入理解Java内存模型(一)——基础

链接:深入理解Java内存模型(二)——重排序

链接:深入理解Java内存模型(三)——顺序一致性

链接:深入理解Java内存模型(四)——volatile

链接:深入理解Java内存模型(五)——锁

链接:深入理解Java内存模型(六)——final

链接:深入理解Java内存模型(七)——总结


猜你喜欢

转载自blog.csdn.net/w372426096/article/details/80898407