深入理解Java虚拟机之Java内存模型(2)

603 阅读21分钟

title: 深入理解Java虚拟机之Java内存模型(2)

date: 2020-07-30 08:54

category: jvm

tags: jvm


前言

感觉周志明老师的书阅读起来确实有些困难,但是《Java并发编程的艺术》阅读起来更加浅显易懂.特此用来辅助学习《深入理解Java虚拟机第三版》.

此文是在学习《Java并发编程的艺术》中的第三章Java内存模型而编写的读书笔记,强烈建议如果需要复习这一块知识的同学阅读此两本书籍.

本文分为几个部分再次学习一下Java内存模型的知识:

  • Java内存模型基础:
    • 内存模型相关的基础概念
  • Java内存模型中的顺序一致性
    • 重排序与顺序一致性内存模型
  • 同步语义的概念
    • 3个同步原语(synchronized、volatile、final)的内存语义,以及重排序规则在处理器中的实现
  • Java内存模型的设计
    • Java内存模型的设计原理,及其与处理器内存模型和顺序一致性内存模型的关系.

Java内存模型基础

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

  • 线程之间如何通信.

  • 线程之间如何同步.

通信概念: 线程之间以何种机制来交换信息.

在命令式编程中,线程之间的通信机制有两种: 共享内存和消息传递

在共享内存的并发模型中,线程之间共享程序的公共状态(共享变量),通过写/读内存中公共状态来进行隐式通信.

在消息传递的并发模型中,线程之间没有公共状态,线程之间通过发送消息来显示进行通信.

同步概念:程序中用于控制不同线程间操作发生相对顺序的机制.

在共享内存并发模型中,同步是显示进行的,咱们必须显示指定某个方法或者某段代码需要在线程之间互斥执行.

在消息传递的并发模型里,由于消息的发送必须在消息接收之前,因此同步是隐式

Java的并发采用的是共享内存模型,所以Java线程之间通信总是隐式进行,整个通信过程对我们是透明的.我们需要理解这种隐式进行的线程之间通信的工作机制,不然会遇到各种奇怪的内存可见性问题

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

在Java中,所有的实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享. 局部变量、方法定义参数和异常处理器参数不会在线程之间共享 、因此他们不会有内存可见性问题,也不会受内存模型影响.

Java线程之间的通信由Java内存模型(JMM)控制.JMM决定一个线程对共享变量的写入操作何时对另一个线程可见.

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

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

从上图可以看出,如果线程A与线程B之间要通信的话,必须经历两个步骤

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

上图表示线程之间的通信示意图.

1、本地内存A和本地内存B由主内存中共享变量x的副本.

2、假设工作内存A、B和主内存中的x值都为0.

3、线程A执行,把更新后的x值(1)临时存放在自己的本地内存A中.

4、当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改的x值刷新到主内存中,此时主内存中的x值变为1.

5、线程B到主内存中去读取线程A更新的x值,此时线程B的本地内存x也为1.

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

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

在执行程序时,为了提供性能,编译器和处理器常常会对指令进行重排序,重排序分3种类型

  • 编译器优化的重排序.编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序.(编译器级别)

  • 指令级并行的重排序.现在处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序.(处理器级别)

  • 内存系统的重排序.由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行.

从Java源代码到最终实际执行的指令序列,经历下面三种冲排序.

1属于编译器重排序,2和3属于处理器重排序.这些重排序可能会导致多线程出现内存可见性问题.

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序.

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

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

4、happens-before简介

从JDK5开始,JAVA使用新的JSR-133内存模型的概念来阐述操作之间的可见性,在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系.

这里提到的两个操作可以是在一个线程之内,也可以是不同线程之间. 具体如下:

  • 程序顺序规则: 线程的操作发生在对这个后续操作之前.

  • 监视器规则: 解锁发生在加锁之前

  • volatile变量规则: 对于volatile的写发生在读之前.

  • 传递性: 如果A发生在B之前,且B发生在C之前,那么A发生在C之前.

注意:两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行,happens-before仅仅要求前一个操作(执行的结果)对于后一个操作可见.且前一个操作按顺序排在第二个操作之前,也就是他们之间是顺序的,但是不是连续的,可以在这两者之间插入其他操作.

总结:

一个happens-before规则对应着一个或者多个编译器和处理器的重排序规则.

happens-before规则简单易懂.

重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段.下面介绍一下重排序中会遵循的几种重要的概念.

