浅谈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线程阻塞; 依此循环:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 也打印出来查看, 结果相当奇怪:

1
2
3
4
...
    function.run();
System.out.println(index);
....

为什么A 会是30, B会是31, 不是有(index.intvalue<30) 的条件判断么, 为什么还会出现这样的数据?灵异事件?

3 解惑

灵异事件自然是不存在的,仔细分析了一番代码之后,发现了问题:

1
2
3
4
5
6
7
8
while (index.intValue() < 30) {  // 1
    lock.lock(); // 2
    if (condition.test(index.intValue())) {
	function.run();
	index++;
    }
    lock.unlock();
}

将1,2行的操作做了这三件事,如下:

  1. 线程读取index的值
  2. 比较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:

把问题剖析清楚之后,解决方案就呼之欲出了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
while (index.intValue() < 30) {  // 1
    lock.lock(); // 2
    if(index>=30){
	continue;
    }
    if (condition.test(index.intValue())) {
	function.run();
	index++;
    }
    lock.unlock();
}

这种解决方法不禁让我想起单例模式里面的双重校验:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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默认恰好是非公平锁, 查看源码可知:

    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内存模型并不是那么地曲高和寡,在日常的开发中也是很常见的.

费了一番工夫排查之后,终究是有新的收获的