【Java并发编程 二】JMM内存模型(三)

简介: 【Java并发编程 二】JMM内存模型

重排序原则

单线程重排序中遵守as-if-serial语义,也就是单线程的重排序是被允许的,但是要求执行结果不能被影响,据此反推,对于存在数据依赖性的操作不能重排,在多线程中这种重排原则会被打破。

数据依赖性

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

上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。前面提到过,编译器和处理器可能会对操作做重排序。

  • 单线程或单处理器下,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
  • 多处理器之间或多线程之间的数据依赖性不被编译器和处理器考虑

那么在并发编程中,数据依赖性其实得不到满足。

as-if-serial语义

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

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C

执行指令的依赖关系如下:

A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序

多线程重排序问题

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?答案是不一定。

class ReorderExample {
     int a = 0;
     boolean flag = false;
     public void writer() {
         a = 1; // 1
         flag = true; // 2
     }
     public void reader() {
        if (flag) { // 3
         int i = a * a; // 4
      }
   }

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序,假设操作1和操作2进行了重排序,因为没有读取到a的赋值,那么最终结果是i=0

当操作3和操作4重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)

在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中。

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

先行发生原则

先行发生是Java内存模型中定义的两项操作之间的偏序关系。JMM模型中的一个重点原则——先行发生原则(Happens-Before),使用这个原则作为依据,来指导你判断是否存在线程安全和竞争问题。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

Happens-Before的语义

两个操作之间具有 happens-before 关系, 并不意味着前一个操作必须要在后一个操作之前执行,happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照,happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事

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

程序员可以依据Happens-Before原则来进行多线程正确同步下的执行顺序推演。

八个原则

关于顺序执行的三个原则

  • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。(同一个线程中前面的所有写操作对后面的操作可见)
  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
  • 对象终结规则(Finalizer Rule)一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

关于锁定与读写的两个原则

  • 管程锁定规则(Monitor Lock Rule)一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。(如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程))
  • volatile变量规则(Volatile Variable Rule)对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。(如果线程1写入了volatile变量v(临界资源),接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程))

关于线程的三个原则

  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。(假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。注意:线程B启动之后,线程A在对变量修改线程B未必可见。)
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。(线程t1写入的所有变量,在任意其它线程t2调用t1.join(),或者t1.isAlive() 成功返回后,都对t2可见。)
  • 线程中断规则(Thread Interruption Rule)对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。(线程t1写入的所有变量,调用Thread.interrupt(),被打断的线程t2,可以看到t1的全部操作)(A h-b B , B h-b C 那么可以得到 A h-b C)

以上先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从以上规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

先行发生示例

一个操作时间上的先发生不代表这个操作会是先行发生

private int value = 0;
public void setValue(int value){
    this.value = value;
}
public int getValue(){
    return value;
}

假设存在线程A和B,线程A先调用setValue(1),然后线程B调用了同一个对象getValue(),那么线程B返回的值是什么?分析:

  • 两个线程调用,不在一个线程中,所以程序次序规则不适用
  • 没有同步块—管程锁定规则不适用
  • value没有被volatile修饰,所以volatile变量规则不适用
  • 线程启动、终止、中断规则和对象终结规则也扯不上关系

所以尽管在时间上A先于B,但无法确定B中getValue()返回值结果,因此我们说这里的操作是线程不安全的。如何修复呢?可以为set、get方法定义为synchronized方法,这样可以使用管程锁定规则;或者把value设定为volatile变量,由于set方法对value的修改不依赖value的原值,满足volatile关键字使用场景。

那如果一个操作先行发生是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的,一个典型的例子就是接下来要提到的“指令重排序”。所以一个操作“先行发生”不代表这个操作会在时间上先发生

时间先后顺序与先行发生原则之间基本没有太大的关系

顺序一致性模型

如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。这对于程序员来说是一个极强的保证。这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用

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

而且前提是即使在多线程的情况下。

可见性保证

假设这两个线程使用监视器锁来正确同步:A线程的3个操作执行后释放监视器锁,随后B线程获取同一个监视器锁。那么程序在顺序一致性模型为:

如果两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图如下:

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1→A1→A2→B2→A3→B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见

JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致

有序性保证

对前面的示例程序用锁来同步,看看正确同步的程序如何具有顺序一致性

class ReorderExample {
     int a = 0;
     boolean flag = false;
     public synchronized void writer() {
         a = 1; // 1
         flag = true; // 2
     }
     public synchronized void reader() {
        if (flag) { // 3
         int i = a * a; // 4
      }
   }

顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果

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

未同步JMM问题

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM需要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响

未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异。

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

我们在前面的主内存与工作内存中提到,在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性

JMM实现三大特性

我们前文提到了并发编程中的三大特性,并发编程才是安全的,原子性、可见性与有序性,也探讨了顺序一致性模型是我们在并发编程中的理想模型,那么JMM模型需要补足或者使用哪些特殊机制来满足顺序一致性模型呢?

