java并发编程的艺术笔记2之Java内存模型

目录

 

二.Java内存模型

1.Java内存模型的基础

1.1并发编程模型的两个关键问题

1.2Java内存模型的抽象结构

1.3 从源代码到指令序列的重排序(为了提高性能)

1.4 并发编程模型的分类

1.5 happens-before (:一个操作的结果需要对另一个操作可见,可在不同线程)

2.重排序

3.顺序一致性

3.1顺序一致性内存模型的两大特性

3.2 顺序一致性内存模型

3.3 同步程序的顺序一致性执行结果

3.4 vloatile的内存语义

3.5锁的内存语义

3.6 final域的内存语义

3.7 happens-before

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

3.9 Java内存模型综述

 

 


二.Java内存模型

1.Java内存模型的基础

1.1并发编程模型的两个关键问题

  • 线程之间如何通信(以何种机制来交换信息)。有两种通信机制:共享内存和消息传递。共享内存的并发模型通过写-读内存中的公共状态进行隐式通信。在消息传递的并发模型中,线程之间无公共状态,通过发送消息进行显式通信,
  • 如何处理线程同步问题(程序用于控制不同线程之间操作发生相对顺序的机制)共享内存模型中,同步显式进行。消息传递模型中,因为必须先发后接,同步隐式进行。

Java采用共享内存模型,线程之间的通信隐式进行,同步显式进行。

1.2Java内存模型的抽象结构

  • 实例域,静态域和数组元素在堆内存中,堆内存在线程之间共享,也就是共享变量。
  • Java之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见,为程序员提供内存可见性的保证。线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(本地内存是一个抽象概念,涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化),本地内存中存储了该线程以/写共享变量的副本
  • 通信流程(这两步骤实质上就是A向B发送消息):

(1)线程A把本地内存A中更新过的共享变量更新到主内存中。

(2)线程B去主内存中读取A已经更新过的共享变量。

1.3 从源代码到指令序列的重排序(为了提高性能)

从代码到实际执行的指令序列会经过3层重排序:编译器优化的重排序(不改变单线程程序语义)、指令级的并行排序(多条指令并行执行)、内存系统重排序(处理器的缓存和读写缓冲区会导致加载和存储看起来是在乱序执行)。

重排序后可能会出现内存可见性问题,解决方法:对于编译器,JMM会禁止特定类型的编译器重排序;堆区处理器重排序(后两个)JMM会要求在生成指令序列时,插入特地类型的内存屏障指令(第一章介绍了)来禁止特定的处理器重排序。

      JMM属于语言级的内存模型,它确保在不同的编译器和处理器上,通过禁止特定类型的重排序,为程序员提供一致的内存可见性保证。

1.4 并发编程模型的分类

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

由于写缓冲区仅对自己的处理器可见,会导致处理器执行内存操作的顺序与内存实际操作执行顺序不一样。

知晓这里有处理器的重排序规则,内存屏障类型表

1.5 happens-before (:一个操作的结果需要对另一个操作可见,可在不同线程)

注意:仅仅是结果可见,并不是要求一操作必须在另一操作后执行

1) 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

2) 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

3) volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

4) 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

5) start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

6) join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

7)线程终止规则

8)对象终结规则

一个happens-before重排序规则对应于多个处理器和编译器的重排序规则。

2.重排序

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

  • 数据依赖性(带有写操作的两个操作访问同一个变量,这两个操作之间具有数据依赖性)不会进行重排序  写读,写写,读写。这里的数据依赖性仅针对单个处理器中的指令序列和和单个线程中执行的操作。
  • as-if-serial语义:不管怎么重排序,单线程的执行结果不能被改变。遵循as-if-serial语义的编译器、runtime和处理器共同创造了一个幻觉:单线程程序时按照程序的顺序来执行的。所以遵循该语义的处理器,不会重排序有数据依赖性的操作。
  • 重排序对多线程的影响:存在控制依赖关系的操作,会影响指令序列执行的并行度,会采用猜测执行来克服控制相关性对并行度的影响。

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

重排序分为编译器重排序和处理器重排序。

3.顺序一致性

顺序一致性模型是一个理论参考模型,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参考

数据竞争(未正确同步)与(如果正确同步)顺序一致性。

3.1顺序一致性内存模型的两大特性

(1)线程的所有操作都必须按照程序的顺序执行

