一.为何引入JMM
每个处理器在执行任务时,不可能单靠"计算"就可以完成所有任务,处理器至少需要和内存交互,进行读取运算数据、存储运算结果等,这个I/O操作是很难消除掉的。但由于计算机的存储设备与处理器的运算速度之间相差了几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能与处理器运算速度相近的高速缓存(Cache),作为内存预处理器之间的缓冲,将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存中,这样处理器就无需等待缓慢的内存读写了。
但是引入缓存给计算机带来了新的问题:缓存一致性问题。在目前已经很普遍的多处理器计算机中,每个处理器都有着自己的高速缓存,而他们又共享一个主内存,当多个处理器的运算任务都涉及到同一块内存时,就会导致各自的缓存数据不一致。
为了解决缓存一致性问题,需要各个处理器访问缓存时遵守一些协议,比如MESI协议等。
以上是引入Java内存模型的预置条件。
Java虚拟机规范试图定义一种Java内存模型JMM,来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果,真正做到:“Code once,run everywhere”。这个模型必须足够严谨,让Java并发内存访问操作不会有歧义,也必须做到足够宽松,使得虚拟机的实现有足够多自由空间去利用硬件的各种特性来获取更好的执行速度。
Java内存模型对并发的保证主要在于实现原子性,可见性,和有序性三大特性。
二.JMM内存模型
并发编程中,有两个关键问题
- 线程之间如何通信
- 线程之间如何同步
线程之间的通信机制有两种:共享内存和消息传递
- 共享内存
在共享内存的并发模型中,线程之间共享程序的公共部分,程序必须显式规定某些指令必须在线程之间互斥执行,这时同步是显式实现的,而消息传递是通过共享的内存隐式实现的。 - 消息传递
在消息传递的并发模型中,线程之间必须发送消息来显式进行通信,但是消息接收方拿到资源必然在消息发送方发送之后,因此同步时隐式实现的。
打个比方,程序员A要和程序员B联手开发一个项目,他们有没有什么经验,相互实现的部分耦合度很高,A要想和B合作,要么用一台电脑,A写代码的时候B就在那瞧,硬瞧,B写的时候A就在那瞧,这是共享内存方式,还有一种方式就是A写了一部分,通过网络发给B,B收到消息,拿到代码接着写,这就是消息传递方式。
如何AB之间的交流出现问题,最后就会导致版本混乱,也就是线程安全问题。
Java中,线程间的通信是通过共享内存来完成的。JMM就规定不同线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存中的共享变量,线程之间的传递均需要JMM控制并通过主内存完成。
在Java中,所有实例域、静态域、和数组元素都存储在堆中,而堆线程间共享的。局部变量,方法定义参数,异常处理参数这些都存储在虚拟机栈中,不会在线程之间共享。
JMM抽象结构示意图:
JMM定义了线程和主内存之间的抽象关系,线程之间的共享变量存储在主内存中,每个线程都有本地内存,存储着该线程读/写的共享变量副本。
本地内存是JMM抽象的一个概念,本身并不存在,它涵盖了缓存,写缓冲区,寄存器以及其他硬件和编译器优化。
CPU 与 Cache 结构图:
假设线程A、B之间要进行通信,需要经历一下步骤
- 线程A把本地内存更新过的共享变量副本刷新到主内存中去
- 线程B到主内存中去读取线程A之前已更新过的共享变量
三. 原子性
1.JMM对原子性的保证
JMM中定义了以下8种原子性操作,虚拟机实现时必须保证其中的每一种都是原子的,不可再分的,这八种原子操作建立在MESI协议基础上。
- lock(锁定):
作用于主内存的变量,把一个变量标识为一条线程独占的状态 - unlock(解锁):
作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 - read(读取)
作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便于随后的load动作读取 - load(载入)
作用于工作内存的变量,它把read操作从主内存得到的变量值放入工作内存中的变量副本中 - use(使用)
作用于工作内存的变量,它把工作内存中的一个变量值传给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令时将会执行这个操作 - assign(赋值)
作用域工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存变量,每当虚拟机遇到一个变量赋值的字节码指定时执行这个操作。 - store(存储)
作用域工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,以便随后的write操作时使用 - write(写入)
作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入到主内存的变量中。
Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现(即不允许一个变量从主存读取了但是工作内存不接受,或者从工作内存发起会写了但是主存不接受的情况),以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。
- 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
- 如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。
其中read,load,use,assign,store,write主要保证基本数据类型访问读写操作的原子性,而lock以及unlock则实现了更广范围上的原子性保证。
2.并发编程中的具体体现
基本数据类型访问读写操作的原子性是JMM为我们提供的最低保证,我们在并发编程不用考虑这些,lock,unlock操作也没有直接开放给用户,但是提供了字节码指令monitorenter
,monitorexit
来隐式使用这两个操作,这两个字节码对应到Java代码就是synchronizd
关键字,具体关于这个关键字的使用我会在其他博客中总结一下,在此不详细讨论。
三.有序性
1.JMM对有序性的实现
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,但是有些重排序势必会导致程序执行结果发生变化,JMM是通过限制编译器和处理器的重排序功能来保证顺序一致性与可见性
首先来看重排序本身,重排序分三种类型:
- 编译器优化的重排序
编译器在不改变单线程程序语义的前提下,可以重新安排语义的执行顺序。
- 指令级并行的重排序
现代处理器采用了指令级并行技术(ILP),来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
一个程序可能经过的重排序过程
上述的第一种属于编译器重排序,第二种和第三种属于处理器重排序。
JMM对于编译器重排序,JMM的编译器重排序格则将直接禁止某些特定类型的编译器重排序。
JMM对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障来禁止特定类型的处理器重排序。
内存屏障类型表:
屏障类型 | 指令示例 | 说明 |
LoadLoad Barriers | Load1;loadload;Load2 | 确保Load1数据的装载完成后,才能执行Load2以及所有后续装载指令的装载 |
StoreStore Barriers | Store1;storestore;Store2 | 确保Store1数据对其他处理器可见(刷新到内存)完成后,才能执行Store2及所有后续存储指令的存储 |
LoadStore Barriers | Load1;loadstore;Store2 | 确保Load1数据装载完成后,才能执行Store2及所有后续的存储指令刷新到内存 |
StoreLoad Barriers | Store1;storeload;Load2 | 确保Store1数据对其他处理器可见(刷新到内存)完成后,才能执行Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令完成之后,才执行屏障之后的内存访问指令 |
关于Load
屏障:屏障之前的操作执行先于屏障之后的操作,而且后续的操作必须先等当前处理器装载操作完成,不能我读的时候让其他处理器又给更改了,保证当前处理器的读对其他处理器是可见的。
关于Store
屏障:屏障之前的操作执行先于屏障之后的操作,而且后续的操作必须等我把当前处理器的本地缓存数据刷新到主内存中,不能我正在刷新的时候其他处理器去读旧数据,保证当前处理器的写对其他处理器是可见的。
以上四种屏障StoreLoad Barriers
是全能的,但是它的开销非常大,它要求当前处理器把屏障前涉及的所有缓存区数据全部刷新到内存中。
所以真正意义上,不是内存屏障真的禁止了处理器重排序,而是处理器重排序无法越过屏障,屏障前的操作始终先于屏障后的操作。
2.并发编程中的具体体现
在实际的并发编程中,我们使用volatile
关键字和synchronized
关键字来实现有序性保证。
四.可见性
JMM通过volatile
,final
和锁
的内存语义保证可见性。
1.volatile内存语义
volatile自身具有以下特性:
- 可见性。对一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入
- 原子性。对任意单个volatile变量的读/写本身具有原子性,但是类似于volatile++这种复合操作不具有原子性
volatile写的内存语义:
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
volatile读的内存语义:
当读一个volatile变量时,JMM会把该线程的对应的本地内存置为无效,从主内存中读取共享变量
如何实现的?
JMM对此采取的时保守策略,具体策略如下:
- 在每个volatile写操作的前面插入一个StoreStore屏障
- 在每个volatile写操作的后面插入一个StoreLoad屏障
- 在每个volatile读操作的后面插入一个LoadLoad屏障
- 在每个volatile读操作的后面插入一个LoadStore屏障
2.锁的内存语义
众所周知,锁可以让临界区互斥执行,每次只允许一个线程访问,保证可见性与原子性
获取锁的内存语义:
当线程获取锁时,JMM会把线程对应的本地内存置为无效。从而使得被监视器保护的临界代码必须从主内存中读取内存变量。
释放锁的内存语义:
当线程释放锁时,JMM会把线程对应的本地内存中的共享变量刷新到主内存中。
如何实现?
利用synchronized关键字或者其他互斥锁
利用CAS的非阻塞轻量锁
五.JMM的happens-before原则
happens-before原则是JMM对程序员的保证,让程序员们安心写代码,不用担心因为重排序或其他非主管因素导致自己程序异常(所以你的并发程序出了问题,别想甩锅,不关人家编译器和处理器的事)
JMM与happens-before原则之间的关系如图所示:
《JSR-133》对happens-before原则的定义:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现一定要按照happens-before原则指定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
happens-befores规则:
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
- 锁定规则:一个unLock()操作先行发生于后面对同一个锁的lock()操作;
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
- start()规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
- interrupt()规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
- join()规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;