01.并发编程(1)Java内存模型

277 阅读11分钟

概述

并发源于多线程,而线程之间的通信通常有两种方式,消息传递跟共享内存,Java采用的后一种,也就是共享内存,所以在学习并发前,有必要西安了解一些关于Java的内存模型,在学习JVM的时候我们知道JVM运行时区域可以分为五大区域,如果站在线程是否共享的角度,实际上可以分为两大块,如下图

thread

JVM规范中试图定义一种java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,也就是下文中要提到的Java内存模型(JMM)。

正文

Java内存模型

内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象,不同架构下的物理机拥有不一样的内存模型,Java虚拟机也有自己的内存模型,即Java内存模型(Java Memory Model, JMM),JMM的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量不包含线程的布局变量,因为对于线程私有内存区,都是单线程不会涉及到并发。 JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。

JMM_New

上图展示了主内存跟工作内存之间的数据交互,下面解释一下整个流程

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

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作,并且read和load、store和write必须是成对出现的,不允许单对出现。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。

重排序

为了使得机器能够更快的执行代码,完成指令运算,除了在硬件上有增加高速缓存等措施,还有一些方法就是对输入代码进行乱序执行优化,从而提高执行效率,而处理器会在计算之后将乱序的结果进行重组,保证结果与顺序执行的结果是一致的。但是并不保证各个语句计算的先后顺序与代码输入顺序一致。 与处理器乱序执行优化类似,JVM的即时编译器也有类似的指令重排序,指令重排序的出现归根到底是为了解决代码执行效率的问题,但是同时也带来了另外一个问题,就是如何保证重排序之后的指令执行结果与输入代码顺序的执行结果保持一致,比如单线程内部的需要顺序执行的代码,比如多线程对于同步代码块的执行等等。

Happens-before

Happens-before是JMM中定义两项操作的偏序关系,也就是我们常说的现行发生原则,如果操作A和操作B满足Happens-before,比如操作A先行发生于操作B,那么操作B一定能看到操作A的影响。 由于指令重排序,导致Java代码运行的时候并不是按照我们预期的顺序来执行,在单线程中,不存在并发是没有问题的,但是多线程中就会出现问题,本身即时在没有重排序的情况下,就已经会产生并发问题,如果在重排序的影响下,数据很容易错乱。

为了保证线程安全,我们已经了volatile跟synchronized关键字,这两种方式可以实现用来帮助我们保证线程安全,但是我们在平时的代码编写中,并没有使用这两个关键字,因为JMM已经定义了一套天然的Happens-before关系,这些先行发生关系并不需要我们进行特殊处理,是约定俗称的,只要我们的操作满足下列原则,JMM就不会进行失灵。

  • volatile变量:对一个volatile修饰的变量,对他的写操作先行发生于读操作。
  • 程序次序规则:在一个线程内,书写在前面的代码先行发生于后面的。确切地说应该是,按照程序的控制流顺序,因为存在一些分支结构
  • 线程启动规则:thread对象的start()方法先行发生于此线程的每一个动作
  • 线程终止规则:线程的所有操作都先行发生于对此线程的终止检测。
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码所检测到的中断事件。
  • 对象终结规则:一个对象的初始化完成(构造函数之行结束)先行发生于发的finilize()方法的开始。
  • 传递性:A先行发生B,B先行发生C,那么,A先行发生C。
  • 管程锁定规则:管程锁定规则。一个unlock操作先行发生于后面对同一个锁的lock操作。

Happens-before规则的直接作用是约束指令重排序,从而保证同步,确定了单线程的安全性。对于多线程来讲,先行发生原则不能保证线程安全,下满看个例子:

public class Automic {
    int value = 3;

    //线程A
    public void setValue(int value) {
        this.value = value;
    }

    //线程B
    public int getValue() {
        return value;
    }
}

如果两个线程同时访问这段代码,就会出现问题,因为这里会出现线程不安全,线程A更改了值,但是这个时候线程B抢占了资源,但是此时线程B获取到的并不是最新值。所以就必须采取一些措施来保证线程安全。下面更改后的代码 volatile 通过使用volatile来修饰多线程访问的变量,来实现先行发生原则

public class Automic {
    volatile int value = 3;

    //线程A
    public void setValue(int value) {
        this.value = value;
    }

    //线程B
    public int getValue() {
        return value;
    }
}

synchronized 通过使用synchronized来实现管程锁定原则,来实现先行发生原则

public class Automic {
    int value = 3;

    //线程A
    public synchronized void setValue(int value) {
        this.value = value;
    }

    //线程B
    public synchronized int getValue() {
        return value;
    }
}
  • 把value定义为volatile变量,这样指令就不会重排,线程A先进行的setValue操作就一定先于后面线程B的getValue操作,符合volatile变量的规则。
  • 要么是对getter、setter方法定义为synchronized方法,这样就可以套用管程锁定规则;

三个特性

原子性

原子性:在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作, 即这些操作是不可被中断的,要么执行,要么不执行。比较抽象,举个例子:

	 int a = 3;//原子性操作
     int b = 4;//原子性操作
     int c = ++a;//非原子性操作

初始化a,b两个都是赋值操作,都是原子性操作,初始化c的时候,因为需要分两步,首先获取a的值,然后a自增,总共有两步,很明显不是原子性操作。 long和double的原子性 先看看Java的基本类型 整型

类型 存储字节 bit数
byte 1字节 4*8
short 2字节 2*8
int 4字节 4*8
long 8字节 8*8

浮点型

类型 存储字节 bit数
float 4字节 4*8
double 8字节 8*8

char类型

类型 存储字节 bit数
char 2字节 2*8

bollean类型

类型 存储字节 bit数
boolean 1字节 1*8

JVM的位数 32位 JVM一次操作只能读取32字节,所以在32bit的JVM中没有把long和double的读写实现为原子操作。 在读写的时候,分成两次操作,每次读写32位。因为采用了这种策略,所以32位的long和double的读写都不是原子操作。 64位 JVM一次操作能够读取64字节,所以对于64bit的环境来说,单次操作可以操作64bit的数据,即可以以一次性读写long或double的整个64bit。long和double的读写有是原子操作。

在命令行输入java -d32 -version,可以查看当前JVM的位数

Mac-mini:~ chmyy$ java -d32 -version
Error: This Java instance does not support a 32-bit JVM.
Please install the desired version.
Mac-mini:~ chmyy$ java -d64 -version
java version "1.8.0_151"
Java(TM) SE Runtime Environment (build 1.8.0_151-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.151-b12, mixed mode)
Mac-mini:~ chmyy$ 

现在我使用的JVM是64位的,所以对long跟double的赋值操作是可以作为原子性操作

可见性

可见性:是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。 JMM在变量修改后将新值同步回主内存,依赖主内存作为媒介,在变量被线程读取前从内存刷新变量新值,保证变量的可见性。 不同线程都是直接操作自身工作内存中的副本,因此可能导致共享变量的修改在线程间不可见,所谓不可见,是指一个线程对共享变量的修改不能及时地被其他线程看到。导致共享变量在进程间不可见的原因有以下几个:

  • 指令重排序 & 线程交叉执行
  • 共享变量更新后的值没有在工作内存和主内存间及时更新 除了volatile外,synchronized和final也能保证可见性。
有序性

有序性:在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排 序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性

JMM的有序性表现为:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句指“线程内表现为串行的语义”(as-if-serial),后半句值“指令重排序”和普通变量的”工作内存与主内存同步延迟“的现象。

参考资料

<<深入理解Java虚拟机>>