读书笔记之浅析Java内存模型

并发模型分类

在并发编程中,最重要的是两个部分:线程通信和线程同步。
常见的线程通信机制有两种:共享内存和消息传递。

  • 共享内存 在共享内存的并发模型中,线程之间通过对程序的公共状态进行共享,线程之间通过对内存中共享状态进行读写来进行隐式通信。
  • 消息传递 在消息传递的并发模型中,线程与线程之间没有共享的公共状态,只能通过显性地发送消息来进行通信。

线程同步是指对线程与线程之间操作的发生的相对顺序进行控制的机制,在共享内存的并发模型中,进行同步是显性,必须通过指定某个方法或者代码块(即临界区)需要在线程之间进行互斥访问。而在消息传递的并发模型中,由于消息的发送必须是在消息的接收之前发生的,同步是隐式进行的。

Java使用的并发模型是共享内存模型,线程与线程之间的通信是在隐式进行的。

Java内存模型抽象

在Java中,程序计数器、虚拟机栈和本地方法栈是线程私有的,不会在线程间进行共享,因此不会存在内存可见性问题,也不会受到内存模型的影响,而堆区和方法区则是线程公有的,对象的实例域和静态域都在存储在堆区,会在线程之间进行共享,即可称为共享变量。
Java线程之间的通信是由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的读写什么时候对另一个线程可见,定义了线程与主内存之间的抽象关系,线程之间的共享变量是存储在主内存当中,而每一个线程都会有自己的工作内存,线程对共享变量的读写是发生在工作内存当中,工作内存存储着该线程用来读写共享变量的副本。然而工作内存实际上是不存在的,它是缓存、缓冲区、寄存器的一个抽象存在。


举个例子,如果两个线程需要进行通信,目前有线程A和工作内存A、线程B和工作内存B、主内存,线程A要与线程B进行通信的话,首先线程A要将工作内存A中更新过的共享变量刷新到主内存中去,然后线程B到主内存中去读取线程A已经更新过的共享变量,对线程B的工作内存的共享变量进行更新,这样看来的话,线程A向线程B进行通信,必须要经过主内存,JMM进通过控制主内存与每一个线程的工作工作内存进行交互,来为Java程序提供了内存可见性保证。

重排序

在程序执行的时候,为了提高性能,编译器和处理器往往会对指令重排序,重排序主要分为以下三种:

  • 编译器优化的重排序,在不改变单线程程序的as-if-serial语义的情况下,会对指令的执行顺序进行重排序。
  • 指令级并行的重排序 如果指令之间不存在数据依赖性的话,现代处理器将会采用指令级并行技术将多条指令重叠执行,改变语句对应的机器指令的执行顺序。
  • 内存系统的重排序 由于处理器是用缓存行和读/写缓冲区,让加载和存储操作看上去可能是在乱序执行。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序,对于处理器重排序,JMM的处理器重排序规则会要求在编译器生成指令序列的时候,插入特定类型的内存屏障,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM确保不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,保证一致的内存可见性。

数据依赖性

如果两个操作对同一个变量进行访问,而且其中一个操作为写操作,就称这两个操作之间存在这数据依赖性。
数据依赖主要分为三种:

  • 写后读 在写一个变量之后,再去读这个变量
  • 写后写 在写一个变量之后,再去写这个变量
  • 读后写 在读一个变量之后,再去写这个变量

在以上三种情况下,如果操作的指令发生了重排序,操作的执行结果就会被改变。
编译器和处理器都可能会对操作进行重排序,不过编译器和处理器的重排序会遵循数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序。(此处的数据依赖性是在单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖不会被处理器和编译器考虑)

as-if-serial语义

as-if-serial语义的意思指:无论如何进行重排序(编译器和处理器为了提供并行度),单线程程序的执行结果是不会发生改变。编译器、Runtime和处理器都必须遵循as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器都不会对存在数据依赖关系的操作做重排序,因为这种重排序会对执行结果做出改变,但是如果不存在依赖关系的话,编译器和处理器可能会对操作做重排序。
as-if-serial语义是单线程程序看起来是按照程序的顺序来执行的,让单线程程序无需担心重排序和内存可见性问题。

内存屏障

为了减少内存访问带来的延迟,现代的处理器一般使用写缓冲区来临时保存向内存写入的数据,写缓冲区可以保证指令流水线持续运行,避免由于处理器停顿等待向内存写入数据而产生的延迟,同时可以通过批处理的方式刷新写缓冲区,以及合并写缓冲区对同一内存地址的多次写,可以减少对内存总线的占用,不过写缓冲区只对它所在缓冲区可见,这会导致处理器对内存的读/写操作的执行顺序,不一定会与内存实际发生的读/写操作顺序一致(读写操作重排序)

