banner
bladedragon

bladedragon

《并发编程的艺术》-阅读笔记03:Java内存模型

《并发编程的艺术》阅读笔记第三章,先从底层讲起。

基础不牢,地动山摇。

image

一、基础#

1、并发的两个关键问题#

线程间通信和线程间同步

线程通信机制:

  • == 共享内存 ==:隐式通信,显式同步
  • == 消息传递 ==:显式通信,隐式同步

Java 的并发采用的是共享内存模型。

2、java 内存结构#

java 中所有的实例域、静态域和数组元素都存储在堆内存中;局部变量、方法定义参数、异常处理器参数不会在线程之间共享

JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程读 / 写共享变量的副本

本地内存是 JMM 的一个抽象概念,并不真实存在,涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化

线程 A 与线程 B 之间要通信必须经过两个步骤:

  1. 线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去
  2. 线程 B 到主内存中去读取 A 之前已更新过的共享变量

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

3、指令重排序#

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

java 最终执行的指令序列:

源码 --------》编译器优化重排序 -------》指令级并行重排序 ----------》内存系统重排序 ---------》最终的指令序列

第一个属于 == 编译器重排序 ==,后面两个属于 == 处理器的重排序 ==,JMM 通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证

对于编译器,JMM 的编译器会禁止特定类型的编译器重排序。== 对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定的内存屏障指令 ==,从而禁止特定类型的处理器重排序。

4、内存屏障分类#

load:读缓冲区

store:写缓冲区

四种内存屏障:

  • LoadLoad Barriers:确保 load1 的数据先于 laod2 及后续所有 load 指令进行装载
  • StoreStore Barriers:确保 store1 的数据对其他处理器的可见性先于 store2 及后续所有存储指令
  • LoadStore Barriers:确保 load 装载先于 store 的存储刷新到内存
  • StoreLoad Barriers:该屏障前的指令全部完成之后才会执行后面的指令(开销大)

image

5、先行发生(happens-before)#

程序员必须遵循的编程原则

==JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 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 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前,,并不意味着前一个操作必须要在后一个操作之前执行!

前一个要求对后一个操纵可见不是意味着前一个操作必须在后一个操作之前执行?

JMM要求如果A happens-before B那么首先必须保证执行结果(A的执行结果对B **不一定可见** )必须和逻辑中的happens-before相同,其次,A的排序顺序在B之前(这里是指重排序之前的顺序,后期允许通过重排序调整A和B的操作顺序),但是最终结果必须和逻辑上的保持一致,因此从表面上看是按序执行了

二、重排序#

数据依赖性

在单线程程序中,对存在控制依赖的操作重排序不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

详见30页的例子

as-if-serial

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义

三、顺序一致性内存模型#

如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)—— 即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同

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

未同步程序在顺序一致性模型中给虽然整体执行顺序是无序的但是所有线程都只能看见一个一致的的整体执行顺序。 之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见

JMM 就没有这个保证。因为只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。

JMM 的处理逻辑:临界区内的代码可以重排序;JMM 会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图

JMM 的具体实现的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门

顺序一致性内存模型和 JMM 区别

  1. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。这一点前面已经讲过了,这里就不再赘述。
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。这一点前面也已经讲过,这里就不再赘述。
  3. ==JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性。==

处理器总线工作机制

在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的

