三、聊聊并发 — 为什么Java并发编程必须了解Java内存模型

前言

前面我们说了在并发编程中引起线程不安全的原因,主要因为共享变量的可见性、重排序、原子性,也稍微的提了一下内存模型,那什么是内存模型呢?为什么必须要了解Java内存模型呢?那我们这篇文章就来聊一聊Java内存模型

什么是Java内存模型

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与Java编程时所说的变量不一样,只包括了实例字段、静态字段和构成数组对象的元素,其实也就是我们Java中所说的实例变量和成员变量,但是不包括局部变量与方法参数。

除此之外,内存模型还描述了多线程对于共享变量的读写机制,前面的文章我们也提到过,那这里就在啰嗦一遍。Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存。线程的工作内存中保存了该线程中用到的变量的主内存副本拷贝,线程对共享变量的所有操作都必须在工作内存中进行,不能直接操作主内存变量,而是将变量拷贝到本地内存中,在本地内存操作完成以后,再将结果同步回主内存,不同的线程之间也无法直接访问对方工作内存中的变量。

这里要说明一下,我们这里所说的主内存、工作内存在层次划分上和JVM运行时内存所说的堆、栈、方法区,并不是一个层次上的划分,基本上是没有关系的,从更基础的角度来说,主内存直接对应的是物理硬件内存,而工作内存是虚拟机(或是硬件、操作系统)可能会让工作内存优先存储于寄存器和高速缓冲区中,程序运行时,主要访问的是工作内存。

UTOOLS1577690490404.png

JMM对并发问题的解决方案

Java内存模型的定义全都是围绕着变量的原子性、可见性、以及重排序来进行展开的,所制定的一些规则,全都是为了保证共享变量的原子性、可见性、有序性。

  • 原子性:我们说对一个共享变量的操作一定是具有原子性的,因为如果不具有原子性,那多线程的情况下,如果没有额外的其他同步操作,会产生数据不一致的问题。

  • 可见性:我们一般都说是内存可见性。假设有两个线程A、B,同时对一个共享变量D进行操作,线程B没有及时拿到线程A对D操作以后的结果,那我们就说线程A的操作对于线程B的操作是不可见的。

  • 重排序:在多线程环境下,对于共享变量的操作如果是非原子性的,那就可能会表现出乱序的现象。

Java为了解决重排序、原子性、可见性问题,提供了语言级别的两个关键字Synchronized和Volatile。

这里我们就不提static关键字了,虽然static关键字也可以变相的解决问题,但是并不和内存模型有关系,而是利用static关键字特殊的加载机制。

volatile关键字是Java内存模型提供的最轻量级的同步机制,它可以保证多线程操作共享变量的有序性以及内存可见性。而synchronized关键字则是一个互斥锁,可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。因为被保护的区域同一时间只能有一个线程访问,所以原子性的问题也变向的得到了保障。我们可以通过下面的图片看到synchronized的执行过程。

synchronized.gif

Java内存模型分析DCL

我们就通过一个具体的例子来看一下volatile和synchronized的使用

下面是一个双重校验的单例模式的一种写法,虽然我们在实际的开发中很少用这种写法,但是通过这种写法我们可以具体的来分析一下synchronized和volatile。实际开发中我们应该用枚举、静态代码块、静态内部类的单例模式比较多。

public class Singleton {
    private static Singleton instance = null;
    private int a;
    private Singleton() {
      a = 4;
    }
    public static Singleton getInstance() {
        if (instance == null) { // 1. 第一次检查
            synchronized (Singleton.class) { // 2
                if (instance == null) { // 3. 第二次检查
                    instance = new Singleton(); // 4
                }
            }
        }
        return instance;
    }
}

上述的写法是不正确的,上述的写法在多线程的情况下没有办法保证是单例的,我们来分析一下。

假如现在有A、B两个线程,线程A先执行,执行到步骤4,因为 instance = new Singleton();不是原子操作,我们知道new一个对象,分为以下几个步骤。

a. 创建对象实例,分配一块内存空间。

b. 进行对象头、属性的初始化。

c. 将对象的引用赋值给instance。

假如线程A此时已经执行到了步骤4,因为步骤4不是一个原子操作,所以可能b和c操作之间发生了重排序,导致对象还没有完成属性的初始化,直接就将对象的引用赋给instance。而此时线程B到执行到了步骤1,发现此时instance不为null直接就返回了Singlotan,但是此时线程B拿到的是一个不完整的对象。修改的方式则是通过使用volatile来修饰,使用volatile修饰以后,步骤4也就不会发生重排序的情况。