为了保证内存可见性,Java编译器会在生成的指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,JMM把内存屏障分为以下四类:

屏障类型 指令实例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1数据的装载,之前于Load2以及所有后续装载指令的装载
StoreStore Barriers Store1;StoreStore;Store2 确保在Store1数据对其他处理器可见,之前于Store2以及所有后续存储指令的存储
LoadStore Barriers Load1;LoadStore;Store2 确保Load1数据装载,之前于Store2以所有后续的存储指令刷新到内存
StoreLoad Barriers Store1;StoreLoad;Load2 确保Store1数据对其他处理器可见,之前于Load2以及所有后续装载指令的装载,StoreLoad Barriers会使该屏障之前的所有内存访问指令完成之后,才执行该屏障之后的内存访问指令

Happens-before原则

happens-before原则是指如果两个操作之间存在happens-before关系,那么第一个操作执行的结果会对第二个操作可见,这两个操作可以是在同一个线程内,也可以是在不同线程当中。

  • 程序顺序原则:在一个线程中的每个操作,happens-before与该线程中的任意后续操作。
  • 监视器锁原则 对一个监视器的解锁,happens-before与随后对这个监视器的加锁
  • volatile变量原则 对一个volatile域的写,happens-before与任意后续对这个volatile域的读。
  • 传递性:如果A操作happens-before B,且B happens-before C,那么A happens-before C.

顺序一致性模型 

如果程序为正确进行同步,就可能会存在数据竞争,Java内存模型对数据竞争做出以下定义:

  • 在一个线程中写一个变量
  • 在另一个线程读同一个变量
  • 读写没有通过同步进行排序

当代吗中存在数据竞争的时候,程序的执行结果可能会出现意想不到的结果。
当一个多线程程序正确进行同步,这个程序将会是一个没有数据竞争的程序。

JMM对正确同步的多线程程序做出了以下的保证:
如果程序是正确同步(广义同步,包括常用的同步原语final、volatile和final)的,程序的执行将具有顺序一致性,即程序的执行结果与在顺序一致性中的执行结果相同,

顺序一致性模型

顺序一致性内存模型提供了极强的内存可见性保证,主要是保证两大特性:

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

在顺序一致性模型中有一个单一的全局内存,这个内存通过与任意一个线程连接,同时每一个线程都必须要按照程序的顺序来执行内存读写操作,由于在任意一个时间点,最多只能有一个线程可以连接到内存,当多个线程并发执行时,可以实现把所有线程的所有内存读/写操作串行化。如果未同步程序在顺序一致性模型中虽然整体执行顺序无序,但是所有线程都只能看到一个一致的整体执行顺序,因为顺序一致性模型中的每个操作都必须立即对任意线程可见。

但是在JMM中就无法保证,未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致.

如果程序正确实现了同步,根据JMM规范,该程序的执行结果在与顺序一致性模型中的执行结果相同。

在顺序一致性模型中,所有的操作完成按照程序的顺序串行执行,而在JMM中,临界区内的代理是可以重排序的,JMM会在退出临界区和进入临界区的时候进行处理使其能与顺序一致性模型相同的内存视图,由于临界区互斥访问的特性,其他线程无法访问当前线程在临界区中执行操作的重排序,这种重排序提供了执行效率而又没有改变程序的执行结果。

如果程序没有正确实现重排序,JMM只能提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要是是默认值,保证变量读取到的值是不会无中生有的,为了保证最小安全性,JMM在堆上分配对象的时候,首先会清零内存空间,然后才会在上面分配对象(同步操作),即在已清零的内存空间分配对象时,域的默认初始化已经完成了。

未同步程序在JMM中的执行时候,整体是无序的,执行结果也是无法预知的,跟顺序一直性模型有以下差异。

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行。
  • 顺序一致性模型保证所有的线程之恩那个看到一致的操作执行顺序,而JMM不保证所有线程看到一致的操作执行顺序。
  • JMM不保证对64为的long型和double型变量的读写操作具有原子性,而顺序一致性模型保证对所有的内存读写操作具有原子性。

volite

