Java Multi-Threads Running in Sequence

最近看到一篇Java多线程顺序打印ABC的文章,用到了wait(), notify(), 感觉写的比较粗糙,这里写一写自己的看法,对他的第一段代码,在变量上稍微做了一些调整a->locka,b->lockb,c->lockc,其他的基本没改动。

预备知识

  1. wait是释放锁,不用等synchronized语句块执行完毕,阻塞,之后等待被重新notify
  2. notify是释放锁,但释放锁是在synchronized语句块执行完毕后,不阻塞,继续执行下去。
  3. yield不释放锁,阻塞,但释放cpu资源
  4. sleep不释放锁,阻塞,同时占用cpu资源

上代码

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.iovi.concurrence;

public class MyThreadPrinter implements Runnable {

private String name;
private Object prev;
private Object self;

private MyThreadPrinter(String name, Object prev, Object self) {
this.name = name;
this.prev = prev;
this.self = self;
}

@Override
public void run() {
int count = 10;
while (count > 0) {
// T0
synchronized (prev) { // T1
// T2
synchronized (self) {// T3
// T4
System.out.print(name); // T5
// T6
count--; // T7
// T8
self.notify(); // T9
// T10
}

// T11
try {
// T12
prev.wait();// T13
// T14
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

public static void main(String[] args) throws Exception {
Object locka = new Object();
Object lockb = new Object();
Object lockc = new Object();

MyThreadPrinter pa = new MyThreadPrinter("A", lockc, locka);
MyThreadPrinter pb = new MyThreadPrinter("B", locka, lockb);
MyThreadPrinter pc = new MyThreadPrinter("C", lockb, lockc);

new Thread(pa).start();
new Thread(pb).start();
new Thread(pc).start();
}
}

有死锁?

程序刚开始运行时,到T3时,pa,pb,pc都执行了,则死锁。在T2位置加一行Thead.sleep(1000),基本就死锁了。

另外,在程序结束时也会产生死锁,下面是最后一轮打印:

  1. pa打印A,countpa=0时,pa卡在lockc.wait(),等待pc执行lockc.notify();

  2. pc打印C,countpc=0,lockc.notify(),pa被唤醒,pc卡在lockb.wait();

  3. pb打印B,countpb=0,lockb.notify(),pc被唤醒,卡在locka.wait();

因此,结果可想而知,pb永远处于死锁状态,而pa,pc谁先跳出循环,应该是随机的,不一定是pa先。因此,更加合理的做法是T13改成prev.wait(1000)

打印顺序有什么规律?

我们先讨论下第一轮打印的顺序,通常是ACB、也有可能是ABC,当然,非A开头也不是没有可能,一般第一轮确定后,后面顺序基本确定,但也不是一定的,只是可能性非常大,后面会详细分析为什么顺序可能会变,这里这讨论顺序的规律。

我们这里获得锁,指的是程序语句synchronized(lock)执行完毕。不考虑死锁的情况下、使用多核CPU,第一轮顺序通常是ACB,这里分析下原因:

pa占有lockc,locka时,不影响pc占有lockb,pc要执行,先获得lockb,再获得lockc,很大概率上,pc已获得lockb,只需要lockc;而pb执行先要获得locka,再获得lockb,而locka需要等pa释放,且pb需要先于pc抢占lockb。基于上面的分析,很可能pc是第二个打印,这里有一个前提,CPU多核

但并不是说,pa打印完后,pc一定占有lockb,也可能pa打印完后,pb获得locka,并且先于pc获得lockb,那么这个顺序就变成了pb第二个打印,CPU单核情况下,很可能发生。一旦这个顺序确定后,后面就基本不太可能变化。

顺序一旦确定,不再改变?

从每次运行结果来看,顺序似乎按照三个顺序下来,即头三个ACB,则后面都是重复这个顺序,先搞清楚下面的一些概念:

  • pa执行lockc.wait()后,需要pc执行lockc.notify()才能继续执行,否则处于休眠状态
  • pb执行locka.wait()后,需要pa执行locka.notify()才能继续执行,否则处于休眠状态
  • pc执行lockb.wait()后,需要pb执行lockb.notify()才能继续执行,否则处于休眠状态

假设A第一个打印,C第二个打印,B第三个打印,那么第二趟开头的可能是A,也可能是C,但不可能是B:

  1. A要先打印,则需要等pc执行lockc.notify(),这一条没问题,然后先后获得lockc、locka,这条也没问题,pb执行locka.wait()时已经满足;

  2. C要先打印,则需要等pb执行lockb.notify(),这一条没问题,然后获得lockb,且先于pa获得lockc;

我们假设的第一轮打印顺序是ACB,那么lockc.notify()先于lockb.notify()执行,很大可能,pa是先于pc获得lockc,那么很大程度上,pa先于pc打印,即第二轮第一个很可能是A。

好了,再思考下第二轮第二个会是什么,其实跟上面的类似:

  1. C要先打印,则需要等pb执行lockb.notify(),这一条没问题,然后先后获得lockb、lockc,这条没问题,pa执行lockc.wait()时已经满足;

  2. B要先打印,则需要等pa执行locka.notify(),这一条没问题,然后获得locka,且先于pc获得lockb;

我们假设已经打印了ACBA,那么lockb.notify()先于locka.notify()执行,很大可能,pc是先于pb获得lockb,那么很大程度上,C要先于B打印,即第二轮第二个很可能是C.

至于第二轮第三个,按照相同的思路,可以得出是B,后面循环按照这个思路进行即可。

由此,可以推断出一旦第一轮顺序确定,后面也就确定了,但不是绝对的。

如何顺序打印

从上面的结论来看,要顺序打印,只要第一轮顺序即可,那么其实也很简单,只要做到第一轮第二个是B,就可以顺序执行,只要将线程启动语句改成:

1
2
3
4
5
new Thread(pa).start();
Thread.sleep(10);
new Thread(pb).start();
Thread.sleep(10);
new Thread(pc).start();

这样做还能避免一开始死锁的情况。

如何有节奏地打印

T10位置添加:

1
Thread.sleep(1000);

创建线程使用以下语句:

1
2
3
MyThreadPrinter pa = new MyThreadPrinter("A", lockb, locka);
MyThreadPrinter pb = new MyThreadPrinter("B", lockc, lockb);
MyThreadPrinter pc = new MyThreadPrinter("C", locka, lockc);

对原函数做了些许调整:

private MyThreadPrinter(String name, Object prev, Object self)

改为

private MyThreadPrinter(String name, Object next, Object self)

即prev–>next,其实也很好理解,A要先于B打印,A要先获得B的锁,那么B就肯定落后于A,对于C,B要先获得C的锁,那么顺序就确定下来了。这里我们要不要考虑CPU多核?CPU多核影响的是第一轮第二个打印字母,有没有可能第二个是C,而不是B?其实在T10添加休眠语句后,pb肯定先于pc获得lockc,那么第二个打印的字母肯定是B了。

不知道读者心中有没有疑问,为什么要在T10位置休眠,如果语句放在T11或者T12,其实效果是一样的,但这里有一个概念需要额外注意:

notify()调用后,并不是马上就释放对象锁的,而是在相应的synchronized(){}语句块执行结束,自动释放锁后

谈到线程阻塞,还可以参考Java Thread.join(), 《 JAVA wait(), notify(),sleep详解》