忽然发现同事老婆很漂亮,感觉身边研发同事好多这样,人都不善言辞的,找老婆倒是都很成功。
昨晚十一点多,我在公司楼下跟小李吃泡面,他一脸生无可恋跟我说:“哥,今天面试官让我写个多线程打印 0 和奇偶数,我当场脑袋就空白了……”我一听这题目,你们肯定也见过,就是那个打印 0102030405... 的题,对吧。
先把题目用人话说清楚哈,不整那些晦涩的定义。大概意思是:有三个线程,一个专门打印 0,一个专门打印奇数,一个专门打印偶数,最后控制这三个线程按顺序输出:01020304... 一直到 n。比如 n = 5,那输出就是 0102030405。 难点不是“打印”,而是:怎么让三个线程老老实实排队轮流输出,不抢话,不掉号。
我当时在桌子上随手拿了三根一次性筷子,跟小李比划。 想象一下:
拿黑色筷子的负责打印 0 拿红色筷子的打印奇数 拿蓝色筷子的打印偶数
桌子中间放一块牌子 state,上面写当前轮到谁了:
写 0:代表轮到“打印 0”的线程 写 1:代表轮到“打印奇数”的线程 写 2:代表轮到“打印偶数”的线程
每个线程都盯着这块牌子,不是自己就乖乖等,是自己就干活,然后把牌子改成下一个人,再喊一嗓子“可以了”,别人再起来干活。其实多线程那点事儿,本质就是“轮流说话”加“一个共享状态”。
小李当时第一反应是:“那我三个 while 死循环,不停判断该不该打印,不就完了?” 我说你这个叫“自旋”,CPU 会被你打满,电脑小风扇都要起飞。面试官看到你这么写,多半会跟你说“那今天就先聊到这儿”。
换个稍微正经点的写法,我们用 synchronized + wait/notifyAll,毕竟 Java 里最朴素那套同步工具,上手快,理解成本也低。
我直接把核心代码给你,先看个全貌,再一点点拆:
import java.util.function.IntConsumer;
publicclassZeroEvenOdd{
privatefinalint n;
// 0: 该打印 0 了;1: 该打印奇数;2: 该打印偶数
privateint state = 0;
publicZeroEvenOdd(int n){
this.n = n;
}
// 打印 0 的线程执行这个方法
publicsynchronizedvoidzero(IntConsumer printNumber)throws InterruptedException {
for (int i = 1; i <= n; i++) {
// 不是自己就等着
while (state != 0) {
wait();
}
printNumber.accept(0);
// 决定下一个该谁来,根据当前数字 i 的奇偶
if ((i & 1) == 1) {
state = 1; // 轮到奇数
} else {
state = 2; // 轮到偶数
}
notifyAll();
}
}
// 打印奇数的线程执行这个方法
publicsynchronizedvoidodd(IntConsumer printNumber)throws InterruptedException {
for (int i = 1; i <= n; i += 2) {
while (state != 1) {
wait();
}
printNumber.accept(i);
// 打完奇数,再轮到 0
state = 0;
notifyAll();
}
}
// 打印偶数的线程执行这个方法
publicsynchronizedvoideven(IntConsumer printNumber)throws InterruptedException {
for (int i = 2; i <= n; i += 2) {
while (state != 2) {
wait();
}
printNumber.accept(i);
// 打完偶数,再轮到 0
state = 0;
notifyAll();
}
}
// 简单测一下
publicstaticvoidmain(String[] args){
ZeroEvenOdd zeroEvenOdd = new ZeroEvenOdd(5);
IntConsumer printer = x -> System.out.print(x);
Thread tZero = new Thread(() -> {
try {
zeroEvenOdd.zero(printer);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread tOdd = new Thread(() -> {
try {
zeroEvenOdd.odd(printer);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread tEven = new Thread(() -> {
try {
zeroEvenOdd.even(printer);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
tZero.start();
tOdd.start();
tEven.start();
}
}
你先别急着往下划,脑子里过一遍执行顺序:
程序一启动, state = 0,所以“打印 0”的那个线程一上来就能干活,另外两个看到state != 自己,就老老实实wait()了。打 0 的线程,第一次打印出 0,这时候i = 1,是奇数,于是把state改成 1,意思是“奇数同学你上”,然后notifyAll()把大家都叫醒。被叫醒后,奇数线程发现 state == 1,开心打印1,然后把state改回 0,让“打印 0”的接着来。如此反复,奇数、偶数轮流接到 0 的“发球”,整个顺序就被锁死了。
这里面几个小点,面试的时候非常容易被问到,我当时跟小李强调了几遍:
一个是为什么用 while (state != X) { wait(); },不用 if。 你们知道线程有个东西叫“虚假唤醒”,就是明明没人叫你,你也醒了;或者一起被唤醒,但是状态又被别人改回去了。 如果用 if,你醒来一次就不再检查条件,直接往下打印,就可能乱序。while 每次被唤醒都会重新检查 state,不满足就继续等,这个是标准写法。
还有一个是 notify() 和 notifyAll() 的问题。 理论上说,叫一个人起来(notify)就够了,但是我们这里有三个线程,谁在等你其实不一定。 为了不踩奇怪的边角 bug,用 notifyAll(),把所有等的人都叫醒,真正能继续干活的,还是只有那个 state 对得上的线程,其他人醒来发现状态不对,又继续 wait() 了。
再一个小细节,odd 方法里 for 循环是 i += 2,even 也是 i += 2,这样每个线程只管自己那一拨数字,比在里面自己算“当前该打印几”要清爽得多,也不容易写错范围。
小李当时看着代码,跟我说:“这么一写,好像也没那么可怕哈,就是围着一个 state 转。” 我说是啊,多线程这种题,别上来就想各种锁、各种类,先把“轮流说话”的模型想明白,再翻译成 state + wait/notify,就顺了。
当然这题也有别的写法,比如用 Semaphore 三把信号量排队,或者 Lock + Condition 把三个条件队列分开,整体思路都差不多: 共享一个计数 i,三个线程守着不同的条件,满足就打印,不满足就睡。
不过对大部分同学来说,你只要能把上面这版 synchronized 写顺溜,把几个细节说清楚:
为什么要 while为什么要 notifyAllstate是怎么在三个线程之间流转的 面试官一般就知道你多线程的基本功还不错了。
行了,零和奇偶数就聊到这儿吧,我手机又在震了,估计是产品在群里喊人,我先去看下他们又改了啥需求……
-END-
我为大家打造了一份RPA教程,完全免费:songshuhezi.com/rpa.html