使用volatile变量的单个读写操作,与对一个普通变量的读写操作使用同一个锁进行同步的执行结果是相同的,
锁的happens-before规则保证了释放锁和获取锁的两个线程之间的内存可见性,这表明队一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入,同时锁表明了在临界区的代码执行具有原子性,即64位的long和double的volatile变量都具有原子性,不过如果是多个操作的话则不保证原子性。
volatile保证了下列特性:

  • 可见性 对一个volatile变量的读,总是可以看到其他线程对volatile变量的最后的写入。
  • 原子性 在对任意单个volatile变量的读写具有原子性,但对复合操作不具有原子性。

volatile变量的happens before关系 

volatile的读-写与锁的释放-加锁具有相同的内存效果,volatile写和锁的释放具有相同的内存语义,volatile读与锁的获取具有相同的内存语义。

volatile写的内存语义:当写一个volatile变量的时候,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中
volatile读的内存语义:当读一个volatile变量的时候,JMM会把该线程对应的工作内存置为无效,线程接下来将主内存中读取共享变量。
如果有两个线程对volatile变量读写的时候,线程A写一个volatile变量,然后线程B读取这个volatile变量,其实就是线程A通过主内存向线程B发送消息。

volatile内存语义的实现 

重排序主要分为编译器重排序和处理器重排序,为了实现volatile内存语义实现,JMM做出了以下的限制


是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写 Yes Yes No
volatile读 No No No
volatile写 Yes No No

从表中我们可以知道,当第二个操作volatile写操作的时候,无论第一个操作是什么,都不能进行重排序,即保证volatile写之前的操作不会重排序到volatile写之后。
第一个操作volatile读操作的时候,无论第二个操作为什么操作,都不会发生重排序,即保证了volatile读之后的操作不会重排序到volatile读之前去。
还有当第一个操作为volatile写的时候,第二个操作为volatile读操作的时候,这两个操作之间也不会发生重排序。

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

JMM采取了保守策略:

  • 它会在每个volatile写操作的前面插入一个StoreStore屏障,后面插入一个StoreLoad屏障;
  • 在每一个volatile读操作后面插入一个LoadLoad屏障,读操作后面插入一个LoadStore屏障。

volatile写后面是会添加一个StoreLoad屏障,它主要是用来保证volatile写与后面可能会有volatile变量的读写操作重排序,因为编译器无法判断一个volatile写后面是否需要插入一个StoreLoad屏障(volatile写之后方法立即访问),为了保证正确实现volatile的内存语义,JMM在这里采用了保守策略,在每一个volatile写后面或者在每个volatile读的前面都插入一个StoreLoad屏障

False Sharing和Cache Line

Cache Line即缓存行,是指CPU从内存中读取数据时,并不是一次只读一个字节,而是会一次读取64字节长度的连续内存块,而这个连续内存块就是缓存行。
False Sharing即伪共享,是指多个线程修改同一个缓存行的不同变量时,会相互发生影响,需要重新加载缓存行,导致性能降低,因为虽然变量的修改逻辑是相互独立的,但是在同一个缓冲行上的数据是同一维护的,一致性的粗粒度并不是单个元素上。
一个CPU核心在加载缓存行数据的时候可能需要执行上百条执行,当一个核心要等待另一个核心来重新加载缓存行的话,就需要等待,即停止运转(stall),因此我们需要减少伪共享来减少stall的发生。
我们可以通过填充的方法来让原本存储在同一个缓冲行的两个变量,在被并发访问的时候位于不同的缓冲行中。
也可以使用@Contended 对需要避免进行陷入伪共享的字段进行注解,它会提醒JVM将字段放到不同的缓存行中。

CPU缓存结构

cpu

现代CPU缓存结构主要分为3个级别,级别越小的缓存越接近CPU,读取的速度越快,但是容量也越小。
L1 Cache是最接近CPU的缓存,它的容量最小,速度最快,在每一个核中存在着两个L1 Cache,一个是用来存储操作数,一个用存储操作指令。
L2 Cache比L1 Cache大一级,存储的容量更大,不过速度稍慢,一般情况下,每一个核都存在一个独立的L2 Cache。
L3 Cache是三级缓存,级别最大,速度最慢,在同一个CPU插槽之间的核共享一个L3 Cache。

当CPU运作的时候,它首先会去L1 Cache中查找数据,然后到L2 Cache,再到L3 Cache,如果在三级缓存中都没有找到需要的数据,那么就会从CPU中获取数据,查找的路径越长,耗时也越大。

参考文献

InfoQ之深入Java内存模型

What is @Contended and False Sharing

COMMENT AND SHARE

冼毅俊

Xupter√Java√德桌迷√虐心控√mugen爱好者√音乐杂食党√小说发烧友√基本色√鱼迷√stan√bitch√daydreamer


Java研发


长安