内存模型对于原子性的保证

在这里我想单独的来介绍一下关于原子性的问题。

Java内存模型定义了8种原子操作,来完成内存的操作。

  1. read(读取):它把一个变量的值从主内存传输到线程的工作内存中,以便以后的load。
  2. load(载入):把read操作从主内存中的得到的变量值放入到工作内存中的变量副本中
  3. use(使用):把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值字节码指令时,会使用到这个指令。
  4. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时,执行此操作。
  5. store(存储):作用于工作内存的变量,它把工作内存中一个变量值传送到主内存中,以便随后的write操作使用。
  6. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入到主内存变量中。
  7. lock(锁定)把变量标识为一条线程独占的状态
  8. unlock(解锁):把一个处于锁定状态的变量释放出来

上面的那些指令1 - 6,是Java程序和计算机交互的时候使用到的指令,这些指令就涉及到了汇编层面。

int x = 10;             //语句1
int y = x;              //语句2
x++;                    //语句3 
x = x + 1;              //语句4
Object z = new Object();//语句5

上述的几种情况,只有语句1是原子操作。这样来解释一下吧,因为语句1,只使用到了一个assign指令就完成了上述的操作,因为只是将一个常数赋值给了变量x。但是其他的几种情况都是多个指令才能完成。内存模型虽然能保证这8个单独的指令是原子性的, 但是没有办法保证这些指令组合在一起的原子性。如果想保证这些组合指令的原子性,只能通过额外的操作来完成,比如说加锁。

内存模型规定,把一个变量从主内存复制到工作内存中,就要顺序的执行read和load操作,如果要把变量同步回主内存,就要顺序的执行store和write操作。需要注意的是,内存模型只规定了顺序执行,但是没有规定两个操作是连续执行。也就是说read 和 load ,store和write之间可以插入其他的指令,如对主内存中的变量a、b进行访问,可能出现的顺序是read a、read b、load b、load a。这也说明了为什么原子性会导致线程不安全。

内存模型之Happens-befor

如果代码的所有有序性都要通过volatile和synchronized来实现,那这样会让使用者感到很繁琐。我们在编写代码时候,不需要时刻考虑自己编写的代码之间是否会发生重排序,那是因为Java语言中有一 个“先行发生”(Happens-Before)的原则,它是判断数据是否存在竞争,依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操 作之间是否可能存在冲突的所有问题。规则如下:

  1. 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作,happens-before于书写在后面的操作。
  2. 监视器锁规则:在一个监视器锁上的解锁操作,必须在同一个监视器锁加锁之前执行。
  3. volatile变量规则:对一个volatile变量的写操作,happens-before于对这个变量的读操作。
  4. 传递性:如果操作 A happens-before 操作 B,而操作 B happens-before操作C,则可以得出,操作 A happens-before 操作C
  5. 线程启动规则:在线程上调用start()方法,必须在该线程执行任何操作之前执行。
  6. 线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已结束之前执行。
  7. 终结器规则:对象的构造函数必须在启动该对象终结器之前执行完成。
  8. 中断规则:对线程 interrupt方法的调用,happens-before被中断线程的代码检测到中断事件的发生。

happens-befor是阐述操作之间的内存可见性。==如果一个操作的结果,需要对另外一个操作可见,那么这连个操作之间必须存在happens-befor关系。这两个操作可以在一个线程内,也可以是在不同的线程之间。== 两个操作之间存在happens-before关系,并不意味着一定要按照 happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照 happens-before关系来执行的结果一致,那么这种重排序并不非法。

内存模型之as-if-serial

as-if-serial相对于happen-befor还是好理解很多。as-if-serial语义的是:所有的操作均可以为了优化而被重排序,但是必须要保证重排序后执行的结果不能被改变,编译器、runtime、处理器都必须遵守 as-if-serial语义。注意,as-if-serial 只保证单线程环境,多线程环境下无效。

如何理解上面的话呢,举个例子

int a = 1;
int b = 2;

这两个赋值操作之间不存在任何的数据依赖,那么这两个操作,是可以被重排序的。有可能先给b变量赋值,再给a变量赋值。
但是如果操作是

int a = 1;
int b = a + 1;

