本文通过对JSR133规范的解读,详细的介绍Java内存模型(JMM)的核心理论,并将开发中常用的关键字的实现原来做了详细的介绍。
通过本文读者可以了解到并发的一些基本理论,并对一些同步原语有了更深层次的理解。
01 导言
多线程、高并发问题相信是每一位从事Java研发工作的程序员都不可回避的一个重要话题。
从启动一个线程,到使用volatile、synchronized、final关键字,到使用wait、notify、notifyAll、join方法,再到编写复杂的多线程程序,不知道大家有没有思考过这样一个问题,为什么要使用这些API,或者说这些API到底给编程人员提供了什么样的保证,才使得在多线程环境下程序的运行结果能够符合预期。
它就是Java内存模型Java Memory Model,后续简称(JMM)。本文就带领大家一起,绕道这些API的背后,一探究竟。
02 约法三章-建立共识
探讨任何话题都需要探讨者站在一个共识基础之上,否则探讨将混乱不堪。正如一位名人曾经说过:”没有共识的讨论,都是抬杠“。我深以为然,所以在探讨JMM之前,需要建立以下几点共识。
- JMM只是一个抽象内存模型。
- JMM和物理机内存模型不是一个范畴。
- JMM和Java运行时数据区没有直接对应关系。
03 以史为鉴-回看计算机内存模型1、现代计算机内存模型
1、计算机内存模型
物理机遇到的并发问题与Java虚拟机中的情况有不少相似之处,物理机对并发问题的处理方案对虚拟机的实现也有相当大的参考价值。现代计算机中,CPU的指令速度远远超过内存的存取速度,由于计算机的存储设备与CPU的运算速度有几个数量级的差距,所以现在计算机中都不得不加入一层读写速度尽可能接近CPU运算速度的高速缓存(cache)来作为内存和CPU之间的缓冲。
基于高速缓存的存储交互很好的解决了CPU和内存的速度的矛盾,但也引入了一个新的问题,缓存一致性,在多处理器系统中,每个CPU都有自己的高速缓存,而他们又共享同一主内存,当多个处理器运算任务都涉及到同一块主内存区域时,将可能导致各自的缓存数据不一致。为了解决这个问题,需要各个处理器在访问内存时,需要遵循一些协议,例如MSI、EMSI、MOSI等。
2、缓存一致性
为了解决这个问题,先后有过两种办法:
- 总线锁机制
总线锁就是使用CPU提供的一个LOCK#信号,当一个处理器在总线上输出此信号,其他处理器的请求将被阻塞,那么该处理器就可以独占共享锁。这样就保证了数据一致性。
- 缓存锁机制
但是总线锁定开销太大,我们需要控制锁的力度,所以又有了缓存锁,核心就是缓存一致性协议,不同的CPU硬件厂商实现方式稍有不同,有MSI、MESI、MOSI等。
3、多线程编程面临的问题
多线程编程面临的两个重要的问题是:
- 线程之间的通信
- 线程之间的同步
线程之间的通信是指线程之间通过什么方式来交换信息。
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。
线程的通信方式:
- 共享内存
- 消息传递
在共享内存的并发模式里,线程之间共享程序的公共状态,线程之间通过读-写内存中的公共状态来实现隐式通信。
在消息传递的并发模式里,同步是显式进行的,程序员必须显式指定某个方法或某段代码需要在线程之间互斥进行。
在消息传递的并发模式里,线程之间没有公共状态,线程之间必须明确发送消息来显式进行通信。
在消息传递的并发模型里,同步是隐式进行的,由于消息发送必然在消息接收之前,因此同步是隐式进行的。
04 师夷长技-直面JSR133
1、JSR133是什么JSR-133规范,即Java内存模型与线程规范,由JSR-133专家组开发。JSR-133规范是JSR-176(定义Java平台Tiger(5.0)发布版的重要特性)的一部分。本规范的标准内容将合并到Java语言规范、Java虚拟机规范以及java.lang包的类说明中。
2、 JSR133倾诉的对象是谁身边好多同事反馈看不懂JSR133的内容,一方面是因为文档全部为英文,并且包含大量的专业英语。另外一方面是没有弄明白JSR133倾诉的对象到底是谁。如果弄明白的倾诉的对象,然后对号入座就能理解JSR133在说什么。JSR133倾诉的对象有两个,一个是使用者(程序员),另外一个是JMM的实现方(JVM)。面向程序员,JSR133通过happens-before规则给使用者提供了同步语义的保证。面向实现者,JSR133限制了编译器和处理器的优化,如下图4:
3、JSR133的主要内容是什么JSR133主要描述了JMM的主要的规则和限制,并详细阐述了一些同步原语的内存语义,详细的请查看下一章节,JSR133的目录,如下图5:
05 抽丝剥茧-专注JMM
1 JMM内存模型概述
前面在第三章节,讲述了共享内存和消息传递并发模型,java采用的是共享内存并发模型。
在java中,所有的实例域,静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量,方法参数和异常处理器参数不会在线程之间共享,他们不会有内存可见性问题,也不受内存模型的影响。
java线程之间的通信由java内存模型(JMM)控制,JMM决定了一个线程对共享变量的写入何时对另一个线程可见。JMM定义了多线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地化内存,本地内存中存储了该线程用以读/写共享变量的副本。本地内存只是JMM的抽象,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。java内存模型的抽象示意,如图6:
2 重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。总的来说重排序分成两类:
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
处理器重排序。现在处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
这些重排序可能会导致多线程出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
重排序对多线程的影响
下面我们从一个很经典的代码例子说明重排序的问题,代码如下:
class RecordExample {
int a = 0 ;
boolean flag = false ;
public void write{
a = 1 ; //步骤1
flage = 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对共享变量a的写入呢?
答案是:不一定能看到。
由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。当操作1和操作2重排序时,可能产生什么效果?如下图7。
如上图,操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读取这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,在这里多线程的语义被重排序破坏了!
3 原子性、可见性、有序性
原子性:
一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在java中当我们讨论一个操作具有原子性问题一般是指这个操作会被线程的随机调度打断。比如下面的操作:
int a = 1; //原子操作
int a = b; //非原子操作,分两步操作第一步读取b的值,第二部将b赋值a
int a = a + 1; //非原子操作,分两步操作第一步读取a的值,第二部将计算结果赋值给a
a ++ ; //非原子操作,同上
JMM对原子性问题的保证如下:
自带原子性保证:在java中,对基本数据类型的变量的读取和赋值操作是原子性操作。
synchronized:synchronized可以保证边界操作结果的原子性。synchronized可以防止多个线程并发的执行同一段代码,从结果上保证原子性。
Lock锁:Lock锁保证原子性的原理和synchronized类似。
原子类操作:JDK提供了很多原子操作类来保证操作的原子性,例如基础类型:AtomicXxx;引用类型AtomicReference等。原子类的底层是使用CAS机制,这个机制对原子性的保证和synchroinized有本质的区别。CAS机制保证了整个赋值操作是原子的不能被打断,二synchronized只能保证代码最终执行结果的正确性,也就是说,synchronized消除了原子性问题对代码最后执行结果的影响。
可见性:
在多线程环境下,一个线程对共享变量的修改,不仅要对本线程可见,而且要对其他线程可见。造成可见性的主要原因是由于CPU多核心和高速缓存(L1,L2,L3)。JMM对可见性问题,提供了如下保证:
volatile:使用volatile关键字修饰一个变量可以保证变量的可见性,大概的保证语义如下
- 线程对共享变量的副本做了修改,会立刻刷新最新值到主内存中。
- 线程对共享变量的副本做了修改,其他其他线程中对这个变量拷贝的副本会时效;其他线程如果需要对这个共享变量进行读写,必须重新从主内存中加载。
synchronized:使用synchronized代码块或者synchronized方法也可以保证共享变量的可见性。当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监听器保护的临界区代码必须从主内存中读取共享变量,从而实现共享变量的可见性。
Lock锁:使用Lock相关实现类也可以保证共享变量的可见性。其原理同synchronized。
原子操作类:原子类底层使用的是CAS机制。java中CAS机制每次都会从主内存中获取最新值进行compare,比较一致之后才会将新值set到主内存中去。而且这个操作是一个原子操作,所以CAS每次操作每次拿到的都是主内存中的最新值,每次set的值也会立即写到主内存中。
有序性:
程序执行的顺序按照代码的先后顺序执行。在JMM允许的重排序环境下,单线程的执行结果和没有重排序的情况下保持一致。JMM中提供一下方式来保证有序性:
happens-before原则:happens-before原则是java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,也就是说发生操作B之前,操作A产生的影响能被操作B观察到。这里的“影响”包括修改共享变量,方法调用。详细的happens-before说明请参看happens-before原则章节。
synchronized机制:synchronized能够保证有序性是因为synchronized可以保证同一时间只有一个线程访问代码块,而单线程环境下,JMM能够保证代码的串行语义;虽然使用synchronized的代码块,还可以发生指令重排序,但是synchronized可以保证只有一个线程执行,所以最后的结果还是正确的。
volatile机制:volatile的底层是使用内存屏障来保障有序性的。写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后。读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前。
多线程面临的两个问题线程之间的通信和线程之间的同步,这两个问题如果仔细分析,从结果的角度看线程之间的通信就是可见性问题,线程之间的同步就是原子性和有序性的问题。
总结JMM对特性提供的支持如下:
特性 | volatile关键字 | synchronized关键字 | Lock接口 | Atomic变量 |
原子性 | 无法保障 | 可以保障 | 可以保障 | 可以保障 |
可见性 | 可以保障 | 可以保障 | 可以保障 | 可以保障 |
有序性 | 一定程度 | 可以保障 | 可以保障 | 无法保障 |
4 happens-before原则
JSR133使用happens-before来阐述操作之间的内存可见性。在JMM中,如果一个操作的结果需要对另一个操作可见,那么这两个操作之间必然要存在happens-before关系。这里提到的两个操作既可以是一个线程之内,也可以是不同线程之间。
在《并发编程的艺术》一书中,对happens-before的定义如下:
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是一个线程之内,也可以是不同线程之间。两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
happens-before规则如下:
程序顺序规则(Program Order Rule):一个线程中的每个操作,happens-before于该线程中的任意后续操作。
监视器锁规则(Monitor Lock Rule):对一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则(Volatile Variable Rule):对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
start规则(Thread Start Rule):如果线程A执行线程B.start(启动线程B),那么A线程的B.start操作happens-before于线程B中的任意操作。
join规则(Thread Join Rule):如果线程A执行线程B.join并成功返回,那么线程B中的任意操作happens-before于线程A从B.join操作成功返回。
程序中断规则(Thread Interruption Rule):对线程interrupt的调用happens-before于被中断线程的interrupted或者isInterrupted。
finalizer规则(Finalizer Rule):一个对象构造函数的结束happens-before于该对象finalizer的开始。
传递性规则(Transitivity):如果A happens-before B,且B happens-before C ,那么A happens-before C。
了解了happens-before原则,下面举例帮助理解:
private int value = 0;
public void setValue(int value)
{
this.value = value;
}
public int getValue
{
return value;
}
假设两个线程A和B,线程A先(在时间上先)调用了这个对象的setValue(1),接着线程B调用了getValue方法,那么B的返回值是多少?
对照happens-before原则,上面的操作不满下面的条件:
- 不是同一个线程,所以不涉及:程序顺序规则。
- 不涉及同步,所以不涉及:监视器锁规则。
- 没有volatile,所以不涉及:volatile变量规则。
- 没有线程的启动和中断,所以不涉及:start规则,join规则,程序中断规则。
- 没有对象的创建和终结,所以不涉及:finalizer规则。
- 更没有传递规则。
所以,一条规则都不满足,尽管线程A在时间上与线程B具有先后顺序,但是,却不满足happens-before原则,也就是有序性并不会保障,所以线程B获取到的数据是不安全的!!!这也反向说明了happens-before原则提到的关系和时间的先后顺序没有关系。
时间先后顺序与先行发生原则之间基本没有太大关系,所以我们衡量并发安全问题的时候不要收到时间顺序的干扰,一切必须以先行发生原则为准。只有真正满足了happens-before原则,才能保证安全。
5 内存屏障
内存屏障(Memory Barrier),也称为内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以执行此点之后的操作。大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
CPU层面的内存屏障
CPU层面的内存屏障分为三类:
写屏障(Store Memory Barrier):告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对写屏障之后的读或者写是可见的。
读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的。
全屏障(Full Memory Barrier):确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作。
JMM层面的内存屏障
在JMM中将内存屏障分为四类:LoadLoad Barrier;StoreStore Barrier;LoadStore Barrier;StoreLoad Barrier,内存屏障的详细解释如下图8(图片来源于《并发编程艺术》):
6 volatile的内存语义
volatile是java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。一方面volatile不会造成上下文切换的开销,另一方面它又不能像synchronized那样保证所有场景下线程安全,因此必须在合适的场景下使用volatile机制。前面一个章节,我们了解到volatile可以支持可见性和有序性,那么它是通过怎样的机制来实现这些特性的?其核心原理就是上一章节描述的内存屏障。
volatile写-读的内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的变量值刷新到主内存。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程将从主内存中读取共享变量。
volatile内存语义的实现
为了实现volatile的内存语义,JMM会限制两种类型的重排序,下图是JMM针对编译器指定的volatile重排序规则表:
是否能重排序 | 第二个操作 | ||
第一个操作 | 普通读/写 | volatile读 | volatile写 |
普通读/写 |
NO |
||
volatile读 |
NO |
NO |
NO |
volatile写 |
NO |
NO |
- 当第二个操作为volatile写操作时,不管第一个操作是什么,都不能进行重排序。这个规则确保volatile写之前的所有操作都不会被重排序到volatile写之后。
- 当第一个操作为volatile读操作时,不管第二个操作时什么,都不能进行重排序。这个规则确保volatile读之后的所有操作都不会被重排序到volatile读之前。
- 当第一个操作时volatile写操作时,第二个操作时读操作,不能进行重排序。
为了实现以上规则,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,下面是基于保守策略(根据不同虚拟机策略不同)的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障(禁止前面的写与volatile写重排序)。
- 在每个volatile写操作的后面插入一个StoreLoad屏障(禁止volatile写与后面可能有的读和写重排序)。
- 在每个volatile读操作的后面插入一个LoadLoad屏障(禁止volatile读与后面的读操作重排序)。
- 在每个volatile读操作的后面插入一个LoadStore屏障(禁止volatile读与后面的写操作重排序)。
下图为volatile写操作插入内存屏障后生成的指令序列示意图。
下图为volatile读操作插入内存屏障后生成的指令序列示意图:
上述volatile写和volatile读的内存屏障插入策略非常保守,在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况忽略不必要的屏障。
7 final的内存语义
在平时的开发过程中常常使用final关键字来修饰方法,保证方法不能被子类重写,那使用final修饰变量又表达什么内存语义呢?
final的内存语义
- 在构造函数内对一个final域的写入,与随后把这个构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读取一个包含final域对象的引用,与随后初次读取这个final域,这两个操作之间不能重排序。
final的内存语义实现
- 写final域的重排序规则会要求编译器在final域写之后,构造函数返回之前,插入一个StoreStore屏障。
- 读final域的重排序规则会要求编译器在final域读之前插入一个LoadLoad屏障。
陈睿mikechen
10年+大厂架构经验,资深技术专家,就职于阿里巴巴、淘宝、百度等一线互联网大厂。
关注「mikechen」公众号,获取更多技术干货!
后台回复【面试】即可获取《史上最全阿里Java面试题总结》,后台回复【架构】,即可获取《阿里架构师进阶专题全部合集》