1、数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作(race++,典型的),那么此时这两个操作之间就存在数据依赖性. 下面列出数据依赖的3种类型: 写后读,写后写,读后写》??读后读,这种为啥没有呢,这种没啥意义,不构成依赖性,读一次和读两次有啥区别,写后写就不一样了,因为发生了写,第一次写了,更改了值,第二次又写,更改了值,因此这里认为只有三种的情况是因为数据被某个操作更改了??

上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果都会改变.

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

2、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之间也存在数据依赖关系 但是A和B之间不存在数据依赖关系.下面给出3个操作之间的依赖关系示意图,以及程序被重排序的两种执行顺序:

3、程序顺序规则

根据happens-before的程序顺序规则,上述例子存在3个happens-before关系

  • A happens-before B

  • B happens-before C

  • A happens-before C

这里还是明确一个相当重要的概念

A happens-before B 一定推导出A一定是在B之前执行吗.

答案不是的,JMM仅仅要求A的执行结果对B是可见的,并且A的按顺序执行排在B之前.

分析: 很明显,A和B不产生数据依赖性,直接就破环了上面所说的结论,因此反推A不一定在B之前执行,这个要看编译器和处理器的具体安排,如果编译器和处理器认为A在B之前执行比较满足并发性,OK,A是可以在B之后执行的.

4、重排序对多线程的影响

直接上代码分析:

class RecordExample{

 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
  }
 }


}

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

答案: 不一定能看到.

分析: 由于操作1和操作2没有数据依赖性,编译器和处理器可以对这两个操作重排序,同样的,操作3和操作4没有数据依赖性,编译器和处理器也可以对这两个操作重排序.

1、操作1和操作2进行重排序示意图:

操作1和操作2做了重排序.程序执行时,线程A首先写标记变量flag,随后线程B读这个变量.由于条件判断为真,线程B将读取变量a,此时,变量a还没有被线程A写入,在这里多线程程序的语义就被重排序破坏了.

2、操作3和操作4重排序示意图:

在程序中,操作3和操作4存在控制依赖关系.当代码中存在控制依赖性时,会影响指令序列执行的并行度. 为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响.

以处理器的猜测执行为例子,执行线程B的处理器可以提前读取并计算a*a,然后计算结果临时保存到一个名为重排序缓冲(Recorder Buffer,ROB)的硬件缓存中,当操作3的条件判断为真时,就把该计算结果写入变量i中.

总结: 因为操作3和操作4存在控制依赖关系,因此处理器会采用猜测执行对操作3和操作4进行了重排序.同样的破坏了多线程程序的语义.

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

顺序一致性

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

1、数据竞争与顺序一致性

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

1、在一个线程中写一个变量

2、在另一个线程中读同一个变量

3、而且写和读没有通过同步来排序

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

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

这里所说的同步是指的广义上的同步,包括了常用的同步原语(synchronized、volatile和final)的正确使用.

2、顺序一致性内存模型

顺序一致性内存模型是一个理论参考模型,为内存的可见性提供了极强的保证.有如下两大特性:

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

通过上图可知,顺序一致性模型有一个单一的全局内存,这个内存通过一个开关可以连接到任意一个线程,同时每个线程必须按照程序的顺序来执行内存读/写操作.

在任意时间点最多只能有一个线程可以连接到内存.

通过图解来描述顺序的一致性模型.假设两个线程A和B并发执行,其中A线程有3个操作,

在程序中的执行顺序A1->A2->A3. B线程也有3个操作,在程序中的顺序是:B1->B2->B3.

先看同步情况:

如果说两个线程使用监视器锁来正确同步,A线程的3个操作执行后释放监视器锁,随后B线程获取同一个监视器锁.

在来看没有做同步情况:

未同步程序在顺序一致性模型中虽然整体上执行顺序是无序的,但是对于线程A来说

其操作是顺序的,对于线程B来说执行也是顺序的.由顺序一致性定义可知:对于一个线程来说,其操作是按照程序的顺序执行的,并且所有的线程只能看到一个单一的操作.

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

3、同步程序的顺序一致性效果

通过锁的形式来实现程序的同步,看看正确同步的程序是如何具有顺序一致性的.

class SynchronizedExample{
    int a =0;
    boolean flag =false;
    public synchronized void writer(){ // 获取锁
        a =1;
        flag = true; 
    }         // 释放锁
    public synchronized void reader(){ // 获取锁
        if(flag){
            int i = a;
            
        } 
    }         // 释放锁
}

