深入理解java内存模型 -学习笔记
深入理解java虚拟机
JSR133
转载自并发编程网 本文链接地址: 深入理解Java内存模型
volatile
volatile的特性
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,当一个变量被定义为volatile类型之后,它将具备两种特性:
- 可见性:另一种说法是:对于volatile变量的读,总能看到(任意线程)对这个volatile变量最后的写。(当一个线程修改了这个变量的值,新值对被其他线程来说是可以立即得到的)
- 使用volatile的第二个语义是:禁止指令重排
volatile写-读的内存语义
- 当写一个volatile变量时,JMM会把该线程对应本地内存中的共享变量值刷回主内存。
- 当读取一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存读取共享变量。
volatile变量的写-读可以实现线程的相互通信:
由于volatile变量只能保证可见性,在不符合下面两条规则的场景中,我们仍要通过加锁来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够保证只有一个线程修改变量的值;(典型的如volatile++情景)
- 变量不需要与其他的状态变量共同参与不变约束。
public class VolatileTest extends Thread {
private static volatile boolean stop = false;//如果不定义为volatile ,会陷入死循环中。
public void stopMe(){
stop = true;
}
@Override
public void run(){
int i = 0;
while(!stop) {
i++;
}
System.out.println("Stop Thread");
}
public static void main(String[] args) throws InterruptedException {
VolatileTest thread = new VolatileTest();
thread.start();
Thread.sleep(1000);
thread.stopMe();
Thread.sleep(1000);
}
}
volatile内存语义的实现
为了实现volatile内存语义,JMM会限制volatile变量的编译器和处理器重排序,禁止重排序的规则如下:
举例来说:在程序顺序中,当第一个操作是普通变量的读或写,第二个变量为volatile写,则编译器不允许这两个操作重排序。
从上表我们可以看出:
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但是它可以保证在任意处理器平台,任意程序中都能得到正确的volatile内存语义。
jsr-133对volatile语义增强
旧内存模型允许volatile变量与普通变量重排序。JSR-133严格限制了volatile变量与普通变量重排序,使volatile的写-读与后续的锁的释放-获取具有相同的语义。
锁
锁释放-获取的内存语义
锁是java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中; 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。保证了共享变量在同步程序中的各个线程间的可见性
对比锁释放-获取的内存语义 与volatile写-读的内存语义,可以看出: 释放锁与volatile写有相同的语义; 获取锁与valatle读又相同的语音。
对于锁释放-获取的内存语义做个总结:
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
锁内存语义的实现
借助ReentrantLock的源代码,来分析锁内存语义的具体实现机制,下面请看代码示例:
public 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的实现依赖于java同步器框架AbstractQueuedSynchronizer(本文简称之为AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态,马上我们会看到,这个volatile变量是ReentrantLock内存语义实现的关键。 下面是ReentrantLock的类图(仅画出与本文相关的部分):
ReentrantLock分为公平锁和非公平锁(默认非公平锁)。
公平锁
公平的锁:线程获取锁的顺序是按照线程加锁的顺序来分配的,即先来先得的FIFO先进先出顺序。
公平锁lock()的方法调用轨迹:
//1.ReentrantLock.lock()
public void lock() {
sync.lock();
}
//2.ReentrantLock.FairSYnc.lock();
final void lock() {
acquire(1);
}
//3.AbstractQueuedSynchronizer.acquire(int arg);
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
//4.ReentrantLock.FairSYnc.tryAcquire(int arg);
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState(); //读取volatile变量state
if (c == 0) {
if (!hasQueuedPredecessors() && //判断当前线程是否在排队队列中的第一位
compareAndSetState(0, acquires)) { //CAS设置当前线程获取锁
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;
}
解锁方法:
//1.ReentrantLock.unlock()
public void unlock() {
sync.release(1);
}
//2.AbstractQueuedSynchronizer.release(int arg)
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
//3.ReentrantLock.Sync.tryRelease(int arg)
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
return free;
}
非公平锁
非公平锁就是一种获取锁的抢占机制,是随机获得锁的,它允许插队:当一个线程请求非公平锁时,如果在发出请求的同时该锁变成可用状态,那么这个线程会跳过队列中所有的等待线程而获得锁。
非公平锁的释放锁与公平锁的释放锁调用逻辑相同,这里只分析费公平锁的获取:
//1.ReentrantLock.lock()
public void lock() {
sync.lock();
}
//2.ReentrantLock.NonFairSYnc.lock();
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
//3.AbstractQueuedSynchronizer.compareAndSetState(int expect, int update);
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);//原子操作更新state的值。
}
通过对ReentrantLock的分析可以看出,锁释放-获取的内存语义至少具有下面两种方式:
- 利用volatile变量的写-读所具有的内存语义。
- 利用CAS所附带的volatile读和volatile写的内存语义。
concurrent包的实现
由于java的CAS同时具有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可以实现线程之间的通信。把这些特性整合到一起,就形成了java的J.U.C包的实现的基石。仔细观察concurrent包的源码实现,会发现一个通用化的实现模式:
- 首先,声明共享变量为volatile;
- 然后,使用CAS的原子条件更新来实现线程之间的同步;
- 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。
AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent包的实现示意图如下:
final
对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() {
obj = new FinalExample();
}
public static void reader(){
FinalExample object = obj; //读取对象引用
int b = object.j; //读取final域
}
}
假设一个线程A执行writer()方法,随后另外一个线程B执行reader()方法。
写final域的重排序规则
写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则实际上包含了下面两个方面:
- JMM禁止编译器把final域的写重排序到构造函数之外。
- 编译器在final域的写之后,在构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
现在分析下writer()方法,writer()方法只包含一行代码:obj = new FinalExample();
这行代码包含了两个步骤:
- 构造一个FinalExample()对象
- 把这个对象的引用赋值给引用变量obj;
假设线程B读对象引用与读对象的成员域之间没有重排序,下图是一种可能的执行时序:
在上图中,写普通域的操作被编译器重排序到了构造函数之外,读线程B错误的读取了普通变量i未初始化之前的值
。而写final域的操作,被写final域的重排序规则“限定”在了构造函数之内,读线程B正确的读取了final变量初始化之后的值。
写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确的初始化了,
而普通域不具有这个保障。以上图为例,在读线程B”看到”对象引用obj时,很可能obj对象还没有构造完成(对普通域i的写操作被重排序到构造函数之外,此时初始值2还没有写入普通域i);
读final域的重排序规则
读final域的重排序规则如下:
- 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接yilaiguanxi。由于处理器遵守间接依赖关系,因此编译器不会重新排序这两个操作。大多数处理器也会遵守间接依赖,故大多数处理器不会重排序这连个操作。
reader()方法包含三个操作:
- 初次读取引用变量obj
- 初次读取引用变量obj指向对象的普通域i
- 初次读取引用变量obj指向对象的final域j;
现在假设写线程A没有发生任何重排序,同时程序在不遵守间接排序
的处理器上执行。
在上图,读对象的普通域的操作被重排序到读对象引用之前。读普通域时,该域还没有被线程A写入,这是一个错误的读取操作。而读final操作域的重排序规则会把读对象final的操作“限定”在读对象引用之后,此时final域已经初始化完毕,这是一个正确的读取操作。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
(在本例中,如果该引用不为null,那么引用对象的final域一定已经被线程A初始化过了。)
如果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执行writeOne())方法,执行完之后线程B执行writeTwo()方法,执行完线程C执行reader()方法。下面可能是一种线程执行顺序:
在上图中,1是对final域的写入,2是对final域引用的对象的成员域的写入,3是把构造对象的引用赋值给某个引用变量。这里除了1和3不能重排序,2和3也不能重排序。
JMM可以确保读线程C至少能看到线程A在构造函数中final引用对象的成员域的写入(即C至少能读取到intArray[0]值为1)。而写线程B对数组元素的写入,C线程不一定能看到。JMM不保证线程B的写入对线程C可见,因为线程B和线程C存在数据竞争,此时的执行结果不可知。
如果想确保线程C能看到写线程B对数组元素的写入,写线程B和读线程C之间需要同步原语(lock或volatile)来确保内存可见性。
为什么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后面,执行reader()方法的线程仍然可能无法看到final域被初始化的值,因为操作1和操作2之间可能被重排序。
从上图可以看出:在构造函数返回之前,被构造对象的引用依旧不能为其他线程可见:因为此时的final域可能还没有被初始化。
在构造函数返回之后,任意线程都将保证能看到final域正确初始化之后的值。
final语义在处理中的实现
以x86处理器为例,说明final语义在处理器中的实现。
上面提到,写final域的重排序规则会要求编译器在final域的写之后,构造函数return之前,插入一个StoreStore屏障。读final域的重排序规则要求编译器在读final域的操作面前插入一个LoadLoad屏障。
由于x86处理器不会对写-写操作做重排序,所以在x86处理器中,写final域需要的StoreStore屏障会被省略掉。同样,由于x86处理器不会对存在间接依赖关系的操作重排序,所以在x86处理器中,读final域需要的LoadLoad屏障也会被省略掉。也就是说x86处理器中,final域读/写不会插入任何内存屏障。
JSR-133为什么要增加final语义
在旧的java内存模型中,最严重的一个缺陷就是线程可能看到final域的值会改变。比如,一个线程看到一个整型final域的值是0(未初始化时的默认值),过了一段时间之后这个线程再去读这个final域值时,却发现了变成了1.
为了修复这个漏洞,JSR-133专家组增加了final的语义。通过final域增加写和读重排序规则,可以为java程序员提供初始化保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(lock,volatile),就可以保证任意线程都能看到这个final域在构造函数中被初始化的值