资源访问受限--引论
在 线程与任务文中,虽然创建了多线程,并且线程之间出现了一些不可预测的CPU调度,但是由于线程之间是相互隔离的——线程没有访问共同的资源,尽管在执行任务的过程可能被CPU剥夺运行权,但是当它们再次获得运行权时对运行结果并没有影响,它们是安全的。
实际上,上篇文章通过
join()
方法演示了 一种安全访问共享资源的方法。
考虑一种情况,如果多个线程访问同一资源,并对资源内容进行修改,会发生什么情况?
对于非原子性操作,多线程下会出现竞争条件。例如,对于操作accounts[to] += amount
,可以被拆分为多个CPU指令:
- 加载accounts[to]到寄存器
- 增加amount
- 将结果写回acounts[to]
上述3个步骤中,线程执行到任一步骤时都可能被剥夺运行权。
如此一来,最后的结果就变得不可预测。
考虑一个经典的“转账”示例:
1public class UnsynchronizedTransfer {
2
3 public static void main(String[] args) {
4 double INITIAL_MONEY = 1000;
5 int ACCOUNTS = 100;
6 Bank bank = new Bank(ACCOUNTS, INITIAL_MONEY);
7 // 可以增加循环次数观察“出错”的概率提升
8 for (int i = 0; i < 2; i++) {
9 // 多个线程使用同一个bank资源
10 Thread t = new Thread(new TransferTask(bank));
11 t.start();
12 }
13 }
14
15 static class TransferTask implements Runnable {
16 private Bank bank;
17 private int size;
18 private double maxAmount = 1000;
19
20 public TransferTask(Bank bank) {
21 this.bank = bank;
22 this.size = bank.size();
23 }
24
25 @Override
26 public void run() {
27 try {
28 int from = (int) (size * Math.random());
29 int to = (int) (size * Math.random());
30 double amount = maxAmount * Math.random();
31 bank.transfer(from, to, amount);
32 Thread.sleep((long) (size * Math.random()));
33 }catch (InterruptedException e){
34 // e.printStackTrace();
35 }
36 }
37 }
38
39 static class Bank {
40 private final double[] accounts;
41
42 public Bank(int accountCount, double money) {
43 // initialize bank account
44 accounts = new double[accountCount];
45 Arrays.fill(accounts, money);
46 }
47
48 public void transfer(int from, int to, double amount) {
49 if (accounts[from] < amount) return;
50 if (from == to) return;
51 // transfer
52 accounts[from] -= amount;
53 // 这句打印语句增加了调度器剥夺线程运行权的风险
54 System.out.println(Thread.currentThread() + " move away");
55 accounts[to] += amount;
56 System.out.printf("%s: %10.2f from %d to %d, Total Balance: %10.2f%n",
57 Thread.currentThread(),
58 amount,
59 from,
60 to,
61 totalBalance());
62 }
63
64 private double totalBalance() {
65 double sum = 0;
66 for (double a : accounts) {
67 sum += a;
68 }
69 return sum;
70 }
71
72 int size() {
73 return accounts.length;
74 }
75 }
76}
77/* output(sample):
78Thread[Thread-1,5,main] move away
79Thread[Thread-0,5,main] move away
80Thread[Thread-1,5,main]: 217.65 from 30 to 20, Total Balance: 99445.52
81Thread[Thread-0,5,main]: 554.48 from 55 to 53, Total Balance: 100000.00
82*///:~
上例中,使用多个线程访问了Bank类的资源,在Bank类的transfer()
方法中,额外增加了一句控制台输出,这是为了增加线程被调度的可能性1 (如果注释这句,会发现程序异常的概率会变小)。Bank类初始化时分配100个“账户”,每个账户1000元,然后不断转账,观察所有账户总额的变化。
仔细观察输出(循环2次,出现的概率较小),我们看到:
线程1在输出 move away 之后被剥夺运行权;
接着线程0在 move away 之后也被剥夺运行权;
线程1继续运行,此时问题就出现了,总金额不是100000:
在计算总额时,线程1获取账户55的余额时少了554.48元,这正是第2步中线程0的
accounts[from] -= amount
将账户55的余额减少的金额。
实际上CPU的调度过程比上述分析复杂得多,在Bank类的transfer()方法中,每一行代码在运行时都可能被剥夺运行权,值得一提的是,上例输出操作的还不是相同的“账户”,若是操作同样的“账户”,情况将变得更复杂。
所以说线程不安全是一种不确定性,在有限的线程时,它可能发生也可能不发生,比如main()方法里只循环1次时就不会发生,循环100次就极大概率会发生。并发编程就是要消除这种不确定性。
接下来的示例,演示一个生成偶数的工具类,在多线程条件下调用生成偶数的方法并加以判断,若发现不是偶数则退出程序:
1public class UnSynchronizedEvenGenerator {
2 public static void main(String[] args) {
3 System.out.println("press Ctrl-C to exit");
4 EvenGenerator evenGenerator = new EvenGenerator();
5 ExecutorService executorService = Executors.newCachedThreadPool();
6 for (int i = 0; i < 3; i++) {
7 executorService.execute(new Thread(new EvenTask(evenGenerator)));
8 }
9 executorService.shutdown();
10 }
11
12 static abstract class AbstractIntGenerator {
13 // 此处使用了volatile关键字
14 private volatile boolean canceled = false;
15
16 public abstract int next();
17
18 public void cancel() {
19 canceled = true;
20 }
21
22 public boolean isCanceled() {
23 return canceled;
24 }
25 }
26
27
28 static class EvenGenerator extends AbstractIntGenerator {
29 private int even = 0;
30
31 @Override
32 public int next() {
33 ++even; // danger here!
34 ++even;
35 return even;
36 }
37 }
38
39 static class EvenTask implements Runnable {
40 private EvenGenerator evenGenerator;
41
42 public EvenTask(EvenGenerator evenGenerator) {
43 this.evenGenerator = evenGenerator;
44 }
45
46 @Override
47 public void run() {
48 while (!evenGenerator.isCanceled()) {
49 int next = evenGenerator.next();
50 if (next % 2 != 0) {
51 System.out.println(Thread.currentThread().toString() + next + " not even!");
52 evenGenerator.cancel();
53 }
54 }
55 }
56 }
57}
58/* output: (sample)
59press Ctrl-C to exit
60Thread[pool-1-thread-2,5,main]1427 not even!
61Thread[pool-1-thread-1,5,main]1425 not even!
62Thread[pool-1-thread-3,5,main]1429 not even!
63*///:~
上例中,使用for
循环开启了多个线程,并使用同一个evenGenerator
对象作为构造器参数:
1for (int i = 0; i < 3; i++) {
2 executorService.execute(new Thread(new EvenTask(evenGenerator)));
3}
当循环次数为1(只有一个线程)时,程序会一直执行,直到按下Ctrl-C手动结束任务;
而当循环次数大于1时,无论其运行多长时间,其总会结束。
AbstractIntGenerator类中的canceled标志是基本数据类型,而Java内存模型规定,所有原始类型对象(除了double和long)的读写都是原子的2;并且由volatile修饰,说明其是可见的,因此当发生错误时,所有线程都能读取到cancel信息而退出。
这个表述没错,程序确实也退出了,但是不够严谨。
查看示例输出可以看到,有3个线程的输出信息,按照输出顺序可以作如下推测:
线程2发现奇数,修改cancel为true
线程1发现奇数,修改cancel为true
嗯?为什么线程1还会执行?根据volatile的语义,线程1不是应该“发现”线程1对cancel的改动么?
实际上volatile的语义只能保证在线程2之后执行的语句能够发现对cancel的改动。
但是由于run()方法没有任何同步,所以线程2可能是在线程1while执行之后剥夺线程1的运行权而运行的。
2022.05.11注:实际上是volotile关键字的特性,其能保证可见性,但是不能保证有序性。
EvenGenerator类中通过两次自增运算获取下一个偶数,但是自增运算也不是原子性操作,其仍可被拆分为多个CPU指令3,并且被调度器剥夺运行权,在多线程下问题就会显现。
如何确定自增运算不是原子性的呢?
以下是javap -c -v UnSynchronizedEvenGenerator\$EvenGenerator
输出的字节码(部分)
1public int next();
2 descriptor: ()I
3 flags: ACC_PUBLIC
4 Code:
5 stack=3, locals=1, args_size=1
6 0: aload_0
7 1: dup
8 2: getfield #2 // Field even:I
9 5: iconst_1
10 6: iadd
11 7: putfield #2 // Field even:I
12 10: aload_0
13 11: dup
14 12: getfield #2 // Field even:I
15 15: iconst_1
16 16: iadd
17 17: putfield #2 // Field even:I
18 20: aload_0
19 21: getfield #2 // Field even:I
20 24: ireturn
可以看到,一个自增操作被拆分为至少43个步骤:
- get字段even
- add修改even
- put设置even
在未同步的情况下,其中执行到其中任何一步的时候都可能被CPU剥夺运行权。
如何解决多线程下共享资源的竞争条件呢?
基本上所有的并发模式在解决线程冲突问题时,都采用序列化访问共享资源的方式。即同一时刻只允许某一个线程访问资源,其他线程被阻塞。通常是通过在代码前面加上一条锁语句来实现的,由于锁产生了一种互斥的效果,这种机制也被称为互斥量( mutex )。
一般看来,任务越耗时,其被CPU调度剥夺运行权的几率越大。 ↩︎
https://docs.oracle.com/javase/specs/jls/se15/html/jls-17.html#jls-17.7 ↩︎
java文件编译的字节码会对Java代码进行拆分 ↩︎
尚不清楚前面aload_0以及dup的意义。 ↩︎