这两个操作是不能够重排序的。因为变量b 的值依赖于变量a。而且只能是在单线程环境下。多线程环境下,是没有办法保证的。

内存模型之volatile

JMM对于volatile读写的规则定义:

  1. 写的内存语义: 当写一个volatile变量时,操作完成以后JMM会把线程对应的本地内存中的共享变量立刻刷新到主内存。
  2. 读的内存语义: 当读一个volatile变量时,JMM会把线程对应的本地内存置为无效,线程接下来从主内存中读取共享变量。

JMM对volatile变量重排序规则定义:

  1. 如果第一个操作为volatile读,则不管第二个操作是什么,都不能重排序。这个操作确保volatile 读之后的操作,不会被编译器重排序到 volatile 读之前;
  2. 如果第二个操作为 volatile 写,则不管第一个操作是什么,都不能重排序。这个操作确保volatile写之前的操作,不会被编译器重排序到 volatile 写之后;
  3. 当第一个操作 volatile 写,第二个操作为 volatile 读时,不能重排序。

JMM是如何实现volatile的语义规则

可见性的实现

如果一个变量被声明volatile,那这个变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。

有序性的实现

编译器在生成字节码时,会在指令序列中插入内存屏障,来禁止特定类型的处理器重排序。JMM采用了保守策略,规则如下:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。保证在volatile写之前,其前面的所有普通写操作,都已经刷新到主内存中。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。保证在volatile写之前,其前面的所有普通写操作,都已经刷新到主内存中。
  • 在每个volatile读操作的前面插入一个LoadLoad屏障。禁止处理器把上面的volatile读,与下面的普通读重排序。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。禁止处理器把上面的volatile读,与下面的普通写重排序。

内存模型之final

对于final域,编译器和处理器要遵守两个重排序规则。

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

通过例子以及下面的分析,来看一下上面的两个规则。

public class FinalTest {
          int i;                          //普通变量
          final int j;                    //final变量
          static FinalTest obj;
      
          public void FinalTest() {    //构造函数
              i = 1;                      //写普通域
              j = 2;                      //写final域
          }
     
         public static void writer() {   //写线程A执行
             obj = new FinalTest();
         }
     
         public static void reader() {   //读线程B执行
             FinalTest object = obj;  //读对象引用
             int a = object.i;           //读普通域
             int b = object.j;           //读final域
         }
   }

final域写重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含2个方面。

  1. JMM禁止编译器把final域的写重排序到构造函数之外。

  2. 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障 禁止处理器把final域的写重排序到构造函数之外。

    下面这个这个执行时序是可能发生的情况

final写语义.png

写普通域的操作被编译器重排序到了构造函数之外,读普通域读取了初始化之前的值。而final域则不存在这种情况,final域被"规则"限定在了构造函数中,确保获取值的线程可以得到正确的结果。写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被 正确初始化过了,而普通域不具有这个保障。

final域读重排序规则

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如alpha处理器),这个规则就是专门用来针对这种处理器。

读final语义.png

读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程A写入,这是一个错误的读取操作。而读final域的重排序规则会把读对象final域的操作“限定”在读对象引用之后,此时该final域已经被A线程初始化过了,这是一个正确的读取操作。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经被A线程初始化过了。

如果final域是引用类型的话,写final域的重排序规则对编译器和处理器增加了如下约束:

在构造函数内,对一个final引用对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这个两个操作之间是不准重排序的。通过这个规则我们也相当于反向的验证了为什么在DCL单例模式中,为什么还需要使用volatile来修饰那个对引用对象了。

总结

通过上述所说,我们基本上对Java内存模型有了清晰的概念。知道了Java内存模型到底是什么,可以干什么,然而实际开发中我们只会使用到某些关键字。为啥还要掌握内存模型呢?

那我根据我个人看法来说一下,虽然开发中只会使用到某写关键字,但是了解内存模型对于这些关键字的实现或者一些规范还是有必要的,了解Java内存模型,可以帮助我们再编写并发程序的时候,让我们对代码的安全性做出判断,判断出代码否是线程安全的,减少一些不必要的错误。除此之外,当我遇到并发产生的问题时,也可以帮住我们快速定位问题,及时的给出解决方法。所以想写好Java并发程序,了解内存模型是必不可少的一步。

参考:

《Java并发编程实战》

《深入理解Java虚拟机》

《深入理解Java内存模型》

你可能感兴趣的:(三、聊聊并发 — 为什么Java并发编程必须了解Java内存模型)