浅谈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线程阻塞; 依此循环:
|
|
输入结果如我预期那般,ABCABC交替输出,也成功输出了10次,奇怪的是A,B却多输出了一次?
为什么会多输出一次,不是应该恰好是输出30次么, 为什么会多输出一次A,B
真的百思不得其解. 所以我把index
也打印出来查看, 结果相当奇怪:
|
|
为什么A 会是30, B会是31, 不是有(index.intvalue<30) 的条件判断么, 为什么还会出现这样的数据?灵异事件?
3 解惑
灵异事件自然是不存在的,仔细分析了一番代码之后,发现了问题:
|
|
将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:
把问题剖析清楚之后,解决方案就呼之欲出了:
|
|
这种解决方法不禁让我想起单例模式里面的双重校验:
|
|
只是当时并不清楚Double Checked的作用,究竟解决了什么问题?
只是知道不加这条语句就会造成初始化多个示例,的确是需要知其然知其所以然.
4 公平锁问题
前文说到,
这个程序是用Lock 来控制线程的顺序,A,B,C线程依次启动,因为A线程先启动,所以A线程会最先拿到锁,B,C阻塞;
但是A输出完字符串,释放锁,B 线程获得锁,C,A线程阻塞; 依此循环。
粗看似乎没什么问题, 但是这里是存在着一个问题: 当线程A释放锁的时候,获取锁的是否一定是线程B, 而不是线程C, 线程C是否能够”插队”抢占锁?
这个就涉及到了公平锁和非公平锁的定义了:
公平锁: 线程C不能抢占,只能排队等待线程B 获取并释放锁
非公平锁:线程C能抢占,抢到锁之后线程B只能继续等(有点惨!)
而ReentrantLock默认恰好是非公平锁, 查看源码可知:
1 2 3 4 5 6 7
/** * Creates an instance of {@code ReentrantLock}. * This is equivalent to using {@code ReentrantLock(false)}. */ public ReentrantLock() { sync = new NonfairSync(); }
因此为了规避非公平锁抢占的问题, 上述的代码在同步块增加了判断条件:
1 2 3
if (condition.test(index.intValue())) { .... }
只有符合条件的线程才能进行操作,否则就是线程自旋.(但是加锁+自旋实现起来,效率不会太高效!)
5 小结
写一条面试题的答案都写得是问题多多的,不禁令人沮丧,说明自己对Java的并发模型理解还有很大的提高。 不过在排查问题的过程中,通过实践有体感地理解了Java的内存模型,发现Java内存模型并不是那么地曲高和寡,在日常的开发中也是很常见的.
费了一番工夫排查之后,终究是有新的收获的