Home Tags Posts tagged with "重排序"

重排序

JMM知识梳理图

一、基础概念

1.并发编程模型的两个问题

  • a.线程通信
  • b.线程同步

2.Java内存模型的抽象结构

  • a.什么是JMM
    • 解释:JMM决定一个线程对共享变量的写入何时对另一个线程可见;JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本,并不真实存在
  • b.JMM做什么
    • 回答:JMM通过控制主内存和每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证

二、上层规则

1.happens-before

  • 作用:简单易懂,避免Java程序员为了理解JMM提高的内存可见性保证而去学习复杂的重排序规则和这些规则的具体实现方法

2.volatile

  • 三大特性
    • a.可见性
    • b.原子性(单个变量具有)
    • c.有序性
  • volatile写-读建立的happens-before关系
  • volatile写-读的内存语义(与锁的释放-获取相同)
    • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存
    • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
  • volatile内存语义的实现
    • 由JMM分别限制编译器重排序和处理器重排序
      • 实现方式:插入内存屏障

三、下层实现

JMM内存模型层面所做的事情

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

编译器和处理器层面所作的事情

  • 1.编译器和处理器:为提高程序执行性能,对指令做重排序
    • 重排序规则
      • a.数据依赖性
      • b.as-if-serial
      • c.程序顺序规则
    • 重排序分类
      • a.编译器优化的重排序
      • b.指令级并行的重排序
      • c.内存系统的重排序
    • 重排序的影响
      • 单线程下无影响
      • 多线程下可能会改变程序的执行结果
  • 2.编译器:在生成指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序
    • a.LoadLoad
    • b.StoreStore
    • c.LoadStore
    • b.StoreLoad

 

切入点:说说volatile关键字吧?

好的,首先volatile关键字具有三大特性,即 可见性,原子性,有序性;

可见性,指的是该关键字修饰的对象在多线程情况下任何一个线程对其的更改都及时的对其他线程可见,它实现的原理,就得从Java的内存模型—JMM说起,JMM的作用是在不同的平台上为Java程序提供一致的内存可见性,之所以能做到这点,是因为其 通过控制 主内存和每个线程的本地内存之间的交互,具体来说 就是其通过禁止特定类型的编译器重排序和处理器重排序来提供内存可见性的,这里就谈到了编译器和处理器;为了提高程序执行性能,编译器和处理器会对指令做重排序,这也就是为了多线程情况下程序执行会出问题的根源,而单线程下由于重排序遵守as-if-serial规则,保证了不会出错;

那么回到前面。禁止特定类型的编译器重排序和处理器重排序是怎么做到的呢?那就是通过编译器在生成指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序而做到的。

那么原子性是怎么做到的呢?这是因为volatile写-读的内存语义和锁的释放—获取相同

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

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

有序性,通过由JMM分别限制编译器重排序和处理器重排序实现,前面已经说了是通过插入内存屏障的方式


3.1 Java内存模型的基础

3.1.1 并发编程模型的两个关键问题(线程通信、线程同步)

线程之间如何通信线程之间如何同步(这里的线程是指并发执行的活动实体)。

线程通信

通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的信机制有两种:共享内存消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态通过写-读内存中的公共状态进行隐式通信。

在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

线程同步

同步是指程序中用于控制不同线程间操作发生相对顺序的机制

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

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

 

3.1.2 Java内存模型的抽象结构

共享变量:实例域,静态域,数组元素

局部变量、方法定义参数、异常处理器参数不会在线程间共享,那么它们不会有内存可见性问题,也不受内存模型的影响。

什么是JMM?

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享 变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽 象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(LocalMemory)本地内存中存储了该线程以读/写共享变量的副本本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化

线程通信步骤:

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

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

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

 

3.1.3 从源代码到指令序列的重排序

目的:执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型。

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
编译器优化的重排序 编译器在不改变单线程程序语义的前提下(代码中不包含synchronized关键字),可以重新安排语句的执行顺序。
指令级并行的重排序 现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

重排序又可以分为两大类,一是编译器重排序:编译器优化重排序,二是处理器重排序:指令级并行重排序、内存系统重排序。

1)编译器优化的重排序

2)指令级并行的重排序(处理器)

