1、线程安全问题的出现
在大多数的多线程应用程序中,两个或者两个以上的线程需要共享对同一数据的存取。这时可能发生多线程同时修改共享变量的情况,以在银行取钱来说,可以分为一下几个步骤:
1. 输入卡号和密码,系统判断是否匹配并有效
2. 用户输入支取金额
3. 系统判断账户可用余额是否足够支取
4. 如果满足支取条件则取款并更新余额,否则取款失败
我们使用两个线程来同时模拟取款操作:
public class Account {
private String acctNo;
private double balance;
//getter/setter
//有参构造方法
}
public class GetMoney extends Thread {
private Account account;
private double tranAmt; //支取金额
public GetMoney(String name, Account account, double tranAmt) {
super(name);
this.account = account;
this.tranAmt = tranAmt;
}
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (account.getBalance() >= tranAmt) {
//更新余额
account.setBalance(account.getBalance() - tranAmt);
System.out.println(getName() + " 余额为 : " + account.getBalance());
} else {
System.out.println(getName() + "账户余额不足,支取失败!");
}
}
public static void main(String[] args) {
Account account = new Account("3303214000000007654", 1000);
new GetMoney("A", account, 400).start();
new GetMoney("B", account, 400).start();
}
}
较大概率出现如下输出结果:
B 余额为 : 200.0
A 余额为 : 200.0
运行结果并不是我们希望的:
A 余额为 : 600.0
b 余额为 : 200.0
在多线程的环境下,如果一个共享资源(取款操作中的账户余额balance)被多个线程同时访问,可能会出现意向不到的情况。特定场景的分析见我的另一篇文章线程安全问题
出现这类问题的原因大多数是因为单个操作的颗粒度较小,例如取款中:①、获取账户余额;②、判断余额是否充足;③、更新余额。这明显是三个独立的操作。可以使用同步机制将颗粒度较小的原子操作包裹成颗粒度较大的操作。
2、同步代码块
为了解决线程安全问题,Java引入同步代码:
synchronized(obj){
//需要同步的操作
}
任何时刻只能有一个线程能够获得obj资源并进入同步代码块进行操作,当同步代码块执行完毕后,该线程会释放obj资源。通常使用多线程共享的资源充当同步代码块中的“锁对象”。 例如取钱的例子中应该使用账户account充当“锁对象”。我们将上例修改为:
@Override
public void run() {
synchronized (account) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (account.getBalance() >= tranAmt) {
//更新余额
account.setBalance(account.getBalance() - tranAmt);
System.out.println(getName() + " 余额为 : " + account.getBalance());
} else {
System.out.println(getName() + "账户余额不足,支取失败!");
}
}
}
使用synchronized将取款的逻辑包裹起来,任何线程进入run方法时都会试图获取account“锁对象”,如果某个线程获取到了“锁对象”,它就可以执行取款操作,其余线程由于不能获取“锁对象”,只能等待那个线程执行完同步代码块中的代码后释放锁。
添加同步代码块后程序总会输出:
A 余额为 : 600.0
B 余额为 : 200.0
3、同步方法
同步方法就是使用synchronized关键字来修饰某个方法,对于同步方法而言,无需显示地声明“锁对象”,它的“锁对象”就是对象本身(this)。
package com.xiaopeng.multthread;
public class AccountSyn {
private String acctNo;
private double balance;
public AccountSyn(String acctNo, double balance) {
this.acctNo = acctNo;
this.balance = balance;
}
public String getAcctNo() {
return acctNo;
}
public double getBalance() {
return balance;
}
public void setBalance(double balance) {
this.balance = balance;
}
//同步方法
public synchronized void draw(double tranAmt) {
if (balance >= tranAmt) {
System.out.println(Thread.currentThread().getName() + " 取款 :" + tranAmt + "元");
balance -= tranAmt;
System.out.println(Thread.currentThread().getName() + " 余额为 :" + balance + "元");
} else {
System.out.println("账户余额不足");
}
}
}
使用同步方法可以实现线程安全的类,它们具有以下特征:
- 该类的每个对象都可以被多线程访问
- 任意线程调用该对象的任意方法都可以得到正确的输出
- 线程调用之后,该对象依旧保存正常状态
增加同步的注意点:
- 代码同步后,同一时间点只能有一个线程对其中的任务进行访问,这会明显降低程序的运行效率,所以应该只对必要的逻辑进行同步操作。
- 如果某段代码会运行在单线程和多线程的环境中,那么应该提供两种版本同时保证单线程中的效率以及多线程中的安全。例如 StringBuffer 保证了多线程中的安全性, StringBuilder 保证了单线程中的高效率。