文章目录
-
- volatile的内存语义
-
- volatile的特性
- [volatile 写 - 读建立的happens-before关系](#volatile 写 - 读建立的happens-before关系)
- [volatile 写 - 读的内存语义](#volatile 写 - 读的内存语义)
- 锁的内存语义
-
- [锁的释放 - 获取建立的happens-before关系](#锁的释放 - 获取建立的happens-before关系)
- 锁的释放和获取的内存语义
- 锁内存语义的实现
- final域的内存语义
volatile的内存语义
当声明共享变量为volatile后,对这个变量的读/写将会很特别。
volatile的特性
- 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性。对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
volatile 写 - 读建立的happens-before关系
从内存语义来看,volatile的写 - 读 与 锁的释放 - 获取有相同的内存效果;volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。
看下面使用volatile变量的示例代码
java
public class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
public void reader() {
if (flag) { // 3
int i = a; // 4
// ......
}
}
// ......
}
假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens-before规则,这个过程建立的happens-before关系可以分为3类:
- 根据程序次序规则,1 happen-before 2;3 happens-before 4
- 根据volatile规则,2 happens-before 3
- 根据happens-before的传递性规则,1 happens-before 4
上述happens-before关系的图形化表现形式如下;
在上图中,每一个箭头链接的两个节点,代表了一个happens-before关系。黑色箭头表示程序顺序规则;橙色箭头表示volatile规则;绿色箭头表示组合这些规则后提供的happens-before保证。
这里A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。
volatile 写 - 读的内存语义
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
以上面的示例程序VolatileExample为例,假设线程A首先执行writer()方法,随后线程B执行reader()方法,初始时两个线程的本地内存中的flag和a都是初始状态。下图是线程A执行volatile写后,共享变量的状态示意图。
如上图所示,线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的。
volatile读的内存语义如下:
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
线程B读同一个volatile变量后,共享变量的状态示意图如下所示:
如上图所示,在读flag变量后,本地内存B包含的值已经被置为无效了。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成一致。
下面对volatile写和volatile读的内存语义做个总结:
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
- 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做的修改)消息
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
锁的内存语义
锁的释放 - 获取建立的happens-before关系
锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发消息。
下面是锁释放 - 获取的示例代码
java
public 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关系可以分为3类。
- 根据程序次序规则,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保证。
锁的释放和获取的内存语义
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。以上面的MonitorExample程序为例,A线程释放锁后,共享数据的状态示意图如下所示
当线程获取锁时,JMM会把线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。如下图所示
对比锁释放 - 获取的内存语义与volatile写 - 读的内存语义可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。
下面对锁释放和锁获取的内存语义做个总结:
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(其对共享变量所做修改的)消息。
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做的修改)消息
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
锁内存语义的实现
借助ReentrantLock来分析锁内存语义的具体实现机制。
java
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分为公平锁和非公平锁,首先分析公平锁
使用公平锁时,加锁方式lock()调用轨迹如下:
- ReentrantLock:lock()
- FairSync:lock()
- AbstractQueuedSynchronizer:acquire(int arg)
- ReentrantLock:tryAcquire(int acquires)
在第4步真正开始加锁,下面是该方法
java
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState(); // 获取锁的开始,首先读volatile变量state
if (c == 0) {
if (!hasQueuedPredecessors() &&
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)
java
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变量后将立即变得对获取锁的线程可见。
接下来分析非公平锁的内存语义的实现。非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取。使用非公平锁时,加锁方法lock()调用轨迹如下:
- ReentrantLock:lock()
- NonfairSync:lock()
- AbstractQueuedSynchronizer:compareAndSetState(int expect, int update)
java
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
该方法以原子操作的方式更新state变量,接下来compareAndSetState简称为CAS。此操作具有volatile读和写的内存语义。
之前提到,编译器不会对volatile读与volatile读后面的任意内存操作重排序;编译器不会对volatile写与volatile写前面的任意内存操作重排序。组合这两个条件,意味着同时实现了volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操作重排序。
final域的内存语义
final域的重排序规则
对于final域,编译器和处理器要遵守两个重排序规则。
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序(先写final域,再把构造对象的引用赋给引用变量)。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序(先读包含final域对象的引用,再读final域)。
下面通过代码示例来说明这两个规则
java
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方法。下面通过两个线程的交互来说明这两个规则。
写final域的重排序规则
写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含2个方面;
- JMM禁止编译器把final域的写重排序到构造函数之外。
- 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
在下图中,写普通域的操作被编译器重排序到了构造函数之外,读线程B错误地读取了普通变量i初始化之前的值。而写final域的操作,被写final域的重排序规则限定在了构造函数之内,读线程B正确地读取了final变量初始化之后的值。
读final域的重排序规则
读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在读final域操作的前面插入一个LoadLoad屏障。
如上图所示,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通时,该域还没有被写线程A写入,这是一个错误的读取操作。而读final域的重排序规则会把读对象final域的操作限定在读对象引用之后,此时该final域已经被A线程初始化过了,是一个正确的读取操作。
final域为引用类型
如果final域是引用类型,将会有什么效果
java
public class FinalReferenceExample {
final int[] intArray;
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域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
为什么final引用不能从构造函数内"溢出"
在构造函数内部,不能让被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中"逸出"。
java
public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample () {
i = 1; // 1写final域
obj = this; // 2this引用在此"逸出"
}
public static void writer() {
new FinalReferenceEscapeExample();
}
public static void reader() {
if (obj != null) { // 3
int temp = obj.i; // 4
}
}
}
在构造函数返回之前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。