  • 原子性(Atomicity),Java内存模型来直接保证的原子性变量操作包括read、load、use、assign、storewrite这六个,我们可以大致的认为基本数据类型的访问读写是具备原子性的(64位的long和64位的double除外)。
  • synchronized关键字,Java内存模型还提供了lockunlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性
  • 原子类进行的CAS操作也能满足原子性。
  • 可见性(Visibility),可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,有以下三种方式满足可见性:
  • volatile关键字,volatile修饰的共享变量保证新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
  • synchronized关键字,可见性是由对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store、write操作)中这条规则获得的
  • final关键字,可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。
  • 有序性(Ordering),Java程序的天然有序性:在本线程内观察,所有操作都是有序的;在一个线程中观察另外一个线程,所有操作都是无序的。(前半句是指线程内表现为串行的语义,后半句是指令重排序现象和工作内存与主内存同步延迟现象)。有以下三种满足有序性
  • volatile关键字volatile本身就包含禁止指令重排序的语义来保证有序性,使用内存屏障,即重排序的指令不能放到内存屏障之前。
  • synchronized关键字,因为一个变量在同一时刻只允许一条线程对其进行lock操作。这个规则决定了持有同一个锁的两个同步块只能串行的进入,指令在临界区内可以重排,但不会影响最终执行结果
  • 先行发生原则(Happens-Before),如果Java内存模型中所有的有序性都只靠volatile和synchronized来完成,那么有一些操作将会变得很繁琐,在JMM中,如果一个操作执行的结果需要对另一个操作可见,这两个操作需要有一定的执行顺序,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间

Java在底层使用volatile关键字和synchronized关键字来实现这三个特性,之后的高级并发包,也都是基于这两个关键字进行的扩展。下一篇Blog我们来详细探讨这两个关键字。

总结

本篇Blog首先了解了处理器的内存模型,进而推演出JMM内存模型并且提出了并发编程需要满足的三大特性,接着了解了执行的先行发生原则(满足可见性和有序性的原则),接着介绍了多线程编程中重排指令导致的问题对有序性、可见性的破坏的一些示例,在了解了理想的顺序一致性模型是什么样的之后,最终给出了JMM如何实现理想顺序一致性模型功能需要的底层语义和关键字来满足三大特性,为后文的并发机制底层关键字实现做好伏笔

相关文章
|
3天前
|
程序员 编译器 C++
【C++核心】C++内存分区模型分析
这篇文章详细解释了C++程序执行时内存的四个区域:代码区、全局区、栈区和堆区,以及如何在这些区域中分配和释放内存。
14 2
|
3天前
|
Java
死磕-java并发编程技术(二)
死磕-java并发编程技术(二)
|
3天前
|
存储 Java 调度
死磕-java并发编程技术(一)
死磕-java并发编程技术(一)
|
3天前
|
设计模式 缓存 Java
死磕-高效的Java编程(一)
死磕-高效的Java编程(一)
|
4天前
|
存储 Java
Java编程中的对象和类
【8月更文挑战第55天】在Java的世界中,“对象”与“类”是构建一切的基础。就像乐高积木一样,类定义了形状和结构,而对象则是根据这些设计拼装出来的具体作品。本篇文章将通过一个简单的例子,展示如何从零开始创建一个类,并利用它来制作我们的第一个Java对象。准备好让你的编程之旅起飞了吗?让我们一起来探索这个神奇的过程!
24 10
|
5天前
|
Java API 容器
JAVA并发编程系列(10)Condition条件队列-并发协作者
本文通过一线大厂面试真题,模拟消费者-生产者的场景,通过简洁的代码演示,帮助读者快速理解并复用。文章还详细解释了Condition与Object.wait()、notify()的区别,并探讨了Condition的核心原理及其实现机制。
|
5天前
|
Java
JAVA并发编程系列(9)CyclicBarrier循环屏障原理分析
本文介绍了拼多多面试中的模拟拼团问题,通过使用 `CyclicBarrier` 实现了多人拼团成功后提交订单并支付的功能。与之前的 `CountDownLatch` 方法不同,`CyclicBarrier` 能够确保所有线程到达屏障点后继续执行,并且屏障可重复使用。文章详细解析了 `CyclicBarrier` 的核心原理及使用方法,并通过代码示例展示了其工作流程。最后,文章还提供了 `CyclicBarrier` 的源码分析,帮助读者深入理解其实现机制。
|
5天前
|
设计模式 安全 Java
Java 编程中的设计模式:单例模式的深度解析
【9月更文挑战第22天】在Java的世界里,单例模式就像是一位老练的舞者,轻盈地穿梭在对象创建的舞台上。它确保了一个类仅有一个实例,并提供全局访问点。这不仅仅是代码优雅的体现,更是资源管理的高手。我们将一起探索单例模式的奥秘,从基础实现到高级应用,再到它与现代Java版本的舞蹈,让我们揭开单例模式的面纱,一探究竟。
22 11
|
2天前
|
Java 开发者
深入探索Java中的并发编程
本文将带你领略Java并发编程的奥秘,揭示其背后的原理与实践。通过深入浅出的解释和实例,我们将探讨Java内存模型、线程间通信以及常见并发工具的使用方法。无论是初学者还是有一定经验的开发者,都能从中获得启发和实用的技巧。让我们一起开启这场并发编程的奇妙之旅吧!
|
3天前
|
算法 安全 Java
JAVA并发编程系列(12)ThreadLocal就是这么简单|建议收藏
很多人都以为TreadLocal很难很深奥,尤其被问到ThreadLocal数据结构、以及如何发生的内存泄漏问题,候选人容易谈虎色变。 日常大家用这个的很少,甚至很多近10年资深研发人员,都没有用过ThreadLocal。本文由浅入深、并且才有通俗易懂方式全面分析ThreadLocal的应用场景、数据结构、内存泄漏问题。降低大家学习啃骨头的心理压力,希望可以帮助大家彻底掌握并应用这个核心技术到工作当中。

热门文章

最新文章