Java内存模型
1.1并发编程概述
在并发编程中,需要处理两个关键问题: 线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息,在命令式编程中,线程之间的通信机制有两种: 共享内存
与消息传递
。
在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。
同步是指程序中用于控制不同线程间操作发生相对顺序的机制
。在共享内存并发模型里,同步时显示
进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员来说完全透明(不可见),如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,和可能会遇到各种各样的内存可见性问题。
1.2Java内存模型的抽象结构
Java线程之间的通信由Java内存模型(简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中(Main Memory)中,每个线程都有一个私有的本地内存(工作内存),本地内存中存储了该线程以读/写共享变量的副本
,本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下图所示。
从上图来看,如果线程A要与线程B进行通信,必须要经历下面2个步骤
1、线程A将本地内存A中更新过的共享变量刷新到主存中
2、线程B到主存中去读取线程A之前已更新过的共享变量
从整体上看,这两个步骤的实质是线程A在向线程B发送消息,而且这个通信过程必须要经过主存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存的可见性保证
1.3从源代码到指令序列的重排序
在执行程序时,为了提高性能,编译器
和处理器
常常会对指令做重排序。重排序分为三种类型。 即 重排序是指编译器和处理器
为了优化程序性能而对指令序列进行重新排列的一种手段。
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
- 指令级并行的重排序。
- 内存系统的重排序。
从Java源代码到最终实际执行的指令序列,会分别经历下面三种重排序,如下图所示。
上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见行问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers Intel 称之为Memory Fence),指令,通过内存屏障指令来禁止特定类型的处理器重排序
。
1.4四种内存屏障的类型
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序
。JMM把内存屏障分为4类。
屏障类型 | 指令实例 | 说明 |
---|---|---|
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会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令 |
StoreLoad Barriers是一个"全能型" 的屏障,它同时具有其他三个屏障的效果。现代的多处理器大多支持该屏障。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区的数据全部刷新到内存中。
1.5happens-before简介
从JDK5开始,Java使用新的JSR-133内存模型,JSR-133模使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作时间必须要存在happens-before关系。这里提到的两个操作既可以是在同一个线程中,也可以在不同的线程之间
。
与程序员密切相关的happens-before规则如下。
- 程序顺序固规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
- 传递性: 如果A happens-before B,且B happens-before C,那么A happens -before C
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)
对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
happens-before与JMM的关系
如上图所示,一个happens-before规则对应于一个或多个编译器和处理器重排序规则
。对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JVM提供的内存可见性保证而去学习复杂的重排序规则以及对这些规则的具体实现方法。
1.6数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列三种类型。如下表。
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1;b = a; | 写一个变量之后,再读这个位置 |
写后写 | a = 1; a = 2; | 写一个变量后,在写这个变量 |
读后写 | a = b;b = 1; | 读一个变量后,再写这个变量 |
以上三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
前面提到过,编译器和处理器可能会对操作做重排序,编译器和处理器在重排序时,会遵守数据依赖性
,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序
这里所说的数据依赖性仅针对单个处理器
中执行的指令序列和单个线程
中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
1.7as-if-serial语义
as-if-serial语义的意思是:不管怎样重排序(编译器和处理器为了提高并行度), (单线程)程序的执行结果不能被改变
。编译器、runtime、和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的,as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见型的问题。
2.Volatile的内存语义
volatile的特性
class A{
volatile long v1 = 0l;
public void set(long l){
vl = l; //单个volatile变量的写
}
public void getAndIncrement(){
vl ++; //复合volatile变量的读/写
}
public long get(){
return vl; //单个volatile变量的读
}
}
// 假设有多线程分别调用上面的三个方法 这个程序在语义上和下面程序等价
class A{
long vl = 0l; //64位long型普通变量
public synchronized void set(long l){
vl = l; //对单个的普通变量的写用同一个锁同步
}
public void getAndIncrement(){
long temp = get();
temp += 1L;
set(temp);
}
public synchronized long get(){
return vl; //对单个的普通变量的读用同一个锁同步
}
}
一个volatile变量的单个读写操作,与一个普通变量的读/写操作都是使用同一个锁来同步的。它们之间的执行效果相同。
锁的happens-before规则保证释放锁和获取锁两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
锁的语义决定了临界区代码的执行具有原子性,这意味着,即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。
简而言之,volatile变量自身具有下列特征
可见性
。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入原子性
:对任意单个
volatile变量的读/写具有原子性,但类似 volatile++ 这种复合操作不具有原子性。
volatile写-读的内存语义
volatile写的内存语义:
当写一个volatile变量时,JMM会把该线程对应的本地内存的共享变量值刷新到主内存
volatile读的内存语义:
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
如果我们把volatile写和volatile读这两个步骤综合起来看的话,在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前的所有可见的共享变量的值都将立即变得对线程B可见。
下面对volatile写和volatile读的内存语义做个总结:
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)信息。
- 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做的修改的)消息。
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
volatile内存语义的实现
(内存屏障)
上面提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。
- 在程序中,当一个操作为普通变量的读或写时,如果第二次操作为volatile写,则编译器不能重排序这两个操作
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。
这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后
- 当第一个操作时volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前
- 当第一个操作是volatile写,第二个操作是volatile时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优不知来最小化插入内存屏障的总数几乎不可能。为此,JMM采取保守策略。下面是就保守策略的JMM内存屏障插入策略。
- 在每个volatile写操作的前面插入一个StoreStore屏障
- 在每个volatile写操作的后面插入一个StoreLoad屏障
- 在每个volatile读操作的后面插入一个LoadLoad屏障
- 在每个volatile读操作的后面插入一个LoadStore屏障
JSR-133增强volatile的内存语义
从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。
3.锁的内存语义
锁的释放和获取的内存语义
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存读取共享变量。
对比锁释放-获取的内存语义与volatile写-读的内存语义可以看出: 锁释放与volatile写有相同的内存语义:锁获取与volatile读有相同的内存语义,下面对锁释放和锁获取的内存语义做个总结。
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程(线程A对共享变量所做的修改)的信息
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)信息
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
所内存语义的实现
本文将借助ReentrantLock的源代码,来分析锁内存语义的具体实现机制。
在ReentrantLock中,调用lock()方法获取锁,调用unlock方法释放锁。
ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer即(AQS, AQS详细讲解在后面文章),AQS使用一个整型的volatile变量 (命名为state) 来维护同步状态。这个volatile变量是ReentrantLock内存语义实现的关键。
加锁方法首先读volatile变量state。释放锁的最后写volatile变量state。
加锁的源代码
protected final boolean compareAndSetState(int expect, int update){
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
该方法以原子操作的方式更新state,本文将Java的compareAndSet()方法调用简称为CAS,JDK文档对方法说明如下: 如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有volatile读和写的内存语义(加入Lock前缀)
上面我们提到过,编译器不会对volatile读与volatile读后面的任意内存操作重排序: 编译器不会对volatile写与volatile写前面的任意内存操作重排序。组合这两个条件,意味着为了同时实现volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操作重排序。
下面我们来分析在常见的intel X86处理器中,CAS是如何同时具有volatile读和volatile写的内存语义的。下面是sun.misc.Unsafe类的compareAdnSwapInt()方法的源码。
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
这是一个本地方法调用。在对应intel X86处理器的源代码片段中,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果是在多处理器上运行,就为cmpxchg指令加上lock前缀,反之,如果是在单处理器上运行,就省略lock前缀。
intel的手册对lock前缀的说明如下:
- 1、确保对内存的读-该-写操作原子执行。在Penium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium4、Intel Xeon及P6处理器开始,Intel使用缓存锁定(Cache Locking)来保证指令执行的原子性。缓存锁定将大大降低lock前缀指令的开销
- 2、
禁止该指令,与之前和之后的读和写指令重排序
- 3、
把写缓冲区中的所有数据刷新到内存中
上面的第2点和第3点所具有的的内存屏障效果,足以实现volatile读和volatile写的内存语义。
现在对公平锁与非公平锁的内存语义做个总结
- 公平锁和非公平锁释放时,最后都要写一个volatile变量state
- 公平锁获取时,首先会去读volatile变量
- 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和写的内存语义。
从本文对ReentrantLock的分析可以看出,锁释放-获取的内存语义的实现至少有两种方式
1、利用volatile变量的写-读所具有的内存语义
2、利用CAS所附带的volatile读和volatile写的内存语义
concurrent包的实现
由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信有了下面4中方式
- A线程写volatile变量,随后B线程读这个volatile变量
- A线程写volatile变量,随后B线程用CAS更新这个volatile变量
- A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量
- A线程用CAS跟新更新volatile变量,随后B线程读这个volatile变量。
我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。
- 首先,声明共享变量为volatile
- 然后,使用CAS的原子条件更新来实现线程之间的同步
- 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。
4.final域的内存语义
对于final域,编译器和处理器要遵守两个重排序规则。
1) 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
写final域的重排序规则禁止把final域的写重排序到构造函数之外,这个规则的实现包含下面两个方面。
1、JMM禁止编译器把final域的写重排序到构造函数外
2、编译器会把在final域的写之后,构造函数return之前,插入一个StoreStore屏障,这个屏障进制处理器把final域的写重排序到构造函数之外。
读final域的重排序规则
读final域的重排序规则是,在一个线程中,初次读对象引用与初次读对象包含的final域,JMM禁止处理器重排序这两个操作(这个操作仅仅针对处理器)
。编译器会在读final域操作的前面插入一个LoadLoad屏障。
初次读对象引用与初次读对象包含的final域这两个操作之间存在间接依赖关系,由于编译器遵守间接依赖关系,因此编译器也不会重排序这两个操作。
写final域的重排序规则对编译器和处理器增加了如下约束:
在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
为什么final引用不能从构造函数内逸出?
写final域的重排序规则可以保证:在引用变量为任意线程可见之前,该引用变量执行的对象的final域已经在构造函数中被正确初始化过了。其实要得到这个效果,还需要另外一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中逸出
原因就是逸出后可能读到未初始化的变量。
5.happens-before总结
JMM把happens-before要求禁止的重排序分为下面两类。
- 会改变程序执行结果的重排序
- 不会改变程序执行结果的重排序
JMM对这两种不同性质的重排序,采取了不同的策略。
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须
禁止
这种重排序。 - 对于不会改变程序执行结果的重排序,JMM对编译器和处理器
不做要求(JMM允许这种重排序)
JMM对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行效果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行
。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再入,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当做一个普通变量来看待。这些优化既不会改变程序的执行效果,又能提高程序的执行效率
happens-before的定义
JSR-133使用happens-before的概念来指定两个操作之间的执行顺序,由于这两个操作可以在一个线程之内,也可以在不同线程之间。因此JMM可以通过happens-before关系向程序员提供跨线程的内存可见行保证(如果A线程的写操作a与B线程的操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见
)
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行,如果重排序之后的执行结果,与按happend-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)
as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
happens-before规则
- 程序顺序规则: 一个线程中的每个操作,happens-before于该线程中的任意后续操作
- 监视器锁规则:
对一个锁的解锁,happens-before于随后对这个锁的加锁
- volatile变量规则:
对一个volatile域的写,happens-before于任意后续对这个volatile域的读
- 传递性:
如果A happens-before B,且B happens-before C,那么A happens-before C
- start()规则: 如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
- join()规则,如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
6.双重检查锁定与延迟初始化
双重检查锁单例模式
public class Person{
private volatile static Person instance;
public static Person getInstance(){
if(instance == null){ //提高效率 后面的线程直接判断不为null直接返回了 不会被阻塞
synchronized (Person.clas){
if(instance == null){
instance = new Person();
}
}
}
return instance;
}
}
为什么变量加volatile
关键在于 instance = new Person() 这一行,创建了一个对象,这一行代码可以分解为如下的三行伪代码。
memory = allocate(); //分配对象的内存空间 1
ctorInstance(memory) //初始化对象 2
instance = memory() //设置instance指向刚分配的 3
上面的三行伪代码2和3之间,可能会重排序(在一些JIT编译器上,这种重排序是真实发生的)
memory = allocate(); //分配对象的内存空间 1
instance = memory() //设置instance指向刚分配的 2 (注意 此时对象还没有被初始化)
ctorInstance(memory) //初始化对象 3
加入volatile后,编译器就会插入内存屏障,禁止指令的重排序, 避免在多线程下2和3重排序导致一些线程拿到还没有被初始化的对象。
基于类初始化的解决方案。允许 2 和 3重排序,但不允许其他线程 "看到"这种重排序
JVM在类的初始化阶段(及在Class被加载后,且被线程使用之前)会执行类的初始化(类加载过程),在执行类的初始化期间,JVM会获取一个锁,这个锁可以同步多个线程对一个类的初始化
基于这种特性,可以实现另一种线程安全的延迟初始化方案,被称之为(Initiallization On Demand Holder idiom)。
public class InstanceFactory{
private static InstanceHolder{
public static Instance instance = new Instance();
}
public static Instance getInstance(){
return InstanceHolder.instance; //这里将导致InstanceHolder 类初始化
}
}
类加载过程),在执行类的初始化期间,JVM会获取一个锁,这个锁可以同步多个线程对一个类的初始化
基于这种特性,可以实现另一种线程安全的延迟初始化方案,被称之为(Initiallization On Demand Holder idiom)。
public class InstanceFactory{
private static InstanceHolder{
public static Instance instance = new Instance();
}
public static Instance getInstance(){
return InstanceHolder.instance; //这里将导致InstanceHolder 类初始化
}
}