总线事务包括读事务(Read Transaction)写事务(Write Transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上连续的字

  • 总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和 I/O 设备执行内存的读 / 写 (从而保证当单个总线事务之中的内存读写具有原子性)
  • 在一些 32 位的处理器上,如果要求对 64 位数据的写操作具有原子性,会有比较大的开销。== 当 JVM 在这种处理器上运行时,可能会把一个 64 位 long/double 型变量的写
    操作拆分为两个 32 位的写操作来执行 ==

注意,在 JSR-133 之前的旧内存模型中,一个 64 位 long/double 型变量的读 / 写操作可以被拆分为两个 32 位的读 / 写操作来执行。从 JSR-133 内存模型开始(即从 JDK5 开始),仅仅只允许把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在 JSR-133 中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。

四、volatile 内存语义#

volatile 变量特性:

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

volatile 写 - 读的内存语义:

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

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

image

volatile读的时候必须保证主内存中的数据不会更改,因此volatile读如果是第一个操作怎不能实现重排序
volatile写的时候必须保证之前的数据能正常写入主内存,因此volatile写如果是第二个操作的话不能实现重排序
普通读写是在本地内存,但是有概率会写入主内存,因此具有随机性

volatile 内存语义的实现#

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

JMM 内存屏障插入策略 (保守!):

  • 在每个volatile写操作前面插入StoreStore屏障
  • 在每个volatile写操作后面插入StoreLoad屏障
  • 在每个volatile读操作后面插入一个LoadLoad、一个LoadStore

image

image

为什么volatile写操作之前不用插入loadstore来避免普通读和volatile写之间的重排序?
猜测:内存屏障之间存在包含关系,比如storeload就可以实现其他三个所有功能

volatile 写 - 读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升

image

实际情况编译器在生成字节码的时候可以优化选择是否添加内存屏障,但是注意一般最后一个 storelaod 不能省略,因为无法判断 return 后是否还会有下一个 volatile 读 / 写出现

不同的处理器平台也会对内存屏障做出优化

在功能上,锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势

五、锁的内存语义#

锁的释放和获取的内存语义(和 volatile 一样)

  • 线程释放锁时,会把本地内存中的共享变量刷新到主内存中(对应 volatile 写)
  • 线程获取锁时,会将线程对应的本地内存置为无效,从而临界区代码必须从主内存读取共享变量(对应 volatile 读)

锁内存语义的实现

分析 ReentrantLock 源码#

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

ReentrantLock 锁分为公平锁和非公平锁

公平锁

现在tryAcqyuire方法有点变化,在查看是不是第一个获取锁的对象处添加了hasQueuedPredecessors()方法,一个用来查询是否有线程比现在线程等待时间更长

非公平锁
调用 CAS:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。

公平锁和非公平锁语义总结:

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

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

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

CAS 是如何同时具有 volatile 读和 volatile 写的内存语义的?

多处理器环境,会为 cmpxchg 指令加上 lock 前缀,单处理器不用加(单处理器会维护自身的顺序一致性)

  • 原先使用总线锁定,后来考虑开销使用缓存锁定,大大降低 lock 前缀指令的执行开销
  • lock 指令能防止重排序
  • 把写缓冲区中的所有数据刷新到内存中

上面 2、3 两点具有的内存屏障效果,足以同时实现 volatile 读和 volatile 写的内存语义

concurrent 包的通用实现模式#

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

image

六、final 域的内存语义#

1、final 域的重排序规则#

(1)写:在 == 构造函数内对 == 一个 final 域的写入,与 == 随后把这个被构造对象的引用赋值给一个引用变量 ==,这两个操作不能重排序

(2)读:初次读一个包含 final 域的对象的引用,与 == 随后初次读这个 final 域 ==,这两个操作不能重排序。

2、写 final 域的重排序规则#

== 禁止把 final 域的写重排序到构造函数之外 == 包含两方面:

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

上述规则可以确保:

在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障。

3、读 final 域的重排序规则#

处理器:在一个线程中,初次读对象引用与初次读该对象所包含的 final 域,JMM 禁止处理器重排序这两个操作

编译器:编译器会在读 final 域操作的前面插入一个LoadLoad屏障・

上述重排序规则可以确保:在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用(针对少数处理器存在间接依赖关系的操作做重排序)

4、当 final 域为引用类型#

对于引用类型,写 final 域的重排序规则增加下面的约束:

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

对于存在数据竞争的线程无法保证可见性

5、为什么 final 域不能从构造函数内溢出#

在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的 final 域可能还没有初始化 (构造函数内可能发生重排序)。

==(在引用变量为任意线程和可见之前,引用变量所指向的对象的 final 域必须要正确初始化,这是写 final 域的重排序规则)==

七、happens-before#

as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序执行的。

happens-before 关系给编写正确同步的多线程程序员创造了一个幻境:正确同步的多线程程序是按 happens-before 指定的顺序执行的。

这么做的目的:为了在不改变程序的执行结果的前提下,尽可能地提高程序执行的并行度。

完整 hgappen-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 中的任意操作。
  • join () 规则:如果线程 A 执行操作 ThreadB.join () 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join () 操作成功返回

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

双重检查锁定

问题描述:(一般是单例模式) 在上锁之前加一个非空判断,上锁后再次非空判断,实现双重检查锁定

原因:在构造实例时,对象引用指针的操作和初始化操作可能会被重排序,这就导致在 if (instance==null) 的时候认为对象已经创建,但这个时候还没有进行初始化

1.分配对象的内存空间
2.初始化对象
3.设置instance指向内存空间
4.初次访问对象

3和2可能会被重排序,导致1342这样的问题

一开始单例模式一旦出现并发,就可能出现初始化两个对象的问题

后来选择加锁,加锁能解决问题,但是出现严重的性能开销

后来就选择在加锁前加一层非空判断,这样就可以只在第一次初始化,后期不用加锁但

是正是由于添加了一层非空判断,导致多线程在进行这个判断的时候,** 读取操作跳过了等待时间直接读取对象,但此时由于锁内空间的重排序,导致此时对象还没有初始化完成。** 从而造成严重的后果

解决方式:

  1. volatile。利用 volatile 的语义禁止重排序。在单例的懒汉模式中,必须给实例添加 volatile 修饰符

    优势: == 除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。==

  2. 基于类初始化的解决方案,使得线程访问之前就完成初始化

    JVM 在类的初始化阶段(即在 Class 被加载后,且被线程使用之前),会执行类的初始化。
    执行类的初始化期间,JVM 会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

    备注:类初始化的几种情况
    1)T是一个类,而且一个T类型的实例被创建。
    2)T是一个类,且T中声明的一个静态方法被调用。
    3)T中声明的一个静态字段被赋值。
    4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
    5)T是一个顶级类(Top Level Class,见Java语言规范),而且一个断言语句嵌套在T内部被执行。
    

