为什么需要同步
在多线程程序中,如果多个线程同时操作相同资源,将会破坏操作过程的原子性,会导致数据的不准确,因此需要在公共资源中加入同步锁,保证在该线程未完成之前,其他线程不能访问该资源。
竞争条件
大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取相同的对象,并且每一个线程都调用了一个修改该对象状态的方法,将会发生什么呢? 可以想象,线程彼此踩了对方的脚。根据各线程访问数据的次序,可能会产生 i 化误的对象。这样一个情况通常称为竞争条件 ( race condition )
例子
这里,使用CoreJava
中的银行存款的例子,假如银行有100个账户,每个账户中有固定的初始金额1000,那么该银行的总资产就会守恒,100*1000,然后我们创建多个线程,在该银行内部多次转账,由于总资产是固定的,而且转账只发生在银行内部,所以即使转账多次,这个总资产应该都是不变的,下面来编码实现。
银行类:
public class Bank
{
private final double[] accounts;
//构造函数,初始化账户数量个每个账号的金额
public Bank(int n, double initialBalance)
{
accounts = new double[n];
for (int i = 0; i < accounts.length; i++)
accounts[i] = initialBalance;
}
public void transfer(int from, int to, double amount)
{
//如果待转出金额小于转出金额,直接返回,因为账户不能写入负数
if (accounts[from] < amount) return;
//输出当前线程
System.out.print(Thread.currentThread());
accounts[from] -= amount;
//格式化输出
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
//返回总金额
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
}
//返回当前总资产
public double getTotalBalance()
{
double sum = 0;
for (double a : accounts)
sum += a;
return sum;
}
}
转移资金线程类:
public class TransferRunnable implements Runnable
{
private Bank bank;
private int fromAccount;
private double maxAmount;
private int DELAY = 10;
//构造函数
public TransferRunnable(Bank b, int from, double max)
{
bank = b;
fromAccount = from;
maxAmount = max;
}
//线程的main方法
public void run()
{
try
{
while (true) //循环执行
{
//随机产生一个转出的账户
int toAccount = (int) (bank.size() * Math.random());
//随机产生一个转出的数额
double amount = maxAmount * Math.random();//随机
bank.transfer(fromAccount, toAccount, amount);
Thread.sleep((int) (DELAY * Math.random()));
}
}
catch (InterruptedException e)
{
}
}
}
测试类:
public class UnsynchBankTest
{
public static final int NACCOUNTS = 100;
public static final double INITIAL_BALANCE = 1000;
public static void main(String[] args)
{
//生成一个银行对象,并初始化账号数量和金额
Bank b = new Bank(NACCOUNTS, INITIAL_BALANCE);
int i;
for (i = 0; i < NACCOUNTS; i++)
{
TransferRunnable r = new TransferRunnable(b, i, INITIAL_BALANCE);
Thread t = new Thread(r);
t.start();
}
}
}
结果:当你运行这段程序时,程序不会停止,需要你手动ctrl+c
停止。以下是一次部分结果
Thread[Thread-10,5,main] 15.12 from 10 to 72 Total Balance: 100000.00
Thread[Thread-38,5,main] 468.40 from 38 to 97 Total Balance: 100000.00
Thread[Thread-7,5,main] 232.32 from 7 to 49 Total Balance: 100000.00
Thread[Thread-73,5,main] 755.17 from 73 to 8 Total Balance: 100000.00
Thread[Thread-37,5,main] 419.46 from 37 to 16 Total Balance: 100000.00
Thread[Thread-48,5,main] 355.65 from 48 to 27 Total Balance: 100000.00
Thread[Thread-6,5,main] 547.55 from 6 to 50 Total Balance: 100000.00
Thread[Thread-13,5,main] 720.90 from 13 to 81 Total Balance: 100000.00
Thread[Thread-60,5,main] 323.53 from 60 to 3 Total Balance: 100000.00
Thread[Thread-8,5,main]Thread[Thread-99,5,main] 567.39 from 99 to 20 Total Balance: 99763.80
Thread[Thread-82,5,main] 272.71 from 82 to 58 Total Balance: 99763.80
Thread[Thread-1,5,main] 759.60 from 1 to 15 Total Balance: 99763.80
Thread[Thread-19,5,main] 855.30 from 19 to 11 Total Balance: 99763.80
Thread[Thread-61,5,main] 368.01 from 61 to 84 Total Balance: 99763.80
Thread[Thread-91,5,main] 328.87 from 91 to 68 Total Balance: 99763.80
236.20 from 8 to 46 Total Balance: 100000.00
Thread[Thread-87,5,main] 213.40 from 87 to 23 Total Balance: 100000.00
可以看到,在几次正常数据后,接下来几次数据出现了问题,银行总资产发生了减少,这是因为:
假定两个线程同时执行指令
accounts [ to ] + = amount ;
问题在于这不是原子操作。 该指令可能被处理如下 :
1 ) 将 accounts [ to ] 加载到寄存器 。
2 ) 增 加 amount 。
3 ) 将结果写回 accounts [ to ]。
现在, 假定第1 个线程执行步骤 1 和 2 , 然后 , 它被剥夺了运行权。 假定第2 个线程被唤醒并修改了 accounts 数组中的同一项。 然后 , 第1 个线程被唤醒并完成其第 3 步。这样 , 这一动作擦去了第二个线程所做的更新。 于是 ,总金额不再正确。
有两种机制防止代码块受并发访问的干扰 Java 语言提供一个 synchronized
关键字达到这一目的, 并且 JavaSE 5.0 引入了 ReentrantLock
类。
ReentrantLock
用 ReentrantLock 保护代码块的基本结构如下 :
myLock.lock() ; // a ReentrantLock object
try
{
}
finally
{
myLock.unlock();//必须执行解锁,否则一旦程序出现异常,其他线程将永远阻塞
}
这一结构确保任何时刻只有一个线程进人临界区。 一旦一个线程封锁了锁对象 , 其他任何线程都无法通过 lock 语句。 当其他线程调用lock 时, 它们被阻塞 , 直到第一个线程释放锁对象。
线程进人临界区,却发现在某一条件满足之后它才能执行 。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。因为账号转出额不能大于该账户的余额,所以只能等待另一个线程注入了资金,但是在临界区其他线程无法访问,那么此时就陷入了死循环了。
我们可以引入条件对象,一个锁对象可以有一个或多个相关的条件对象。 你可以用newCondition
方法获得一个条件对象。 习惯上给每一个条件对象命名为可以反映它所表达的条件的名字 。 例如 , 在此设置一个条件对象来表达 “ 余额充足 ” 条件。
如果余额不足时,调用sufficient Funds.await()
方法,当前线程被阻塞,于是锁释放了,其他线程可以访问临界区进行增加余额的操作。
一旦一个线程调用 await
方法,它进人该条件的等待集 。当锁可用时,该线程不能马上解除阻塞 。相反,它处于阻塞状态,直到另一个线程调用同一条件上的signalAll
方法时为止 。
此时Bank
类可以改为这样:
public class Bank
{
public Bank(int n, double initialBalance)
{
accounts = new double[n];
for (int i = 0; i < accounts.length; i++)
accounts[i] = initialBalance;
bankLock = new ReentrantLock();//构造函数中实例化锁
sufficientFunds = bankLock.newCondition();//实例化条件对象
}
public void transfer(int from, int to, double amount) throws InterruptedException
{
bankLock.lock();
try
{
while (accounts[from] < amount)
//当账号余额小于转出金额时,该线程阻塞,释放锁
sufficientFunds.await();
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
//等待新的线程进入,注入资金后,唤醒阻塞的线程,然后重新判断条件对象是否成立
sufficientFunds.signalAll();
}
finally
{
bankLock.unlock();
}
}
public double getTotalBalance()
{
bankLock.lock();
try
{
double sum = 0;
for (double a : accounts)
sum += a;
return sum;
}
finally
{
bankLock.unlock();
}
}
public int size()
{
return accounts.length;
}
private final double[] accounts;
private Lock bankLock;
private Condition sufficientFunds;
}
关于线程同步还有一种方法是使用synchronized
关键字,我们将在下一节中介绍。