深入理解JMM之锁,final,总结
- 1 JMM
- 1.1 锁
- 1.1.1 锁的释放-获取建立的happens-before 关系
- 1.1.2 锁释放和获取的内存语义
- 1.1.3 锁内存语义的实现
- 1.1.3.1 ReentrantLock公平锁
- 1.1.3.2 ReentrantLock非公平锁
- 1.1.4 concurrent包的实现
- 1.2 final
- 1.2.1 写final域的重排序规则
- 1.2.2 读final域的重排序规则
- 1.2.3 引用类型的final域
- 1.2.4 为什么final引用不能从构造函数内“逸出”
- 1.2.5 final语义在处理器中的实现
- 1.2.6 JSR-133为什么要增强final的语义
- 1.3 总结
- 1.3.1 内存模型总结
- 1.3.2 JMM,处理器内存模型与顺序一致性内存模型之间的关系
- 1.3.3 JMM的设计
- 1.3.4 JMM的内存可见性保证
- 1.3.5 JSR-133对旧内存模型的修补
- 1.1 锁
1 JMM
学习此篇文章,
先学习基础java内存模型(JMM)基础详解
然后学习入门JMM之并发模型,重排序规则,顺序一致性
1.1 锁
1.1.1 锁的释放-获取建立的happens-before 关系
锁是java
并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
下面是锁释放-获取的示例代码:
class MonitorExample {
int a = 0;
public synchronized void writer() { //1
a++; //2
} //3
public synchronized void reader() { //4
int i = a; //5
……
} //6
}
假设线程A执行writer()
方法,随后线程B执行reader()
方法。根据happens before
规则,这个过程包含的happens before
关系可以分为两类:
- 根据程序次序规则,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。
- 根据监视器锁规则,3 happens before 4。
- 根据happens before 的传递性,2 happens before 5。
上述happens before
关系的图形化表现形式如下:
在上图中,每一个箭头链接的两个节点,代表了一个happens before
关系。黑色箭头表示程序顺序规则
;橙色箭头表示监视器锁规则
;蓝色箭头表示组合这些规则后提供的happens before保证
上图表示在线程A释放了锁之后,随后线程B获取同一个锁。在上图中,2 happens before 5。因此,线程A在释放锁之前所有可见的共享变量,在线程B获取同一个锁之后,将立刻变得对B线程可见。
1.1.2 锁释放和获取的内存语义
当线程释放锁时,JMM
会把该线程对应的本地内存中的共享变量刷新到主内存中。以上面的MonitorExample
程序为例,A线程释放锁后,共享数据的状态示意图如下:
当线程获取锁时,JMM
会把该线程对应的本地内存置为无效
。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。下面是锁获取的状态示意图:
对比锁释放-获取的内存语义与volatile
写-读的内存语义,可以看出:锁释放与volatile
写有相同的内存语义;锁获取与volatile
读有相同的内存语义。
下面对锁释放和锁获取的内存语义做个总结:
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
1.1.3 锁内存语义的实现
本文将借助ReentrantLock
的源代码,来分析锁内存语义的具体实现机制。
请看下面的示例代码:
class ReentrantLockExample {
int a = 0;
ReentrantLock lock = new ReentrantLock();
public void writer() {
lock.lock(); //获取锁
try {
a++;
} finally {
lock.unlock(); //释放锁
}
}
public void reader () {
lock.lock(); //获取锁
try {
int i = a;
……
} finally {
lock.unlock(); //释放锁
}
}
}
在ReentrantLock
中,调用lock()
方法获取锁;调用unlock()
方法释放锁。
ReentrantLock
的实现依赖于java
同步器框架AbstractQueuedSynchronizer
(简称之为AQS
)。AQS
使用一个整型的volatile
变量(命名为state
)来维护同步状态,马上我们会看到,这个volatile
变量是ReentrantLock
内存语义实现的关键。 下面是ReentrantLock
的类图(仅画出与本文相关的部分):
ReentrantLock
分为公平锁
和非公平锁
,我们首先分析公平锁。
1.1.3.1 ReentrantLock公平锁
使用公平锁时,加锁方法lock()
的方法调用轨迹如下:
- ReentrantLock : lock()
- FairSync : lock()
- AbstractQueuedSynchronizer : acquire(int arg)
- ReentrantLock : tryAcquire(int acquires)
在第4步真正开始加锁,下面是该方法的源代码:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState(); //获取锁的开始,首先读volatile变量state
if (c == 0) {
if (isFirst(current) &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
从上面源代码中我们可以看出,加锁方法首先读volatile
变量state
。
在使用公平锁时,解锁方法unlock()
的方法调用轨迹如下:
- ReentrantLock : unlock()
- AbstractQueuedSynchronizer : release(int arg)
- Sync : tryRelease(int releases)
在第3步真正开始释放锁,下面是该方法的源代码:
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c); //释放锁的最后,写volatile变量state
return free;
}
从上面的源代码我们可以看出,在释放锁的最后写volatile
变量state
。
公平锁在释放锁的最后写volatile
变量state
;在获取锁时首先读这个volatile
变量。根据volatile
的happens-before
规则,释放锁的线程在写volatile
变量之前可见的共享变量,在获取锁的线程读取同一个volatile
变量后将立即变的对获取锁的线程可见。
1.1.3.2 ReentrantLock非公平锁
现在我们分析非公平锁的内存语义的实现。
非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取。
使用公平锁时,加锁方法lock()
的方法调用轨迹如下:
- ReentrantLock : lock()
- NonfairSync : lock()
- AbstractQueuedSynchronizer : compareAndSetState(int expect, int update)
在第3步真正开始加锁,下面是该方法的源代码:
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
该方法以原子操作的方式更新state
变量,把java
的compareAndSet()
方法调用简称为CAS
。JDK
文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有 volatile
读和写的内存语义。
这里我们分别从编译器和处理器的角度来分析,CAS
如何同时具有volatile
读和volatile写
的内存语义。
编译器不会对volatile
读与volatile
读后面的任意内存操作重排序;编译器不会对volatile
写与volatile
写前面的任意内存操作重排序。组合这两个条件,意味着为了同时实现volatile
读和volatile
写的内存语义,编译器不能对CAS
与CAS
前面和后面的任意内存操作重排序。
下面我们来分析在常见的intel x86
处理器中,CAS
是如何同时具有volatile
读和volatile
写的内存语义的。
下面是sun.misc.Unsafe
类的compareAndSwapInt()
方法的源代码:
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);
可以看到这是个本地方法调用。这个本地方法在openjdk中依次调用的c++代码为:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp
。这个本地方法的最终实现在openjdk的如下位置:openjdk-7-fcs-src-b147-27jun2011\openjdk\hotspot\src\oscpu\windowsx86\vm\ atomicwindowsx86.inline.hpp(对应于windows操作系统,X86处理器)。下面是对应于intel x86处理器的源代码的片段:
// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
__asm je L0 \
__asm _emit 0xF0 \
__asm L0:
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
// alternative for InterlockedCompareExchange
int mp = os::is_MP();
__asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}
如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg
指令添加lock
前缀。如果程序是在多处理器上运行,就为cmpxchg
指令加上lock
前缀(lock cmpxchg
)。反之,如果程序是在单处理器上运行,就省略lock
前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock
前缀提供的内存屏障效果)。
intel
的手册对lock
前缀的说明如下:
- 确保对内存的
读-改-写
操作原子执行。在Pentium
及Pentium
之前的处理器中,带有lock
前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域
在lock
前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行
中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定
,缓存锁定将大大降低lock
前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。- 禁止该指令与之前和之后的读和写指令重排序。
- 把写缓冲区中的所有数据刷新到内存中。
上面的第2点和第3点所具有的内存屏障效果,足以同时实现volatile
读和volatile
写的内存语义。
经过上面的这些分析,现在我们终于能明白为什么JDK
文档说CAS
同时具有volatile
读和volatile
写的内存语义了。
现在对公平锁
和非公平锁
的内存语义做个总结:
- 公平锁和非公平锁释放时,最后都要写一个
volatile
变量state
。 - 公平锁获取时,首先会去读这个
volatile
变量。 - 非公平锁获取时,首先会用
CAS
更新这个volatile
变量,这个操作同时具有volatile
读和volatile
写的内存语义。
从本文对ReentrantLock的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式:
- 利用
volatile
变量的写-读所具有的内存语义。 - 利用
CAS
所附带的volatile
读和volatile
写的内存语义。
1.1.4 concurrent包的实现
由于java
的CAS
同时具有 volatile读
和volatile写
的内存语义,因此Java线程之间的通信现在有了下面四种方式:
- A线程写
volatile
变量,随后B线程读这个volatile
变量。 - A线程写
volatile
变量,随后B线程用CAS
更新这个volatile
变量。 - A线程用
CAS
更新一个volatile
变量,随后B线程用CAS
更新这个volatile
变量。 - A线程用
CAS
更新一个volatile
变量,随后B线程读这个volatile
变量。
Java
的CAS
会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写
指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile
变量的读/写和CAS
可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent
包得以实现的基石。如果我们仔细分析concurrent
包的源代码实现,会发现一个通用化的实现模式:
- 声明共享变量为
volatile
; - 使用
CAS
的原子条件更新来实现线程之间的同步; - 配合以
volatile
的读/写和CAS
所具有的volatile
读和写的内存语义来实现线程之间的通信。
AQS
,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent
包中的基础类都是使用这种模式来实现的,而concurrent
包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent
包的实现示意图如下:
1.2 final
与前面介绍的锁和volatile
相比较,对final
域的读和写更像是普通的变量访问。对于final
域,编译器和处理器要遵守两个重排序规则:
- 在构造函数内对一个
final
域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。 - 初次读一个包含
final
域的对象的引用,与随后初次读这个final
域,这两个操作之间不能重排序。
下面,我们通过一些示例性的代码来分别说明这两个规则:
public class FinalExample {
int i; //普通变量
final int j; //final变量
static FinalExample obj;
public FinalExample () { //构造函数
i = 1; //写普通域
j = 2; //写final域
}
public static void writer () { //写线程A执行
obj = new FinalExample ();
}
public static void reader () { //读线程B执行
FinalExample object = obj; //读对象引用
int a = object.i; //读普通域
int b = object.j; //读final域
}
}
这里假设一个线程A执行writer()
方法,随后另一个线程B执行reader()
方法。下面我们通过这两个线程的交互来说明这两个规则。
1.2.1 写final域的重排序规则
写final
域的重排序规则禁止把final
域的写重排序到构造函数之外。这个规则的实现包含下面2个方面:
JMM
禁止编译器把final
域的写重排序到构造函数之外。- 编译器会在
final
域的写之后,构造函数return
之前,插入一个StoreStore
屏障。这个屏障禁止处理器把final
域的写重排序到构造函数之外。
现在让我们分析writer ()
方法。writer ()
方法只包含一行代码:finalExample = new FinalExample ()
。这行代码包含两个步骤:
- 构造一个
FinalExample
类型的对象; - 把这个对象的引用赋值给引用变量
obj
。
假设线程B读对象引用与读对象的成员域之间没有重排序(马上会说明为什么需要这个假设),下图是一种可能的执行时序:
在上图中,写普通域的操作被编译器重排序到了构造函数之外,读线程B错误的读取了普通变量i初始化之前的值。而写final域
的操作,被写final域的重排序规则限定
在了构造函数之内,读线程B正确的读取了final
变量初始化之后的值。
写final
域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final
域已经被正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程B“看到”对象引用obj
时,很可能obj
对象还没有构造完成(对普通域i
的写操作被重排序到构造函数外,此时初始值2还没有写入普通域i)。
1.2.2 读final域的重排序规则
读final
域的重排序规则如下:
在一个线程中,初次读对象引用与初次读该对象包含的final
域,JMM
禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final
域操作的前面插入一个LoadLoad
屏障。
初次读对象引用与初次读该对象包含的final
域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序,这个规则就是专门用来针对这种处理器。
reader()
方法包含三个操作:
- 初次读引用变量obj;
- 初次读引用变量obj指向对象的普通域j。
- 初次读引用变量obj指向对象的final域i。
现在我们假设写线程A没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,下面是一种可能的执行时序:
在上图中,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程A写入,这是一个错误的读取操作。而读final
域的重排序规则会把读对象final
域的操作限定
在读对象引用之后,此时该final
域已经被A线程初始化过了,这是一个正确的读取操作。
读final
域的重排序规则可以确保:在读一个对象的final
域之前,一定会先读包含这个final
域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final
域一定已经被A线程初始化过了。
1.2.3 引用类型的final域
上面我们看到的final
域是基础数据类型,下面让我们看看如果final
域是引用类型,将会有什么效果?
请看下列示例代码:
public class FinalReferenceExample {
final int[] intArray; //final是引用类型
static FinalReferenceExample obj;
public FinalReferenceExample () { //构造函数
intArray = new int[1]; //1
intArray[0] = 1; //2
}
public static void writerOne () { //写线程A执行
obj = new FinalReferenceExample (); //3
}
public static void writerTwo () { //写线程B执行
obj.intArray[0] = 2; //4
}
public static void reader () { //读线程C执行
if (obj != null) { //5
int temp1 = obj.intArray[0]; //6
}
}
}
这里final
域为一个引用类型,它引用一个int
型的数组对象。对于引用类型,写final
域的重排序规则对编译器和处理器增加了如下约束:
在构造函数内对一个final
引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
对上面的示例程序,我们假设首先线程A执行writerOne()
方法,执行完后线程B执行writerTwo()
方法,执行完后线程C执行reader ()
方法。下面是一种可能的线程执行时序:
在上图中,1是对final
域的写入,2是对这个final
域引用的对象的成员域的写入,3是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的1不能和3重排序外,2和3也不能重排序
JMM
可以确保读线程C至少能看到写线程A在构造函数中对final
引用对象的成员域的写入。即C至少能看到数组下标0的值为1。而写线程B对数组元素的写入,读线程C可能看的到,也可能看不到。JMM
不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。
如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock
或volatile
)来确保内存可见性。
1.2.4 为什么final引用不能从构造函数内“逸出”
前面我们提到过,写final
域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final
域已经在构造函数中被正确初始化过了。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。为了说明问题,让我们来看下面示例代码:
public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample () {
i = 1; //1写final域
obj = this; //2 this引用在此“逸出”
}
public static void writer() {
new FinalReferenceEscapeExample ();
}
public static void reader {
if (obj != null) { //3
int temp = obj.i; //4
}
}
}
假设一个线程A执行writer()
方法,另一个线程B执行reader()
方法。这里的操作2使得对象还未完成构造前就为线程B可见。即使这里的操作2是构造函数的最后一步,且即使在程序中操作2排在操作1后面,执行read()
方法的线程仍然可能无法看到final
域被初始化后的值,因为这里的操作1和操作2之间可能被重排序。实际的执行时序可能如下图所示:
从上图我们可以看出:在构造函数返回前,被构造对象的引用不能为其他线程可见,因为此时的final
域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final
域正确初始化之后的值。
1.2.5 final语义在处理器中的实现
现在以x86
处理器为例,说明final
语义在处理器中的具体实现。
上面我们提到,写final
域的重排序规则会要求译编器在final
域的写之后,构造函数return
之前,插入一个StoreStore
障屏。读final
域的重排序规则要求编译器在读final
域的操作前面插入一个LoadLoad
屏障。
由于x86
处理器不会对写-写
操作做重排序,所以在x86
处理器中,写final
域需要的StoreStore
障屏会被省略掉。同样,由于x86
处理器不会对存在间接依赖关系的操作做重排序,所以在x86
处理器中,读final
域需要的LoadLoad
屏障也会被省略掉。也就是说在x86
处理器中,final
域的读/写不会插入任何内存屏障
1.2.6 JSR-133为什么要增强final的语义
在旧的Java
内存模型中 ,最严重的一个缺陷就是线程可能看到final
域的值会改变。比如,一个线程当前看到一个整形final
域的值为0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个final
域的值时,却发现值变为了1(被某个线程初始化之后的值)。最常见的例子就是在旧的Java
内存模型中,String
的值可能会改变
为了修补这个漏洞,JSR-133
专家组增强了final
的语义。通过为final
域增加写和读重排序规则,可以为java
程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指lock
和volatile
的使用),就可以保证任意线程都能看到这个final
域在构造函数中被初始化之后的值
1.3 总结
1.3.1 内存模型总结
顺序一致性内存模型是一个理论参考模型,JMM
和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。JMM
和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和JMM
,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。
根据对不同类型读/写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:
- 放松程序中写-读操作的顺序,由此产生了total store ordering内存模型(简称为
TSO
)。 - 在前面1的基础上,继续放松程序中写-写操作的顺序,由此产生了partial store order 内存模型(简称为
PSO
)。 - 在前面1和2的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了relaxed memory order内存模型(简称为
RMO
)和PowerPC
内存模型。
注意,这里处理器对读/写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守as-if-serial
语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。
下面的表格展示了常见处理器内存模型的细节特征:
内存模型名称 | 对应的处理器 | Store-Load 重排序 | Store-Store重排序 | Load-Load 和Load-Store重排序 | 可以更早读取到其它处理器的写 | 可以更早读取到当前处理器的写 |
---|---|---|---|---|---|---|
TSO | sparc-TSOX64 | Y | Y | |||
PSO | sparc-PSO | Y | Y | Y | ||
RMO | ia64 | Y | Y | Y | Y | |
PowerPC | PowerPC | Y | Y | Y | Y | Y |
在这个表格中,我们可以看到所有处理器内存模型都允许写-读重排序,它们都使用了写缓存区,写缓存区可能导致写-读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区:由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己的写缓存区中的写。
上面表格中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。
由于常见的处理器内存模型比JMM
要弱,java
编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM
在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了JMM
在不同处理器内存模型中需要插入的内存屏障的示意图:
如上图所示,JMM
屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为java
程序员呈现了一个一致的内存模型。
1.3.2 JMM,处理器内存模型与顺序一致性内存模型之间的关系
JMM
是一个语言级的内存模型
,处理器内存模型是硬件级的内存模型
,顺序一致性内存模型是一个理论参考模型
。下面是语言内存模型,处理器内存模型和顺序一致性内存模型的强弱对比示意图:
从上图我们可以看出:常见的4种处理器内存模型比常用的3中语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。
1.3.3 JMM的设计
从JMM
设计者的角度来说,在设计JMM
时,需要考虑两个关键因素:
- 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型来编写代码。
- 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
由于这两个因素互相矛盾,所以JSR-133
专家组在设计JMM
时的核心目标就是找到一个好的平衡点:一方面要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能的放松。下面让我们看看JSR-133
是如何实现这一目标的。
为了具体说明,请看前面提到过的计算圆面积的示例代码:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面计算圆的面积的示例代码存在三个happens- before关系:
- A happens- before B;
- B happens- before C;
- A happens- before C;
由于A happens- before B
,happens- before
的定义会要求:A操作执行的结果要对B可见,且A操作的执行顺序排在B操作之前。 但是从程序语义的角度来说,对A和B做重排序即不会改变程序的执行结果,也还能提高程序的执行性能(允许这种重排序减少了对编译器和处理器优化的束缚)。也就是说,上面这3个happens- before关系中,虽然2和3是必需要的,但1是不必要的。因此,JMM
把happens- before要求禁止的重排序分为了下面两类: - 会改变程序执行结果的重排序。
- 不会改变程序执行结果的重排序。
JMM对这两种不同性质的重排序,采取了不同的策略:
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不作要求(JMM允许这种重排序)。
下面是JMM的设计示意图:
从上图可以看出两点:
JMM
向程序员提供的happens- before
规则能满足程序员的需求。JMM
的happens- before
规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的A happens- before B
)。
JMM
对编译器和处理器的束缚已经尽可能的少。
从上面的分析我们可以看出,JMM
其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再比如,如果编译器经过细致的分析后,认定一个volatile
变量仅仅只会被单个线程访问,那么编译器可以把这个volatile
变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
1.3.4 JMM的内存可见性保证
Java
程序的内存可见性保证按程序类型可以分为下列三类:
- 单线程程序。单线程程序不会出现内存可见性问题。编译器,
runtime
和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。 - 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是
JMM
关注的重点,JMM
通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。 - 未同步/未正确同步的多线程程序。
JMM
为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。
下图展示了这三类程序在JMM中与在顺序一致性内存模型中的执行结果的异同:
只要多线程程序是正确同步的,JMM
保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致
1.3.5 JSR-133对旧内存模型的修补
JSR-133
对JDK5
之前的旧内存模型的修补主要有两个:
- 增强
volatile
的内存语义。旧内存模型允许volatile
变量与普通变量重排序。JSR-133
严格限制volatile
变量与普通变量的重排序,使volatile
的写-读和锁的释放-获取具有相同的内存语义。 - 增强
final
的内存语义。在旧内存模型中,多次读取同一个final
变量的值可能会不相同。为此,JSR-133
为final
增加了两个重排序规则。现在,final
具有了初始化安全性