3)内存系统的重排序(处理器)

 

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

 

3.1.4 并发编程模型的分类

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁 止特定类型的处理器重排序。JMM把内存屏障指令分为4类,如表3-3所示。

 

 

3.1.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。(先于传递性)

先于条件注意点:

1.两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个 操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一 个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

2.happens-before规则简单易懂,相当于是JAVA为了方便程序员理解而设计的规则,其实现通过JMM层面的重排序规则和实现方法。也就是说 happens-before是上层规则!!!

 

3.2 重排序

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

3.2.1 数据依赖性

如果两个操作访问同一变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性

上述三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。(也就是说编译器和处理器所遵守的数据依赖性在并发情况下,无法保证程序正确执行)

 

3.2.2 as-if-serial语义

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

为了遵守该语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但,如果操作之间不存在数据依赖关系,则这些操作就可能被编译器和处理器重排序。

as-if-serial语义使单线程程序员无需担心会被重排序干扰,也无需担心内存可见性问题

 

3.2.3 程序顺序规则

JMM仅仅要求前一个 操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A 的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B 按happens-before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法(not illegal),JMM允许这种重排序。

即如果重排序不影响结果,则可以进行重排序!!!

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下, 尽可能提高并行度。编译器和处理器遵从这一目标,从happens-before的定义我们可以看出, JMM同样遵从这一目标。

 

3.2.4 重排序对多线程的影响

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

 

3.3 顺序一致性

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

3.3.1 数据竞争与顺序一致性(正确同步则无数据竞争)

程序未正确同步,就可能会存在数据竞争。Java内存模型规范对数据竞争的定义如下:

在一个线程中写一个变量,
在另一个线程读同一个变量,
而且写和读没有通过同步来排序。
当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(前一章的示例正是如此)。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

JMM对正确同步的多线程程序的内存一致性做了如下保证:

如果程序正确同步程序的执行将具有顺序一致性—即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。此正确同步指广义上的同步,包括对常用同步原语的正确使用。

 

3.3.2 顺序一致性内存模型

顺序一致性模型为程序员提供了极强的内存可见性保证,顺序一致性内存模型有两大特性:

1)一个线程中的所有操作必须按照程序的顺序来执行

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

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关 可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。从上面的示意图可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发 执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。

JMM中无此保证!!!

 

正确同步的程序达到顺序一致性效果

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

 

3.3.4 未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的 值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取 到的值不会无中生有(Out Of Thin Air)的冒出来。为了实现最小安全性,JVM在堆上分配对象 时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两操作)。因 此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了。

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。

未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。

 

3.4 volatile的内存语义

3.4.1 volatile的特性

理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这 些单个读/写操作做了同步。

锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意现场)对这个volatile变量最后的写入

锁的语义决定了临界区代码的执行具有原子性,即64位的long和double变量,只要是volatile变量,对其读/写也有原子性。

volatile的特性:

1.可见性

2.原子性(单个变量具有)

3.有序性

 

3.4.2 volatile写-读建立的happens-before关系

JDK5开始,volatile变量的写-读可以实现线程之间的通信

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和 锁的释放有相同的内存语义volatile读与锁的获取有相同的内存语义。

 

3.4.3 volatile写-读的内存语义

volatile写:

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

volatile读:

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

总结

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

 

3.4.4 volatile内存语义的实现

JMM为实现volatile写/读的内存语义,JMM会分别限制编译器重排序和处理器重排序

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

·在每个volatile写操作的前面插入一个StoreStore屏障。
·在每个volatile写操作的后面插入一个StoreLoad屏障。
·在每个volatile读操作的前面插入一个LoadLoad屏障。
·在每个volatile读操作的后面插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能 得到正确的volatile内存语义

 

3.4.5 JSR-133为什么要增强volatile的内存语义

在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内 存模型允许volatile变量与普通变量重排序。在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类 似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。
因此,在旧的内存模型中,volatile的写-读没有锁的释放-获所具有的内存语义。为了提供 一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格 限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获 取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile 变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

volatile和锁对比:

volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大在可伸缩性和执行性能上,volatile更有优势如果我们希望在程序中使用volatile代替锁,请一定谨慎