前言
Java 并发也学了一阵子了,想着找点题目练练手,去力扣上翻了翻,免费(穷人充不起会员)的竟然还有六道题,不得不说,真是万能的力扣,在这里总结一下,每道题给出自己当时的解法和分析,注意了,只是作者自己的方法,每道题都还有很多别的方法。
六道多线程题
1114. 按序打印
我们提供了一个类:
public class Foo {
public void first() { print("first"); }
public void second() { print("second"); }
public void third() { print("third"); }
}
三个不同的线程 A、B、C 将会共用一个 Foo
实例。
- 一个将会调用
first()
方法 - 一个将会调用
second()
方法 - 还有一个将会调用
third()
方法
请设计修改程序,以确保 second()
方法在 first()
方法之后被执行,third()
方法在 second()
方法之后被执行。
这道题的意思就是线程A,B,C 调用各自对应的方法的时间不一定固定,可能调用顺序为C,B,A
,但最终还是要按顺序输出"firstsecondthird"
的字符串。所以就要编写代码控制不同线程的执行顺序,即使C
先调用了third
方法,也不能让它在A,B
执行之前执行,B
同理。所以可以考虑用一些变量来表示各个线程完成的状态,只有A
完成了之后,对应的状态变量才变为B
可以执行的状态,B
完成之后,再更新此变量,让它变为C
可以执行的状态。当时我写出了下面的代码:
class Foo {
Object obj = new Object();
volatile int flag = 1;
public Foo() {
}
public void first(Runnable printFirst) throws InterruptedException {
synchronized(obj) {
while (flag != 1) obj.wait();
printFirst.run();
// 设置下一个工作的线程
flag = 2;
obj.notifyAll();
}
}
public void second(Runnable printSecond) throws InterruptedException {
synchronized(obj){
// 即使先抢到了锁,也没用,不满足条件,要让出锁
while(flag != 2) obj.wait();
printSecond.run();
// 设置下一个工作的线程
flag = 3;
obj.notifyAll();
}
}
public void third(Runnable printThird) throws InterruptedException {
synchronized(obj) {
// 即使先抢到了锁,也没用,不满足条件,要让出锁
while(flag != 3) obj.wait();
printThird.run();
}
}
}
如代码,用flag
来表示三个线程能否执行的标志,初始化为1,为了保证写操作的可见性,flag
是 volatile 的,下同。B
只有flag
为 2 时才能执行,否则,即使B
先抢到了synchronized 锁,由于不满足flag
的条件,还是得调用obj.wait()
让出锁,C
同理,每个线程执行完了之后,把flag
设置为下个线程需要的值,这样,就完成了按规定顺序打印。
当然,这道题也能用 ReentrantLock 来做,稍微改一下代码如下:
class Foo {
volatile int flag = 1;
ReentrantLock lock = new ReentrantLock();
Condition condition1,condition2,condition3;
public Foo() {
condition1 = lock.newCondition();
condition2 = lock.newCondition();
condition3 = lock.newCondition();
}
public void first(Runnable printFirst) throws InterruptedException {
lock.lock();
try {
while (flag != 1) {
condition1.await();
}
printFirst.run();
flag = 2;
// 精准唤起B
condition2.signal();
} finally {
lock.unlock();
}
}
public void second(Runnable printSecond) throws InterruptedException {
lock.lock();
try {
while (flag != 2) {
condition2.await();
}
printSecond.run();
flag = 3;
// 精准唤起C
condition3.signal();
} finally {
lock.unlock();
}
}
public void third(Runnable printThird) throws InterruptedException {
lock.lock();
try {
while (flag != 3) {
condition3.await();
}
printThird.run();
} finally {
lock.unlock();
}
}
}
和上面同样的思想,就不解释了,说说不同。用 synchronized + obj.wait() + obj.notifyAll()
的组合,每次必须调用obj.notifyAll()
,这是因为 obj.notify()
每次只能唤起一个线程,有可能A
调用之后,唤起了C
,此时,显然C
条件不满足,C
释放锁,那么B,C
都在obj.wait()
,程序永远跑不出结果,自然超时。为了避免这种现象,只有调用obj.notifyAll()
,唤起所有等待的线程。但是这又带来一个问题:B,C
都被唤起,可能C
先抢到锁,经过判断后,得知条件不合适,又调用obj.wait()
挂起自己,这样一来,其实浪费了一部分CPU资源。
而 ReentrantLock 就没有这个问题了,一个锁可以对应多个条件变量,每次线程执行完之后,可以精准的唤起下一个要执行的线程,没有上面那种抢到了发现条件不满足又让出的现象。
1115. 交替打印FooBar
我们提供一个类:
class FooBar {
public void foo() {
for (int i = 0; i < n; i++) {
print("foo");
}
}
public void bar() {
for (int i = 0; i < n; i++) {
print("bar");
}
}
}
两个不同的线程将会共用一个 FooBar
实例。其中一个线程将会调用 foo()
方法,另一个线程将会调用 bar()
方法。
请设计修改程序,以确保 "foobar" 被输出 n 次。
这道题就是说,输入了一个n
,那么要输出n
个foobar
结构,也就是一个线程打印一次之后,就要停止,让另一个线程打印。周而复始,直至打印完,那我们就可以用一个标志变量,每次一个线程打完后改变它,让另一个线程工作,本线程停止,由于只有两个线程,所以用一个 boolean 变量即可,代码如下:
class FooBar {
private int n;
Object obj = new Object();
// 标志哪个线程可以工作
volatile boolean flag = true;
public FooBar(int n) {
this.n = n;
}
public void foo(Runnable printFoo) throws InterruptedException {
for (int i = 0; i < n; i++) {
synchronized(obj) {
// 控制线程打印顺序
while(!flag) obj.wait();
printFoo.run();
// 打印一次之后,flag 变为false,即使本线程下次先抢到锁,也只有挂起
flag = false;
obj.notifyAll();
}
}
}
public void bar(Runnable printBar) throws InterruptedException {
for (int i = 0; i < n; i++) {
synchronized(obj) {
while(flag) obj.wait();
// 打印一次之后,flag 变为true,即使本线程下次先抢到锁,也只有挂起
printBar.run();
flag = true;
obj.notifyAll();
}
}
}
}
这里用了一个 flag
变量作为标志,由于就两个线程,boolean 类型就可以轻松的解决问题。 flag
初始化为 true,这就保证了一定是Foo
线程第一次打印,第一次打印完之后,flag
的值即刻改变,换Bar
线程工作,这样一直下去,最终输出了 n 个 FooBar
,此时,两个线程各自的 for 循环也走完了,正常退出,不会再去抢锁,程序结束。
这道题只有两个线程,所以可重入锁的多条件变量没什么用武之地。obj.wait() + obj.notifyAll()
就可以。
1116. 打印零与奇偶数
假设有这么一个类:
class ZeroEvenOdd {
public ZeroEvenOdd(int n) { ... } // 构造函数
public void zero(printNumber) { ... } // 仅打印出 0
public void even(printNumber) { ... } // 仅打印出 偶数
public void odd(printNumber) { ... } // 仅打印出 奇数
}
相同的一个 ZeroEvenOdd
类实例将会传递给三个不同的线程:
- 线程 A 将调用
zero()
,它只输出 0 。 - 线程 B 将调用
even()
,它只输出偶数。 - 线程 C 将调用
odd()
,它只输出奇数。
每个线程都有一个 printNumber
方法来输出一个整数。请修改给出的代码以输出整数序列 010203040506
... ,其中序列的长度必须为 2n。
这道题就是给你一个 n 你要从 1 打印到 n,但是,每次打印一个整数前,必须带上0,还是控制三个线程顺序的问题。这里B,C
线程工作完之后,都很简单,让标志变量变为打印 0 的那种状态即可,关键是A
打印完 0 之后,如何判断下一个该唤起B
还是C
,如何设置对应的标志变量?这也很简单,第一次打印完 0 肯定要唤起C
,下一次就是B
,然后一直轮流。我们让 flag = 0
代表 A
线程可以工作,每次A
工作完,用flag = i % 2 + 1;
进行flag
的更新,i
一开始为 0 所以flag
的值在1,2
之间轮流变化,让flag
等于 1 代表C
可以工作,2 代表B
可以工作即可,代码如下:
class ZeroEvenOdd {
private int n;
volatile int flag = 0;
Object obj = new Object();
public ZeroEvenOdd(int n) {
this.n = n;
}
public void zero(IntConsumer printNumber) throws InterruptedException {
for (int i = 0;i < n;++i) {
synchronized (obj) {
while (flag != 0) {
obj.wait();
}
printNumber.accept(0);
// 计算下一次该哪个线程工作了
flag = i % 2 + 1;
obj.notifyAll();
}
}
}
public void even(IntConsumer printNumber) throws InterruptedException {
for (int i = 2;i <= n;i += 2) {
synchronized (obj) {
// 不满足条件,即使先抢到锁,也得挂起
while (flag != 2) {
obj.wait();
}
printNumber.accept(i);
flag = 0;
obj.notifyAll();
}
}
}
public void odd(IntConsumer printNumber) throws InterruptedException {
for (int i = 1;i <= n;i += 2) {
synchronized (obj) {
// 不满足条件,即使先抢到锁,也得挂起
while (flag != 1) {
obj.wait();
}
printNumber.accept(i);
flag = 0;
obj.notifyAll();
}
}
}
}
当然,对于这种三线程及以上的协同工作,可以用可重入锁的多条件变量来精准唤起,和按序打印一样,就不赘述了。
1117. H2O 生成
现在有两种线程,氧 oxygen
和氢 hydrogen
,你的目标是组织这两种线程来产生水分子。
存在一个屏障(barrier)使得每个线程必须等候直到一个完整水分子能够被产生出来。
氢和氧线程会被分别给予 releaseHydrogen
和 releaseOxygen
方法来允许它们突破屏障。
这些线程应该三三成组突破屏障并能立即组合产生一个水分子。
你必须保证产生一个水分子所需线程的结合必须发生在下一个水分子产生之前。
换句话说:
- 如果一个氧线程到达屏障时没有氢线程到达,它必须等候直到两个氢线程到达。
- 如果一个氢线程到达屏障时没有其它线程到达,它必须等候直到一个氧线程和另一个氢线程到达。
书写满足这些限制条件的氢、氧线程同步代码。
这道题的输入用例是"OOHHHH"
这种形式,我理解就是输入的字符串总长度为 3n,可以保证o
的个数为 n,H
的个数为 2n。每个字符都代表有一个线程调用一次打印对应H
或O
的函数,3n 个字符(也可以说是线程)操作同一个实例变量。我们要对这个实例变量进行控制,每三个字符一个单位,当H
线程进来后,如果发现本单位里H
已经打印了两次,这个H
线程必须挂起,不能工作,这种情况比如HHH......
,第三个H
不能进行打印工作,需要挂起,等待O
线程的唤起;同理,O
进来后,如果发现本单位的O
已经打印了一次,也得挂起,等待H
线程的唤起。
我们可以用一个 count 来记录 H 打印了多少次,到了 2 次就挂起,等待打印 O,打印了 O 之后,再将 count 置为 0。这样就可以保证最后的输出里,任意的一个单位,输出肯定是HHO
。
class H2O {
// 当前打印了多少次 H
volatile int count = 0;
Object obj = new Object();
public H2O() {
}
public void hydrogen(Runnable releaseHydrogen) throws InterruptedException {
synchronized (obj) {
// 打印的 H 为 2 了,等待 O 的打印
while(count > 1) {
obj.wait();
}
releaseHydrogen.run();
++count;
obj.notifyAll();
}
}
public void oxygen(Runnable releaseOxygen) throws InterruptedException {
synchronized (obj) {
// 打印 H 的个数小于2,不能打印 O
while (count < 2) {
obj.wait();
}
releaseOxygen.run();
count = 0;
obj.notifyAll();
}
}
}
1195. 交替打印字符串
编写一个可以从 1 到 n 输出代表这个数字的字符串的程序,但是:
- 如果这个数字可以被 3 整除,输出 "fizz"。
- 如果这个数字可以被 5 整除,输出 "buzz"。
- 如果这个数字可以同时被 3 和 5 整除,输出 "fizzbuzz"。
例如,当 n = 15
,输出: 1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, 11, fizz, 13, 14, fizzbuzz
。
假设有这么一个类:
class FizzBuzz {
public FizzBuzz(int n) { ... } // constructor
public void fizz(printFizz) { ... } // only output "fizz"
public void buzz(printBuzz) { ... } // only output "buzz"
public void fizzbuzz(printFizzBuzz) { ... } // only output "fizzbuzz"
public void number(printNumber) { ... } // only output the numbers
}
请你实现一个有四个线程的多线程版 FizzBuzz
, 同一个 FizzBuzz
实例会被如下四个线程使用:
- 线程A将调用
fizz()
来判断是否能被 3 整除,如果可以,则输出fizz
。 - 线程B将调用
buzz()
来判断是否能被 5 整除,如果可以,则输出buzz
。 - 线程C将调用
fizzbuzz()
来判断是否同时能被 3 和 5 整除,如果可以,则输出fizzbuzz
。 - 线程D将调用
number()
来实现输出既不能被 3 整除也不能被 5 整除的数字。
还是在一个实例变量里对4个线程进行协同控制,貌似与上面那道 打印零与奇偶数 很相似,不就是这个线程打印完,设置对应的标志,唤起下一个线程嘛。不过这道题,我做的时候发现有一个地方还挺坑的,看代码:
class FizzBuzz {
private int n;
volatile int flag = 1;
Object obj = new Object();
public FizzBuzz(int n) {
this.n = n;
}
// printFizz.run() outputs "fizz".
public void fizz(Runnable printFizz) throws InterruptedException {
retry:
for (int i = 3;i <= n;i += 3) {
synchronized (obj) {
while (flag != 3) {
obj.wait();
// i = 15时,本线程并不会被唤起,所以应该跳过,否则,线程会一直困在 i = 15处
if (i % 15 == 0) {
continue retry;
}
}
printFizz.run();
flag = 1;
obj.notifyAll();
}
}
}
// printBuzz.run() outputs "buzz".
public void buzz(Runnable printBuzz) throws InterruptedException {
retry:
for (int i = 5;i <= n;i += 5) {
synchronized (obj) {
while (flag != 5) {
obj.wait();
// // i = 15时,本线程并不会被唤起,所以应该跳过,否则,线程会一直困在 i = 15处
if (i % 15 == 0) {
continue retry;
}
}
printBuzz.run();
flag = 1;
obj.notifyAll();
}
}
}
// printFizzBuzz.run() outputs "fizzbuzz".
public void fizzbuzz(Runnable printFizzBuzz) throws InterruptedException {
for (int i = 15;i <= n;i += 15) {
synchronized(obj){
while (flag != 15) {
obj.wait();
}
printFizzBuzz.run();
flag = 1;
obj.notifyAll();
}
}
}
// D 来决定 flag 到底是多少,下一次该哪个线程
public void number(IntConsumer printNumber) throws InterruptedException {
for (int i = 1;i <= n;i += 1) {
synchronized (obj) {
while (flag != 1) {
obj.wait();
}
// 如果 i 不被 3 和 5整除,直接输出
if (i % 3 != 0 && i % 5 != 0) {
printNumber.accept(i);
}
// 否则,计算 i 对应哪个线程工作
else {
flag = i % 15 == 0?15:(i % 3 == 0?3:5);
}
obj.notifyAll();
}
}
}
}
这道题我还是用flag
来表示下一步该哪个线程工作了,A,B,C
工作了之后,都会唤起D
,由D
来决定下一个是哪个线程,每个A,B,C
对应的flag
也都是由D
设置的。但是开始我的代码却总是超时,后来才发现原因:如果 n >= 15
,那么A,B
都会走到各自循环的i = 15
处,但是i = 15
,这俩线程都不会被唤起,此时,flag
为 15,这俩线程会被挂着,那么如果 n 是 15,16,17,这俩线程会一直挂着,没人唤起,即使 n 是18,此时,D
线程似乎可以走到i = 18
处,好像唤起了A
,但是A
此时的 i 为 15,接下来,它会走到 i = 18
处,而线程D
已经走完了,A
会在i = 18
处被挂着,还是被少唤起一次,最终超时。
为了解决这个问题,在A,B
对应的obj.wait()
后加了下面这段代码:
if (i % 15 == 0) {
continue retry;
}
如果被唤起之后,发现此时的 i 可以被 15 整除,直接跳过此次循环即可。当然,把这个语句拿到 synchronized 语句块外面也可以。
1226. 哲学家进餐
这道题题目挺长的,还有个精美的图,我就不放原题了。哲学家就餐问题是操作系统里典型的死锁问题,如果 5 个哲学家每个人都拿起自己左边的叉子,每个人都在等待右边的叉子,而每个人又不会主动放下叉子,那么就会造成死锁,为了解决死锁,我们可以控制同时吃饭的人数,例如,如果同时只能有一个人吃饭,那肯定可以吃啊,也不会死锁,我一开始的代码就是这么写的,这么写竟然也过了:
class DiningPhilosophers {
Object obj = new Object();
public DiningPhilosophers() {
}
// call the run() method of any runnable to execute its code
public void wantsToEat(int philosopher,
Runnable pickLeftFork,
Runnable pickRightFork,
Runnable eat,
Runnable putLeftFork,
Runnable putRightFork) throws InterruptedException {
synchronized (obj) {
pickLeftFork.run();
pickRightFork.run();
eat.run();
putLeftFork.run();
putRightFork.run();
}
}
}
后来在题解里看到用 Java 的信号量来控制同时吃饭的人数的,感觉还不错,把代码贴一下:
class DiningPhilosophers {
ReentrantLock[] locks;
// 用信号量控制准入人数
Semaphore limit;
public DiningPhilosophers() {
// 每个叉子都有一把锁
locks = new ReentrantLock[5];
for (int i = 0;i < 5;++i) {
locks[i] = new ReentrantLock();
}
// 控制同时吃饭的人数为 2
limit = new Semaphore(2);
}
// call the run() method of any runnable to execute its code
public void wantsToEat(int philosopher,
Runnable pickLeftFork,
Runnable pickRightFork,
Runnable eat,
Runnable putLeftFork,
Runnable putRightFork) throws InterruptedException {
// 这里哲学家的编号是 0-4,定义每个哲学家左右叉子的编号其实有多种方法
// 只要保证最后求出来的编号和哲学家可以对应上即可
int left = philosopher,right = (philosopher + 4) % 5;
limit.acquire();
locks[left].lock();
locks[right].lock();
pickLeftFork.run();
pickRightFork.run();
eat.run();
putLeftFork.run();
putRightFork.run();
locks[left].unlock();
locks[right].unlock();
limit.release();
}
}
这里哲学家的编号为 0-4,叉子的编号其实可以自己定义,比方说,你可以让 0 号哲学家左右的叉子编号为 0,4,也可以为 1,0。只要最终能保证题目中每个哲学家要拿起左右两个叉子这一语义即可。
这里信号量设置为 2,同时只有两个人能进来吃饭,拿叉子之前要获取该叉子对应的锁,其实信号量设置为 3,4 也可以,即使是4,也肯定有一个人可以吃饭(同时得到两把叉子的锁),吃完了释放叉子,也是没问题的,只要不是 5 就行了(同时 5 个人一块吃饭,还是有可能死锁的)。
总结
起码从这些题来说,它们对应的多线程编程主要难点在于如何在某共享变量内部进行一定的同步措施,从而让不同的线程按照我们想要的方式协同工作,它们实际并不太关心这些线程是怎么起的。