JVM 初始化期间的同步过程#

初始化阶段

  • 第 1 阶段:通过在 Class 对象上同步(即获取 Class 对象的 == 初始化锁 ==),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。

    == 此时只有一个线程给能获得初始化锁 ==

  • 第 2 阶段:线程 A 执行类的初始化,,同时线程 B 在初始化锁对应的condition上等待

    初始化 instance 过程 (== 可能发生重排序,但是对于其他线程不可见 ==)

    1. 分配内存空间
    2. 复制给引用变量
    3. 初始化对象
  • 第 3 阶段:线程 A 设置初始化完成标志,然后唤醒在 condition 中等待的所有线程。

  • 第 4 阶段:线程 B 结束类的初始化处理。

    线程 A 在第 2 阶段的 A1 执行类的初始化,并在第 3 阶段的 A4 释放初始化锁;

    线程 B 在第 4 段的 B1 获取 == 同一个初始化锁 ==,并在第 4 阶段的 B4 之后才开始访问这个类

  • 第 5 阶段:线程 C 执行类的初始化的处理。

    线程 A 在第 2 阶段的 A1 执行类的初始化,并在第 3 阶段的 A4 释放锁;``

    线程 C 在第 5 阶段的 C1 获取 == 同一个锁 ==,并在在第 5 阶段的 C4 之后才开始访问这个类

九、JAVA 内存模型综述#

处理器内存模型#

常见 处理器内存模型

  • Total Store Ordering 内存模型 (TSO)

    放松程序中 == 写 - 读 == 操作的顺序

  • Total Store Ordering 内存模型(PSO)

    继续放松程序中 == 写 - 写 == 操作的顺序

  • Relaxed Memory Order 内存模型(RMO)

    继续放松程序中 == 读 - 写 == 操作的顺序

  • PowerPC 内存模型

    继续放松 == 读 - 读 == 操作的顺序

image

所有处理器内存模型都允许写 - 读重排序,原因:它们都使用了写缓存区.

  1. 写缓存区可能导致写 - 读操作重排序

  2. 由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己写缓存区中的写。

image

内存模型之间关系#

同处理器内存模型一样,越是追求执行性能的语言,内存模型设计得会越弱。

image

JMM 的内存可见性保证#

按程序类型可分为

  • 单线程程序

    不会出现内存可见性问题

  • 正确同步的多线程程序

    具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)

  • 未同步 / 未正确同步的多线程程序

    JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。

    但最小安全性并不保证线程读取到的值,一定是某个线程写完后的值。== 最小安全性保证线程读取到的值不会无中生有的冒出来,但并不保证线程读取到的值一定是正确的 ==。

旧内存模型的修补#

JDK1.5 之后对 volatile 的内存语义、final 的内存语义做了增强

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.