通过示例验证java中的指令重排序问题

Mar 1, 2021, 8:43 pm

一、 一个悲伤的故事:

有家宠物店,按照店里规定,喂狗是先投食,狗吃完后, 在狗脖子挂一个牌子,表示狗已喂。
但是实际工作中,有时店员不按这个流程走,可能会先在狗脖子上挂一个牌子,然后再去取狗粮来喂。
某一天,店员在挂好牌子后,去厨房取狗粮,这时狗主人进来,发现狗挂了已喂的牌子,但是狗又饿的汪汪叫。
狗主人质疑店员没有喂,并向市场监督管理局投诉,市场监督局认真调查,发现狗确实没有被喂。
于是根据相关法律法规,关闭了这家店。。。

下面这段程序在线演绎上述故事:

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package concurrent;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class ReOrderTest {
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
PetShop petShop = new PetShop();

for (int i = 0; i < 500000; i++) {
DogHolder dogHolder = new DogHolder();
service.execute(new Runnable() {
@Override
public void run() {
petShop.feedDog(dogHolder.dog());
}
});
service.execute(new Runnable() {
@Override
public void run() {
dogHolder.dog().check();
}
});
}

service.shutdown();
}

static class Dog {
private static int count = 0;
private final int id = ++count;
// 是否饲养过
private boolean hasFeed;
private ReentrantLock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
// 投食量
private int feetNum;


/**
* 按照店里规定,喂狗是先投食,狗吃完后, 在狗脖子挂一个牌子,表示狗已喂。
*但是实际工作中,有时店员不按这个流程走,可能会先在狗脖子上挂一个牌子,然后再去取狗粮来喂。
* 某一天,店员在挂好牌子后,去厨房取狗粮,这时狗主人进来,发现狗挂了已喂的牌子,但是狗又饿的汪汪叫。
* 狗主人质疑店员没有喂,并向市场监督管理局投诉,市场监督局认真调查,发现狗确实没有被喂。
* 于是根据相关法律法规,关闭了这家店。。。
*/
public void feed() {
feetNum = 1;
hasFeed = true;
}

public void check() {
if (hasFeed) {
if (feetNum == 0) {
System.out.println(this + "的主人震惊了!宠物店说喂了狗但是投食量却是0,这是一家黑心店!!");
System.out.println(this + "的主人向市场监督局投诉了这家店!!");
}
}
}

@Override
public String toString() {
return "Dog#" + id;
}
}

// 宠物店
static class PetShop{
public PetShop() {
System.out.println("宠物店开门营业...");
}

public void feedDog(Dog dog) {
dog.feed();
}
}

// 狗主人
static class DogHolder{
private Dog dog;

public DogHolder() {
this.dog = new Dog();
}

public Dog dog(){
return dog;
}
}
}

多次运行程序,出现文章开头的一幕::

为什么发生上述结果呢?明明feed方法是先喂狗,再给狗挂个牌子,看起来没有问题。

二、 feed方法发生了什么?

输出表示在程序的多次运行中,出现过hasFeed为true,而feetNum仍然等于0的情况,这就验证了java内部指令重排序的情况。
再看下面这个例子:

int a = 1;
int b = 2;
int c = a + b

以A表示a的赋值,B表示b的赋值,C表示相加。
在java程序实际执行中,处于性能优化的目的,A和B的执行顺序可能会颠倒,比如先执行B,再执行A。
但是不管怎么优化,C的执行不会到A或B的前面,也就是说java程序会保证A和B均执行结束,才会执行C。
上述方案,在单线程下不会有任何问题,但是在多线程环境中就不行了,模拟宠物店的程序输出结果也验证了这点。
原因参见下图:
我们以为的流程:

由于指令重排序,实际上可能会发生的流程:

三、 如何避免这种情况?

宠物店重新开张后,老板想了个解决办法。
首先不能禁止先挂牌子在喂食这种行为,因为禁止后会降低店员的工作效率,并且行为并没有损害客户的利益,只是容易让客户产生误解。
于是老板想了个好办法,给每只狗加个窗帘遮挡,具体流程如下:

1.准备给狗喂食,把窗帘拉上,让狗主人看不到狗的状态;
2.喂食(先喂食后上牌或者先上牌后喂食均可);
3.喂食结束,去除窗帘。此后狗主人才可以看到狗的状态。

java中有多重方法实现上述中窗帘的功能。出于演示程序的目的,本文使用ReentrantLock加锁方式解决前述问题。代码如下:

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package concurrent;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;

public class ReOrderTest {
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
PetShop petShop = new PetShop();

for (int i = 0; i < 500000; i++) {
DogHolder dogHolder = new DogHolder();
service.execute(new Runnable() {
@Override
public void run() {
petShop.feedDog(dogHolder.dog());
}
});
service.execute(new Runnable() {
@Override
public void run() {
dogHolder.dog().check();
}
});
}

service.shutdown();
}

static class Dog {
private static int count = 0;
private final int id = ++count;
// 是否饲养过
private boolean hasFeed;
private ReentrantLock lock = new ReentrantLock();
// 投食量
private int feetNum;

public void feed() {
try {
lock.lock();
feetNum = 1;
hasFeed = true;
} finally {
lock.unlock();
}
}

public void check() {
try {
lock.lock();
if (hasFeed) {
if (feetNum == 0) {
System.out.println(this + "的主人震惊了!宠物店说喂了狗但是投食量却是0,这是一家黑心店!!");
System.out.println(this + "的主人向市场监督局投诉了这家店!!");
}
}
} finally {
lock.unlock();
}
}

@Override
public String toString() {
return "Dog#" + id;
}
}

// 宠物店
static class PetShop{
public PetShop() {
System.out.println("宠物店开门营业...");
}

public void feedDog(Dog dog) {
dog.feed();
}
}

// 狗主人
static class DogHolder{
private Dog dog;

public DogHolder() {
this.dog = new Dog();
}

public Dog dog(){
return dog;
}
}
}

多次运行后,程序结果均如下

至此,我们解决了困扰宠物店老板的问题。
加了锁后,先上牌后喂食的程序运行流程如下:

四、 总结

  • java程序指令运行时存在指令重排序行为,虚拟机保证指令重排序不会影响单线程的执行结果,但不保证多线程下的执行结果符合预期;
  • 多线程环境下,针对共享变量的访问,应当谨慎使用,适当加锁或同步机制,以避免出现数据不一致行为。