故事背景
团队内部前几天讨论了一个面试题,在本地用乐观锁和悲观锁实现计数器需要volatile关键字吗?毫无疑问,使用乐观锁一定是需要的。但使用悲观锁需要呢?
张三:不需要吧,每次不都是一个线程访问变量吗?
李四:还是需要的,加锁只是保证了该变量被一个线程独占,但是不能保证拿到变量最新的值,因为可能上个线程操作后数据还在线程本地内存里,导致本线程读取的数据是脏数据!
张三:嘶,好像有点道理,不过如果加锁的话,这个本地内存的数据什么时候刷到主内存呢?会不会加锁后就直接读取到最新数据了?
李四:诶?问得好,这个得研究研究。
预备知识
Hppens-Before 规则
Java Memory Model(JMM) 里定义了一些跨线程操作的 Happens-Before 关系,并据此来决定线程间一些操作的相对顺序。如果说操作 A “Happens-Before” B,则有两个含义:
- 可见性:A 的操作对 B 可见
- 顺序性:A 要在 B 之前执行
Happens-before 规则有多条,本文只借助几条来进行解释
- 程序顺序规则:如果程序中操作 A 在操作 B 之前,那么在线程中操作 A Happens-Before 操作 B
- 监视器锁规则:监视器上的 unlock 操作 Happens-Before 同一个监视器的 lock 操作
- volatile 变量规则:写入 volatile 变量 Happens-Before 读取该变量
- 传递性:如果
hb(A, B)
且hb(B, C)
,则hb(A, C)
- …
volatile 的内存语义
从内存语义的角度来说,volatile的写-读
与锁的释放-获取
有相同的内存效果。
volatile 写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中写 volatile 前所有的共享变量刷新到主内存中,并让其他 core 的缓存失效,不管这些变量是否volatile,不仅仅只是 volatile 变量本身。
volatile 读的内存语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
关于 volatile 写的说明:https://jenkov.com/tutorials/java-concurrency/volatile.html
synchronized 实现
synchronized 可见性原理
Synchronized 的 Happens-Before 规则,即监视器锁规则
:对同一个监视器的解锁,Happens-Before 于对该监视器的加锁。
图中每一个箭头连接的两个节点就代表之间的 Happens-Before 关系,红色的为监视器锁规则推导而出:线程A释放锁 Happens-Before 线程B加锁;蓝色的则是通过程序顺序规则和监视器锁规则推测出来 Happens-Before 关系,通过传递性规则进一步推导的 Happens-Before 关系。
根据 Happens-Before 规则的程序顺序规则:如果 A Happens-Before B,则 A 的执行结果对 B 可见,并且 A 的执行顺序先于 B。因此,如果线程 A 修改了计数器的值,对线程 B 是可见的。
synchronized 验证代码
public class TestSynchronizedCounter {
private static int count = 0;
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
synchronized (TestSynchronizedCounter.class) {
count++;
}
}
System.out.println("thread1 finish, sum = " + count);
}).start();
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
synchronized (TestSynchronizedCounter.class) {
count++;
}
}
System.out.println("thread2 finish, sum = " + count);
}).start();
}
}
Reentrantlock 实现
Reentrantlock 可见性原理
ReentrantLock 也可以起到和 Synchronized 关键字同样的效果,在 Lock 接口的注释中有如下描述。这段描述的意思是说所有的 Lock 接口实现必须在内存可见性上具有和内置监视器锁(Synchronized)相同的语义。
Memory Synchronization
All Lock implementations must enforce the same memory synchronization semantics as provided by the built-in monitor lock, as described in The Java Language Specification (17.4 Memory Model) :
A successful lock operation has the same memory synchronization effects as a successful Lock action.
A successful unlock operation has the same memory synchronization effects as a successful Unlock action.
ReentrantLock
通过内部的 Sync
类来完成锁的功能,Sync
类扩展了 AQS
,重用 AQS 的各项同步功能。众所周知:Reentrantlock 的 lock 和 unlock 都需要读取并用 CAS 方式修改被 volatile 修饰的变量 state。
需要注意的是,volatile 写操作会把之前的共享变量更新一并发布出去,而不只是 volatile 变量本身。
假设线程a通过调用lock方法获取到锁,此时线程b也调用了lock方法,因为a尚未释放锁,b只能等待。a在获取锁的过程中会先读state,再写state。当a释放掉锁并唤醒b,b会尝试获取锁,也会先读state,再写state。
根据 Hppens-Before 规则的 volatile 变量规则:写入 volatile 变量 Happens-Before 读取该变量以及 volatile 写的内存语义。可以推测出,线程a在写入state变量之前的任何操作结果对线程b都是可见的。
再次说明:volatile 写操作会把之前的共享变量更新一并发布出去,而不只是 volatile 变量本身。
以公平锁为例,我们看看 ReentrantLock 获取锁 & 释放锁的关键代码:
private volatile int state; // 关键 volatile 变量
protected final int getState() {
return state;
}
// 获取锁
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState(); // 重要!!!读 volatile 变量
... // 竞争获取锁逻辑,省略
}
// 释放锁
protected final boolean tryRelease(int releases) {
boolean free = false;
... // 根据状态判断是否成功释放,省略
setState(c); // 重要!!!写 volatile 变量
return free;
}
简单来说就是对于每一个进入到锁的临界区域的线程,都会做三件事情:
- 获取锁,读取 volatile 变量;
- 执行临界区代码,针对本文是对 count 做自增;
- 写 volatile 变量 (即发布所有写操作),释放锁。
Reentrantlock 验证代码
public class TestLockCounter {
private final static ReentrantLock LOCK = new ReentrantLock();
private static int count = 0;
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 1000000; i++) {
try {
LOCK.lock();
count++;
} finally {
LOCK.unlock();
}
}
System.out.println("thread1 finish, sum = " + count);
}).start();
new Thread(() -> {
for (int i = 0; i < 1000000; i++) {
try {
LOCK.lock();
count++;
} finally {
LOCK.unlock();
}
}
System.out.println("thread2 finish, sum = " + count);
}).start();
}
}
相关原理
内存屏障
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个:
- 保证特定操作的执行顺序
- 二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。
Intel硬件提供了一系列的内存屏障,Java内存模型屏蔽了底层硬件平台的差异,由 JVM来为不同的平台生成相应的机器码。 JVM中提供了四类内存屏障指令:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | 在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕 |
StoreStore | Store1; StoreStore; Store2 | 在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见 |
LoadStore | Load1; LoadStore; Store2 | 在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕 |
StoreLoad | Store1; StoreLoad; Load2 | 在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见 |
Happens-Before 底层实现原理
Happens-Before 是对 Java 内存模型(JMM)中所规定的可见性的更高级的语言层面的描述,程序员可以用这个原则解决并发环境下两个操作之间的可见性问题,而不需要陷入 Java 内存模型苦涩难懂的定义中。
一个 Happens-Before 规则对应于一个或多个编译器和处理器重排序规则,Happens-Before 与JMM的关系如下图所示:
volatile 底层实现原理
JVM的实现会在 volatile 读写前后均加上内存屏障,实现了可见性和有序性。如下所示:
LoadLoadBarrier
volatile 读操作
LoadStoreBarrierStoreStoreBarrier
volatile 写操作
StoreLoadBarrier
总结
本地用悲观锁实现计数器不需要加 volatile ,synchronized 关键字和 Lock 接口都具有 Happens-Before 规则:
- synchronized 关键字遵循监视器锁规则,从而实现了代码临界区内变量的可见性。
- ReentrantLock 及其它 Lock 接口实现类借助了 volatile 关键字间接地实现了可见性。
参考资料
- Java 并发知识:https://lotabout.me/books/Java-Concurrency/Happens-Before/index.html
- Happens-Before 原则深入解读:https://xie.infoq.cn/article/d0f4d9e812ee03b6a32265686
- Java Volatile Keyword:https://jenkov.com/tutorials/java-concurrency/volatile.html
- 关键字: synchronized 详解:https://pdai.tech/md/java/thread/java-thread-x-key-synchronized.html
- 深度好文 | Java 可重入锁内存可见性分析:https://cloud.tencent.com/developer/article/1142546
- ReentrantLock 是如何保证内存的可见性的:https://zhuanlan.zhihu.com/p/80929454