假设线程A执行writer()方法后,B线程执行reader()方法,这是一个正确同步的多线程程序.根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同.

顺序一致性模型中,所有操作完全按照程序的顺序串行执行. 在JMM中,临界区内的代码可以重排序(但是JMM不允许临界区内的代码逃逸到临界区之外,这样会破坏监视器的语义).

线程A在临界区做了重排序,但是由于监视器互斥执行的特性,线程B根本无法观察到线程A在临界区的重排序,这种重排序既提高了执行效率、有没有改变程序的执行结果.

在不改变程序执行结果的前提下,尽可能为编译器和处理器优化这就是JMM的具体实现.

4、未同步程序的执行特性

对于未同步或未正确同步的多线程程序, JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,false).

为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象(JVM内部会同步这两个操作),在已清零的内存空间分配对象时,域的默认初始化已经完成了.

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型的执行一致.如果想要保证执行结果一致,JMM需要禁止大量的处理器和编译器优化工作,这对程序执行性能会产生很大的影响.并且未同步程序在顺序一致性模型中执行时,整体时无序的,其执行结果往往无法预知.

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

  • 顺序一致性模型保证了单线程内的操作按照程序的顺序执行,而JMM不保证单线程内的操作会按照程序的顺序执行(比如上诉临界区的重排序).
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能够看到一致的操作执行顺序.
  • JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证了对所有内存读/写操作都具有原子性.

Volatile的内存语义

当声明共享变量为volatile后,对这个变量的读/写将会很特别,下面就介绍一下volatile的内存语义以及volatile内存语义的实现.

1、volatile特性

对volatile变量的单个读/写,可以看成使用同一个锁对这些单个读/写操作做了同步.

class VolatileFeaturesExample{
    volatile long vl =0L;    // 使用volatile声明64位的long类型变量
    public void set(long l){
        vl = l;      // 单个volatule变量的写
    } 
    public void getAndIncrement(){
        vl++;      //(多个)volatile变量的读/写
    }
    public long get(){    //单个volatile变量的读
        return vl;
    }
}

有多个线程分别调用上面3个方法,语义等价程序如下:

class VolatileFeaturesExample{
    long vl = 0L;      // 64位的long型普通变量
    public synchronized void set(long l){//对单个普通变量的写用同一个锁同步
        vl = l;      
    }
    public void getAndIncrement(){  //普通方法调用
        long temp = get();    //调用已同步的读方法
        temp += 1L;      // 普通写操作
        set(temp);      // 调用已同步的写方法
    }
    
    public synchronized long get(){  //对单个的普通变量读用一个锁同步
        return vl;
    }
}

对一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都使用同一个锁来同步执行效果相同.

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

锁的语义决定了临界区代码的执行具有原子性,这就意味着即使64位的long类型和double类型变量,只要被volatile修饰,对该变量的读/写就具有原子性.

如果多个volatile操作或类型volatile++这种复合操作,这些操作不具有原子性

* 总结 *

volatile变量自身具有以下特性:

  • 可见性: 对于一个volatile变量的读,总能够看到(任意线程)对这个volatile变量最后的写入.
  • 有序性: 禁止指令重排序,对JMM来说,使用volatile原语,能够避免不必要的指令重排而引起的错误.
  • 原子性: 对于任意单个volatile变量的读/写具有原子性,但是类似volatile++这种复合操作不具有原子性

2、volatile写-读建立的happens-before关系

从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信.

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

volatile写和锁的释放有相同的内存语义,volatile读与锁的获取有相同的内存语义.

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

假设线程A执行writer()方法之后,线程B执行reader()方法,根据happens-before规则,这个过程建立的happens-before关系可以分为3类:

  • 根据程序次序规则: 1happens-before 2; 3happens-before 4
  • 根据volatile规则: 2 happens-before 3(写优先与读)
  • 根据happens-before的传递规则: 1 happens-before 4

3、volatile写-读的内存语义

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

假设线程A首先执行writer()方法, 随后线程B执行reader()方法. 初始两个线程的本地内存中的flag和a都是初始状态.

线程A执行volatile写后,本地内存A中更新过的两个共享变量被刷新到主内存中.此时主内存和本地内存A中的共享变量的值是一致的.

因此总结一下:

volatile读的内存语义:

当读一个volatile变量时,JMM会把该线程(这里指的是线程B)对应的本地内存置为无效.线程会从主内存中读取共享变量.

volatile写的内存语义:

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

致谢

Java并发编程艺术

本文使用 mdnice 排版