(2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。每个操作原子执行且立刻对所有线程可见。

但JMM中没有立刻对所有线程可见的保证。所以不但整体的执行顺序是无序的而且所有线程看到的操作执行顺序也不一致。临界区内的操作可以重排序。

3.2 顺序一致性内存模型

两大特性:

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

3.3 同步程序的顺序一致性执行结果

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

3.4 vloatile的内存语义

理解:把对volatile变量的单个读写,看成是使用用一个锁对单个读写操作做了同步。

可见性:对一个volatile变量的读,总能看到任意线程对这个变量的最后写入

原子性:对任意单个volatile变量的读写具有原子性,但类似于volatile++这种复合操作不具有原子性。

volatile写-读建立了happens-before关系,volatile写读与锁的释放获取具有相同的内存效果/语义。

volatile写的内存语义:JMM会把线程对应的本地内存中的共享变量值刷新到主内存。

volatile读的内存语义:JMM会把该线程对应的内地内存设置无效,从主内存中读取共享变量。

写读过程实质上是线程A通过主内存向线程B发送消息

要求:

总结:第一个操作是volatile读时,第二个操作都不能重排序。第一个操作是普通操作时,只有volatile不能重排序;第一个操作是volatile写时,volatile读写都不能重排序。

JMM基于保守策略插入内存屏障:

  • 在每个volatile写操作的前加入一个StoreStore屏障
  • 在每个volatile写操作的前加入一个StoreLoad屏障
  • 在每个volatile读操作的前加入一个LoadLoad屏障
  • 在每个volatile读操作的前加入一个LoadStore屏障

JMM实现特点:首先保证正确性,在追求提升效率。

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

3.5锁的内存语义

(与volatile的一样)

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

公平锁在获取锁时首先读这个volatile变量,在释放锁的最后写volatile变量。

非公平锁在获取锁时先用CAS更新volatile变量,,这个操作同时具有volatile读写的内存语义。在释放锁的最后写volatile变量。

Java的CAS附带了volatile读和volatile写的内存语义。

3.6 final域的内存语义

两个重排序规则:

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用对象,这两个操作之间不能重排序
  2. 初次读一个final对象的引用与随后初次读这个final域,这两个操作之间不能重排序。(仅仅针对处理器)

写final域的重排序规则:

  1. JMM禁止编译器把final域的写重排序到构造函数之外
  2. 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障,以达到目的1.

需要一个保证:在构造函数内部,不能让这个被构造函数的对象的引用为其他线程可见,即对象引用不能在构造函数中‘逸出’。

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

读final域的重排序规则:

  1. 初次读一个final对象的引用与随后初次读这个final域,这两个操作之间不能重排序。
  2. 编译器会在读final域操作的前面加上一个LoadLoad屏障。

这两个读操作存在间接依赖关系,大多数处理器也会遵循间接依赖原则,不会重排序这两个操作。这个操作主要是针对允许重排序间接依赖关系的处理器(alpha处理器)。

如果final域为引用类型:

写final域的重排序规则对编译器和处理器增加了如下约束:

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

3.7 happens-before

as-if-serial语义和happens-before本质上一致。 

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

在java多线程程序中,又是需要采用延迟初始化类和创建对象开销。双重检查是常见的延迟初始化技术,但它是一个错误的用法。you'you'li

有两种解决办法:

  1. 不允许重排序 
    1. 对变量添加volatile
  2. 允许重排序,但不允许其他线程‘看到’这个重排序

Java初始化一个类或接口的处理过程:

  1. 通过在class对象上同步(即获取初始化锁)来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。线程Astate=initializing
  2. 线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。
    1. 类的初始化时可以重排序,但线程B看不到
  3. 线程A设置state=initialized,然后唤醒在condition中等待的线程。
  4. 线程B结束类的初始化处理
  5. 线程C执行类的初始化处理

基于类的初始化方案的代码更简洁,但基于volatile的双重检查锁定都有一个额外的好处:除了对静态字段实现延迟初始化以外,还可以对实例字段实现延迟初始化。 

字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程安全的延迟初始化,使用基于volatile的延迟初始化方案;如果需要对静态字段使用线程安全的延迟初始化,使用基于类初始化的方案。

3.9 Java内存模型综述

内存模型的名称放松的操作Store-Load重排序Store-Store重排序Load-Load和Load-Store重排序可以更早读取到其他处理器的写可以更早读取到当前处理器的写
TSO写-读Y   Y
PSO写-写YY  Y
RMO读-写YYY Y
PowerPC读-读YYYYY

 从上到下,模型由强变弱,越是追求性能的处理器,内存模型设计的就越弱。

 

 

 

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值