浅谈Java公平锁与内存模型
1 前言
春天来了,春招还会远么? 又到了春招的季节,随之而来的是各种的面试题。今天就看到组内大佬面试实习生的一道Java题目:
编写一个程序,开启 3 个线程A,B,C,这三个线程的输出分别为 A、B、C,每个线程将自己的 输出在屏幕上打印 10 遍,要求输出的结果必须按顺序显示。如:ABCABCABC….
2 经过
出于好奇的心态,我花了点时间来尝试解决这个问题, 主要的难点是让线程顺序地如何顺序地输出,线程之间如何交换。
很快就按着思路写出了一个版本,用Lock 来控制线程的顺序,A,B,C线程依次启动,因为A线程先启动,所以A线程会最先拿到锁,B,C阻塞;但是A输出完字符串,释放锁,B 线程获得锁,C,A线程阻塞; 依此循环:
public void Test(){
private static Integer index = 0;
Lock lock = new ReentrantLock();
@Test
public void testLock(){
Thread threadA = work(i -> i % 3 == 0, () -> System.out.println("A"));
Thread threadB = work(i -> i % 3 == 1, () -> System.out.println("B"));
Thread threadC = work(i -> i % 3 == 2, () -> System.out.println("C"));
threadA.start();
threadB.start();
threadC.start();
}
private Thread work(Predicate<Integer> condition, Runnable function) {
return new Thread(() -> {
while (index < 30) {
lock.lock();
if (condition.test(index)) {
function.run();
index++;
}
lock.unlock();
}
});
}
}
输入结果如我预期那般,ABCABC交替输出,也成功输出了10次,奇怪的是A,B却多输出了一次?
为什么会多输出一次,不是应该恰好是输出30次么, 为什么会多输出一次A,B
真的百思不得其解. 所以我把index
也打印出来查看, 结果相当奇怪:
...
function.run();
System.out.println(index);
....
为什么A 会是30, B会是31, 不是有(index.intvalue<30) 的条件判断么, 为什么还会出现这样的数据?灵异事件?
3 解惑
灵异事件自然是不存在的,仔细分析了一番代码之后,发现了问题:
while (index.intValue() < 30) { // 1
lock.lock(); // 2
if (condition.test(index.intValue())) {
function.run();
index++;
}
lock.unlock();
}
将1,2行的操作做了这三件事,如下:
- 线程读取index的值
- 比较index的值是否大于30 3. 如果小于30, 尝试获取锁
换言之,当index=29时,线程C持有锁,但是锁只能阻止线程A,线程B修改index的值,并不能阻止线程A,线程B在获取锁之前读取index的值,所以线程A读取index=29,并把值保持到线程的内部,如下图:
当线程C执行完,还没释放锁的时候,线程A的index值为29;当线程C释放锁,线程A获取锁,进入同步块的时候,因为 Java内存模型有内存可见性的要求, 兼之Lock的实现类实现了内存可见,所以线程A的index值会变成30,
这就解析了为什么线程A index=30的时候能跳过(index.intValue<30)
的判断条件,因为执行这个判断条件的时候线程A index=29, 进入同步块之后变成了30:
把问题剖析清楚之后,解决方案就呼之欲出了:
while (index.intValue() < 30) { // 1
lock.lock(); // 2
if(index>=30){
continue;
}
if (condition.test(index.intValue())) {
function.run();
index++;
}
lock.unlock();
}
这种解决方法不禁让我想起单例模式里面的双重校验:
public static Singleton getSingleton() {
if (instance == null) { //Single Checked
synchronized (Singleton.class) {
if (instance == null) { //Double Checked
instance = new Singleton();
}
}
}
return instance ;
}
只是当时并不清楚Double Checked的作用,究竟解决了什么问题?
只是知道不加这条语句就会造成初始化多个示例,的确是需要知其然知其所以然.
4 公平锁问题
前文说到,
这个程序是用Lock 来控制线程的顺序,A,B,C线程依次启动,因为A线程先启动,所以A线程会最先拿到锁,B,C阻塞;
但是A输出完字符串,释放锁,B 线程获得锁,C,A线程阻塞; 依此循环。
粗看似乎没什么问题, 但是这里是存在着一个问题: 当线程A释放锁的时候,获取锁的是否一定是线程B, 而不是线程C, 线程C是否能够”插队”抢占锁?
这个就涉及到了公平锁和非公平锁的定义了:
公平锁: 线程C不能抢占,只能排队等待线程B 获取并释放锁
非公平锁:线程C能抢占,抢到锁之后线程B只能继续等(有点惨!)
而ReentrantLock默认恰好是非公平锁, 查看源码可知:
/** * Creates an instance of {@code ReentrantLock}. * This is equivalent to using {@code ReentrantLock(false)}. */ public ReentrantLock() { sync = new NonfairSync(); }
因此为了规避非公平锁抢占的问题, 上述的代码在同步块增加了判断条件:
if (condition.test(index.intValue())) { .... }
只有符合条件的线程才能进行操作,否则就是线程自旋.(但是加锁+自旋实现起来,效率不会太高效!)
5 小结
写一条面试题的答案都写得是问题多多的,不禁令人沮丧,说明自己对Java的并发模型理解还有很大的提高。 不过在排查问题的过程中,通过实践有体感地理解了Java的内存模型,发现Java内存模型并不是那么地曲高和寡,在日常的开发中也是很常见的.
费了一番工夫排查之后,终究是有新的收获的