深入学习Java多线程——Java内存模型基础

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>> hot3.png

 1.处理器——缓存——主内存

1.1数据处理过程

    由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都会加入一层读写速度尽可能接近处理器速度的高速缓存来作为内存与处理器间的缓冲:将运算需要使用的数据从系统内存中复制到处理器缓存中,然后处理器能够快速处理这部分数据进行运算,当运算结束后,在将数据从缓存同步回系统内存中,这样处理器就无需等待缓慢的系统内存读写了。

    但是这一会带来另一个问题,即缓存一致性,在多处理器系统中,每个处理器有自己单独的处理器的同时,又共享同一个系统内存,这就会导致当多个处理器同时处理一块内存区域时,将导致各自缓存数据不一致,当处理完数据后,返回系统内存的数据不一定准确。为了解决这个问题,就出现了缓存一致性协议。

2.Java内存模型基础

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

    在并发编程中,需要处理两个问题:线程之间如何通信以及线程间如何同步。通信是指线程间以何种机制交换信息。同步是指程序中用于控制不同线程间操作发生相对顺序的机制。

    1.通信机制有两种:共享内存和消息传递。在共享内存的并发模型里,线程之间共享程序的公共状态,通过写——读内存中的公共状态来进行隐式通信。而在消息传递的并发模型中,线程间没有公共状态,线程间必须通过发送消息进行显式通信。

    2.依据通信机制,同步也分为共享内存和消息传递两种情况:在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

    Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

2.2 内存模型的抽象结构

    Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存,和从内存中取出变量。此处的变量指的是实例字段,静态字段,和构成数组对象的元素,但不包括局部变量,方法参数和异常处理器参数,因为后者为线程私有不会被共享(注意,如果局部变量或方法参数为对象引用,那么这个对象在Java堆中仍然是共享的,但这个引用是在栈中的局部变量表中,为线程私有),不会被共享,也就不存在竞争问题。

    Java内存模型规定了所有变量都存储在主内存(虚拟机一部分)中,每条线程由自己的工作内存,每个线程中保存了使用到的变量的主内存副本拷贝(注意,并不是拷贝整个内存,比如说访问一个10kb的对象,并不会把10kb的内存全都拷贝,而是拷贝这个对象的引用,或对象的某个被线程访问的字段等,但不会拷贝整个对象内存),线程对变量的所有操作(读取,更改,赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程间也无法直接访问对方工作内存中的变量,线程间变量的传值需要主内存来完成。

    主内存,工作内存与Java堆,栈,方法区并不是一个层面的划分,两者基本没有关系。硬要联系起来的话,主内存相当于Java堆中的对象实例部分,工作内存对应虚拟机栈。

2.3 内存间交互操作

   1. Java内存模型定义了8种操作来完成主内存与工作内存间的交互,以下每种操作都是原子性的(double和long类型的数据,可能会有例外)。

(1)lock(锁定):作用于主内存的变量,他把一个变量表示为一条线程独占的状态。

(2)unlock(解锁):作用于主内存的变量,他把一个处于锁定状态的变量解放出来,释放后的变量才可以被其他线程锁定。

(3)read(读取):作用于主内存的变量,他把一个变量从主内存传输到线程的工作内存中,用于load操作。

(4)load(载入):作用于工作内存的变量,把read操作从主内存中传过来的变量值放入工作内存的变量副本中。

(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令时会执行这个操作。

(6)assign(赋值):作用于工作内存的变量,他把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时会执行这个操作。

(7)store(存储):作用于工作内存的变量,它将工作内存中的一个变量的值传输到主内存中,用于write操作。

(8)write(写入):作用域主内存的变量,他把stroe操作从工作内存中取到的值写入主内存的变量中。

    2.而以上8种操作必须满足以下规则:

(1)不允许read和load,store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存回写了但主内存不接受。

(2)不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

(3)不允许一个线程无原因的(没有发生任何assign操作)把数据从线程的工作内存同步回主内存。

(4)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,即在对一个变量进行use或store操作之前,必须先执行完assign和load操作。

(5)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock操作之后,只有进行相同次数的unlock操作,变量才会被解锁。

(6)如果对一个变量执行lock操作,那么将会清空工作内存中该变量的值,在执行引擎使用这个变量之前,需要重新执行load或assign操作初始化变量的值。

(7)如果一个变量没有进行lock操作,那么就不允许对它执行unlock操作,也不允许unlock一个被其他线程锁住的变量。

(8)对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,也就是store,write操作。

    3.以上8种基本内存操作及8种内存操作规则限制,以及volatile关键字的一些特殊规定,就已经确定了那些内存访问操作在并发下是安全的。

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

    1.在执行程序时,为了提高性能,编译器核处理器常常会对指令进行重排序,重排序分三种类型:

  • 编译器优化的重排序:编译器在不改变单线程语义(或者说执行结果)的情况下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

    从Java源码到最终执行的实际指令序列会依据以上次序进行重排序。第一条属于编译器重排序,后两个属于处理器重排序,这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM(Java内存模型)的处理器重排序规则会要 求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

2.5 写缓冲区带来的影响与解决

    1.现代的处理器使用写缓冲区临时保存向内存写入的数据。它的优点是:

(1)写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。

(2)同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。

    缺点:每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响,即处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。

    比如,若变量a=b=0

//处理器A执行代码
a=1;//A1
x=b;//A2

//处理器B执行代码
b=2;//B1
y=a;//B2

则执行后可能得到x=y=0,因为当A,B处理器并行执行时,分别同时将共享变量a,b写入自己的缓冲区,然后再从内存中读取另一个共享变量的,最后再将自己缓冲区内的数据写回到内存中,当以这种顺序执行时就会得到这种结果。具体过程如下图:

    在图中,以处理器A为例,执行a=1代码的过程主要分为A1和A3两步,A1,A3都完成才算完整的写入内存操作,但在A1操作执行后,处理器进行了读取b的操作即A2,读取操作仅有A2一步,所以此时x=b代码执行完毕,而a=1这行代码并没有完整执行,还有最后一步A3继续执行,代码a=1才算执行完毕。

    在代码上看,处理器的内存操作顺序应为代码A1——代码A2,但实际上内存的操作顺序变成了代码A2——代码A1,此时,处理器的内存操作就是被重排序了。

    这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序(Store-Load重排序)。常见的处理器都不允许对存在数据依赖的操作做重排序。

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

(1)LoadLoad Barriers:比如Load1:LoadLoad:Load2指令,确保Load1数据的装载操作先于Load2及后续所有装载指令的装载操作。

(2)StoreStore Barriers:比如Store1:StoreStore :Store2指令,确保Store1数据对其他处理器可见(即立即刷新到内存)操作先于Store2及所有后续存储指令的存储操作。

(3)LoadStore Barriers:比如Load1:LoadStore :Store1指令,确保Load1数据装载的操作先于Store1及后续所有存储指令刷新到内存的操作。

(4)StoreLoad Barriers:比如Store1:StoreLoad :Load1指令,确保Stroe1刷新内存操作先于Load1及后续所有装载数据指令的操作之前。也会使该屏障之前的所有内存操作(装载和存储)指令完成之后,才执行该屏障之后的指令操作。

    StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中

2.6 happens-before规则简介

    1.happens-before的概念来阐述操作之间的内存可见性。在JMM(Java内存模型)中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关 系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

    2.具体规则:

(1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

(2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

(3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

(4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

 但是,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。