0x01 茴香豆的起源
众所周知,java八股文中关于多线程的最经典问题莫过于:使用三个线程依次打印A, B, C三个字母,重复10次,输出结果为 A B C A B C ......
考虑这个问题很容易发现,因为我们要在三个线程中完成打印,这显然不是一个原子操作,所以肯定没法用诸如volaile关键字、CAS操作等非阻塞方式来实现了,如果百度一下这个问题,偶尔还能在一些博客上看到这个问题的非阻塞写法,这些方法都存在很明显的漏洞,这里就不再赘述了。
既然要让三个线程轮流进行打印,那么就需要考虑如何最大程度的减少线程切换的次数,以提高程序的性能了。如果我们只是简单的进行了加锁,那么可能出现这种情况:负责“茴”的线程打印之后,下一个被切换上CPU的不是“香”的线程反而是“豆”线程,那么 "豆"线程需要阻塞自己,等待"香"线程被切换上CPU。
既然如此,很容易想到需要以某种方式让“茴”线程去通知(唤醒)“香”线程。既然我们有一段代码要使用同一个锁来保护,又需要三个线程分别在不同条件下被唤醒,这时自然就想到可以使用ReentrantLock和它的三个Condition来实现。// 提前剧透,马上大家会发现这种写法其实很麻烦的.....
0x02 ReentrantLock与它的三个Condition
为了避免把代码写三遍,最简单的方法是继承java的Thread类,并且重写它的run()方法来进行打印。这个类需要包含的成员变量有:锁,唤醒它的Condition和唤醒下一个线程的Condition, 打印的内容,循环次数。
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
static class PrintThread extends Thread {
private Lock lock;
// 这个的用处等下再说 private CountDownLatch latch;
private String s;
private Condition pre, post;
private int loop;
PrintThread(Lock lock, Condition pre, Condition post, String s, CountDownLatch latch, int loop) {
this.lock = lock;
this.pre = pre;
this.post = post;
this.latch = latch;
this.s = s;
this.loop = loop;
}
@Override
public void run() {
while (loop > 0) {
lock.lock();
// 线程每次先进入等待状态 try {
latch.countDown(); // latch的作用是为了保证能成功唤醒第一个线程 pre.await();
} catch (InterruptedException e) {
System.out.println("Interrupted");
}
// 进行打印并唤醒下一个线程 System.out.println(s);
post.signalAll();
loop--;
lock.unlock();
}
}
}
那么问题来了,我们在初始化线程时如何保证“茴”线程第一个运行呢?
网络上关于线程初始化时的写法可谓是各显神通......
有强行靠延时来确定顺序的:
printerA.start();
Thread.sleep(100);
printerB.start();
Thread.sleep(100);
printerC.start();
// 这种写法的run()方法中的操作是先打印一次,然后再进行await() → notifyAll()的循环
// (而我的代码中是让三个线程都先进入等待,然后再进行唤醒)
// 所以这里只需要保证第一次打印的顺序即可
还有靠睡眠来保证三个打印线程都调用了await()的:
t1.start();
t2.start();
t3.start();
try {
// 注意下面的三行 Thread.sleep(1000);
lock.lock();
condition1.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
// 等到t1, t2, t3都调用await()后,才可以从主线程中调用condition1.signal()来唤醒第一个打印线程t1// 所以要sleep 1000ms 等待t1, t2, t3的执行
但是实话实话.....以上两种方法都要靠 “1000ms”和“100ms”这种魔法值来保证线程执行的顺序,要是在工作中写这样的代码,怕是要被组里的大佬砍死 orz
那么为了保证线程的顺序,我只好无可奈何地使用一个CountDownLatch来等待三个线程调用await(),开始线程的代码为:
Lock lock = new ReentrantLock();
Condition c1 = lock.newCondition(), c2 = lock.newCondition(), c3 = lock.newCondition();
CountDownLatch latch = new CountDownLatch(3);
new PrintThread(lock, c3, c1, "茴", latch, 10).start();
new PrintThread(lock, c1, c2, "香", latch, 10).start();
new PrintThread(lock, c2, c3, "豆", latch, 10).start();
latch.await();
lock.lock();
c3.signalAll(); // 这行代码有抛出IllegalMonitorStateException的风险吗?
lock.unlock();
上述代码中,主线程从latch.await()中被唤醒时,可以保证三个打印线程都已经执行了latch.release()。
聪明的读者可能会注意到,打印线程执行了latch.release(),可是并不能保证它们已经执行了await()方法呀,如果正好“茴”线程在执行了latch.countDown()后被切换下去了,然后主线程执行“c3.signalAll()”岂不是会抛出异常?
但其实这种担心是多余的,从上图的对比中很容易看出,我们为了调用Condition::signalAll()需要让主线程取得lock,而主线程从latch.await()中被唤醒时,显然有最后一个线程正在持有lock,所以主线程一定会在lock.lock()这一行被阻塞,直到最后的那个线程执行了pre.await()来释放掉lock。
所以使用ReentrantLock和三个Condition的方法看起来简单,其实也是有蛮多坑的,如果不小心很容易发生唤醒顺序错误,或者是在不恰当的时机调用了Condition::signalAll(),导致抛出IllegalMonitorStateException异常。
0x03 使用三个Semaphore
这种方法嘛,我就偷个懒了,大家可以看看这位大佬的文章:
因为使用了Semaphore的初始值来确保线程执行顺序,代码比使用ReentrantLock的方法简介多了~