在学习Java并发编程时,有以下demo,简单演示商品出售的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Test {
static int t = 1000000;
public static void main(String[] args) {
// 减少t
new Thread(() -> {
while (t > 0){
t--;
System.out.println(Thread.currentThread().getName() + " " + t);
}
}).start();
// 减少t
new Thread(() -> {
while(t > 0){
t--;
System.out.println(Thread.currentThread().getName() + " " + t);
}
}).start();
}
}

这段代码是存在并发问题的,当库存为1的时候,两个线程同时判断 t > 0,然后都进入t–操作,会导致输出的值为-1。

但是在本地实测的时候却发现,尝试了很多次都没有出现t为负数的情况,然后将代码改成如下:

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
public class Test {
static int t = 1000000;

public static void main(String[] args) {
// 减少t
new Thread(() -> {
while (t > 0){
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
t--;
System.out.println(Thread.currentThread().getName() + " " + t);
}
}).start();
// 减少t
new Thread(() -> {
while(t > 0){
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
t--;
System.out.println(Thread.currentThread().getName() + " " + t);
}
}).start();
}
}

这种情况,很容易出现t为负数的情况。

为什么加了sleep就很容易出现,而不加却需要尝试很多次呢?

一个比较简单的解释

在不加sleep的情况下,由于操作只有t–,执行的特别快,所以说很难出现错误的情况,概率比较小。

而加了sleep之后,进入判断后会先进行sleep,sleep完再去处理t–。这样在t = 1时,线程判断完进入while后就睡眠,另一个线程在这100ms内有很大概率也会进入相同的判断,导致结果为-1。

在这里加sleep可以理解为加大了判断与执行t–之间的时间,导致更多的线程会在这之间执行。

另一种情况

如果sleep是在下面这个位置:

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
public class Test {
static int t = 1000000;
public static void main(String[] args) {

// 减少t
new Thread(() -> {
while (t > 0){
t--;
System.out.println(Thread.currentThread().getName() + " " + t);
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
// 减少t
new Thread(() -> {
while(t > 0){
t--;
System.out.println(Thread.currentThread().getName() + " " + t);
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}

也就是说先执行t–,然后打印,之后再进行sleep。按照这么个顺序来执行,一个线程的判断和执行扣减操作的时间并没有被加长,却也很容易出现t为负数或者打印相同t的情况。

从硬件考虑

我的电脑是8核cpu的,该程序只开了两个线程,如果当前电脑cpu资源比较充足是时,从理论上来讲,存在一种情况就是两个线程都是并行执行,并不存在竞争cpu资源的问题。

当并发的资源竞争没那么大时,每个线程在工作内存中所做的修改,可以很快的同步回主内存,另一个线程也可以从主内存中读取到最新的值(非volatile修饰的公共变量,虚拟机并不保证其可见性,但不代表一定是不可见的),然后再进行操作。

所以,当每个线程都执行的很快且并发资源竞争没那么大时(对应上面不加sleep的demo),我们可以做一个大胆一点的假设(虽然不对):对t > 0的判断和 t –操作是原子性的。也就是说,这两步执行的太快,以至于其他线程无法在中间穿插任何操作(而事实是存在穿插操作的可能),好像加了锁一样。

而加了sleep之后,无论在哪里加,都会导致当前的线程阻塞。而阻塞操作涉及到一个从用户态切换到核心态。因为Java的线程设计是每一个Java线程都绑定了一个内核线程,要阻塞一个线程或者唤醒都需要操作系统帮忙。这里由于内核切换带来的开销,可能会导致线程写入主内存延,也可能会导致另一个线程读取主内存的数据延迟,加大了发生问题的概率。