16.5 线程同步 16.5.1 线程安全问题

16.5 线程同步

多线程编程很容易突然出现“错误情况”,这是由系统的线程调度具有一定的随机性造成的,不过即使程序偶然出现问题,那也是由于编程不当引起的。当使用多个线程来访问同一个数据时,很容易“偶然”出现线程安全问题

16.5.1 线程安全问题

银行取钱问题

关于线程安全问题,有一个经典的问题——银行取钱的问题。银行取钱的基本流程基本上可以分为如下几个步骤

  1. 用户输入账户、密码,系统判断用户的账户、密码是否匹配。
  2. 用户输入取款金额
  3. 系统判断账户余额是否大于取款金额
    1. 如果余额大于取款金额,则取款成功;
    2. 如果余额小于取款金额,则取款失败

乍一看上去,这个流程确实就是日常生活中的取款流程,这个流程没有任何问题。但一旦将这个流程放在多线程并发的场景下,就有可能出现问题。注意此处说的是有可能,并不是说一定。也许你的程序运行了一百万次都没有出现问题,但没有出现问题并不等于没有问题!

程序 银行取钱问题

按上面的流程去编写取款程序,并使用两个线程来模拟取钱操作,模拟两个人使用同一个账户并发取钱的问题。此处忽略检査账户和密码的操作,仅仅模拟后面三步操作。

账户类

下面先定义一个账户类,该账户类封装了账户编号和余额两个实例变量。

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
public class Account
{
// 封装账户编号、账户余额的两个成员变量
private String accountNo;
private double balance;
public Account(){}
// 构造器
public Account(String accountNo , double balance)
{
this.accountNo = accountNo;
this.balance = balance;
}
// 此处省略getter和setter方法,请自己补上
// 下面两个方法根据accountNo来重写hashCode()和equals()方法
public int hashCode()
{
return accountNo.hashCode();
}
public boolean equals(Object obj)
{
if(this == obj)
return true;
if (obj !=null
&& obj.getClass() == Account.class)
{
Account target = (Account)obj;
return target.getAccountNo().equals(accountNo);
}
return false;
}
}

取钱线程

接下来提供一个取钱的线程类,该线程类根据执行账户、取钱数量进行取钱操作,取钱的逻辑是当其余额不足时无法提取现金,当余额足够时系统吐出钞票,余额减少。

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
public class DrawThread extends Thread
{
// 模拟用户账户
private Account account;
// 当前取钱线程所希望取的钱数
private double drawAmount;
public DrawThread(String name , Account account
, double drawAmount)
{
super(name);
this.account = account;
this.drawAmount = drawAmount;
}
// 当多条线程修改同一个共享数据时,将涉及数据安全问题。
public void run()
{
// 账户余额大于取钱数目
if (account.getBalance() >= drawAmount)
{
// 吐出钞票
System.out.println(getName()
+ "取钱成功!吐出钞票:" + drawAmount);
try
{
Thread.sleep(1);
}
catch (InterruptedException ex)
{
ex.printStackTrace();
}
// 修改余额
account.setBalance(account.getBalance() - drawAmount);
System.out.println("\t余额为: " + account.getBalance());
}
else
{
System.out.println(getName() + "取钱失败!余额不足!");
}
}
}

上面程序是一个非常简单的取钱逻辑,这个取钱逻辑与实际的取钱操作也很相似。

主程序

程序的主程序非常简单,仅仅是创建一个账户,并启动两个线程从该账户中取钱。如下所示:

1
2
3
4
5
6
7
8
9
10
11
public class DrawTest
{
public static void main(String[] args)
{
// 创建一个账户
Account acct = new Account("1234567" , 1000);
// 模拟两个线程对同一个账户取钱
new DrawThread("甲" , acct , 800).start();
new DrawThread("乙" , acct , 800).start();
}
}

运行结果

多次运行上面程序,很有可能都会看到如下所示的错误结果

1
2
3
4
甲取钱成功!吐出钞票:800.0
乙取钱成功!吐出钞票:800.0
余额为: -600.0
余额为: -600.0

账户余额出现了负值,这不是银行希望的结果。