铁文整理
14.5 同步
在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取相同的对象,并且每一个线程都调用了一个修改该对象状态的方法,将会发生什么呢?可以想像,线程彼此踩了对方的脚。根据各线程访问数据的次序,可能会产生讹误的对象。这样一个情况通常称为竞争条件(race condition)。
14.5.1 竞争条件的一个例子
为了避免多线程引起的对共享数据的讹误,必须学习如何同步存取。在本节中,你会看到如果没有使用同步会发生什么。在下一节中,将会看到如何同步数据存取。
在下面的测试程序中,模拟一个有若干账户的银行。随机地生成在这些略户之间转移钱款的交易。每一个账户有一个线程。每一笔交易中,会从线程所服务的账户中随机转移一定数目的钱款到另一个随机账户。
模拟代码非常直观。我们有具有transfer方法的Bank类。该方法从一个账户转移一定数目的钱款到另一个账户(还没有考虑负的账户余额)。如下是Bank类的transfer方法的代码。
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());
}
这里是TransferRunnable类的代码,它的run方法不断地从一个固定的银行账户取出钱款。在每一次迭代中,run方法随机选择一个目标账户和一个随机账户,调用bank对象的transfer方法,然后睡眠。
public class TransferRunnable implements Runnable {
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) {
}
}
}
当这个模拟程序运行时,不清楚在某一时刻某一银行账户中有多少钱。但是,知道所有账户的总金额应该保持不变,因为所做的一切不过是从一个账户转移钱款到另一个账户。
在每一次交易的结尾,transfer方法重新计算总值并打印出来。
本程序永远不会结束。只能按CTRL+C来终止这个程序。
下面是典型的输出:
……
正如前面所示,出现了错误。在最初的交易中,银行的余额保持在$100 000。这是正确的,因为共100个账户,每个账户$1 000。但是,过一段时间,余额总量有轻微的变化。当运行这个程序的时候,会发现有时很快就出错了,有时很长的时间后余额发生混乱。这样的状态不会带来信任感,人们很可能不愿意将辛苦挣来得钱存到这个银行。
例14-5到例14-7中的程序提供了完全的源代码。看看是否可以从代码中找出问题。下一节将解说其中神秘。
例14-5 UnsynchBankTest.java
/**
* This program shows data corruption when multiple threads access a data
* structure.
*
* @version 1.30 2004-08-01
* @author Cay Horstmann
*/
public class UnsynchBankTest {
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();
}
}
public static final int NACCOUNTS = 100;
public static final double INITIAL_BALANCE = 1000;
}
例14-6 Bank.java
/**
* A bank with a number of bank accounts.
*
* @version 1.30 2004-08-01
* @author Cay Horstmann
*/
public class Bank {
/**
* Constructs the bank.
*
* @param n
* the number of accounts
* @param initialBalance
* the initial balance for each account
*/
public Bank(int n, double initialBalance) {
accounts = new double[n];
for (int i = 0; i < accounts.length; i++)
accounts[i] = initialBalance;
}
/**
* Transfers money from one account to another.
*
* @param from
* the account to transfer from
* @param to
* the account to transfer to
* @param amount
* the amount to transfer
*/
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());
}
/**
* Gets the sum of all account balances.
*
* @return the total balance
*/
public double getTotalBalance() {
double sum = 0;
for (double a : accounts)
sum += a;
return sum;
}
/**
* Gets the number of accounts in the bank.
*
* @return the number of accounts
*/
public int size() {
return accounts.length;
}
private final double[] accounts;
}
例14-7 TransferRunnable.java
/**
* A runnable that transfers money from an account to other accounts in a bank.
*
* @version 1.30 2004-08-01
* @author Cay Horstmann
*/
public class TransferRunnable implements Runnable {
/**
* Constructs a transfer runnable.
*
* @param b
* the bank between whose account money is transferred
* @param from
* the account to transfer money from
* @param max
* the maximum amount of money in each transfer
*/
public TransferRunnable(Bank b, int from, double max) {
bank = b;
fromAccount = from;
maxAmount = max;
}
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) {
}
}
private Bank bank;
private int fromAccount;
private double maxAmount;
private int DELAY = 10;
}
14.5.2 详解竞争条件
上一节中运行了一个程序,其中有几个线程更新银行账户余额。一段时间之后,错误不知不觉地出现了,总额要么增加,要么变少。当两个线程试图同时更新同一个账户的时候,这个问题就出现了,假定两个线程同时执行指令accounts[to] += amount;
问题在于这不是原子操作。该指令可能被处理如下:
-
将accounts[to]加载到寄存器;
-
增加amount;
-
将结果写回accounts[to]。
现在,假定第1个线程执行步骤1和2,然后,它被剥夺了运行权。假定第2个线程被唤醒并修改了accounts数组中的同一项。然后,第1个线程被唤醒并完成其第3步。
这样,这一动作擦去了第二个线程所做的更新,于是,总金额不再正确(见图14-4)。
我们的测试程序检测到这一讹误。(当然,如果线程在运行这一测试时被中断,也有可能会出现失败警告!)
注释:可以具体看一下执行我们的类中的每一个语句的虚拟机的字节码。运行命令
javap -c -v Bank
对Bank.class文件进行反编译,例如,代码行accounts[to] += amount;被转换为下面的字节码:
21: aload_0
22: getfield #2 // Field accounts:[D
25: iload_1
26: dup2
27: daload
28: dload_3
29: dsub
30: dastore
这些代码的含义无关紧要。重要的是增值命令是由几条指令组成的,执行它们的线程可以在任何一条指令点上被中断。
出现这一讹误的可能性有多大呢?这里通过将打印语句和更新余额的语句交织在一起执行,增加了发生这种情况的机会。
如果删除打印语句,讹误的风险会降低一点,因为每个线程在再次睡眠之前所做的工作很少,调度器在计算过程中剥夺线程的运行权可能性很小。但是,讹误的风险并没有完全消失。如果在负载很重的机器上运行许多线程,那么,即使删除了打印语句,程序依然会出错。这种错误可能会几分钟、几小时或几天出现一次。坦白地说,对程序员而言,很少有比无规律出现错误更槽的事情了。
真正的问题是transfer方法的执行过程中可能会被中断。如果能够确保线程在失去控制之前方法运行完成,那么银行账户对象的状态永远不会出现讹误。
14.5.3 锁对象
从Java SE 5.0开始,有两种机制防止代码块受并发访问的干扰。Java语言提供一个synchronized关键字达到这一目的,并且Java SE 5.0引入了ReentrantLock类。synchronized关键字自动提供一个锁以及相关的“条件”,对于大多数需要显式锁的情况,这是很便利的。但是,我们相信在读者分别阅读了锁和条件的内容之后,理解synchronized关键字是很轻松的事情。java.util.concurrent框架为这些基础机制提供独立的类,在此以及第14.5.4节加以解释这个内容,读者理解了这些构建块之后,将讨论第14.5.5节。
用ReentrantLock保护代码块的基本结构如下:
myLock.lock(); // a Reentrantlock object
try {
// critical section
} finally {
myLock.unlock(); // make sure the lock is unlocked even if an
// exception is thrown
}
这一结构确保任何时刻只有一个线程进入临界区。一旦一个线程封锁了锁对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们被阻塞,直到第一个线程释放锁对象。
警告;把解锁操作括在finally子句之内是至关重要的。如果在临界区的代码抛出异常,锁必须被释放。否则,其他线程将永远阻塞。
让我们使用一个锁来保护Bank类的transfer方法。
public class Bank {
public void transfer(int from, int to, double amount) {
bankLock.lock();
try {
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());
} finally {
bankLock.unlock();
}
}
privatLockck bankLock = new ReentrantLock(); // ReentrantLock implements the Lock interface
}
假定一个线程调用transfer,在执行结束前被剥夺了运行权。假定第二个线程也调用transfer,由于第二个线程不能获得锁,将在调用lock方法时被阻塞,它必须等待第一个线程完成transfer方法的执行之后才能再度被激活。当第一个线程释放锁时,那么第二个线程才能开始运行(见图14-5)。
尝试一下。添加加锁代码到transfer方法并且再次运行程序。你可以永远运行它,而银行的余额不会出现讹误。
注意每一个Bank对象有自己的ReentrantLock对象。如果两个线程试图访问同一个Bank对象,那么锁以串行方式提供服务。但是,如果两个线程访问不同的Bank对象,每一个线程得到不同的锁对象,两个线程都不会发生阻塞。本该如此,因为线程在操纵不同的Bank实例的时候,线程之间不会相互影响。
锁是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个持有计数(hold count)来跟踪对lock方法的嵌套调用。线程在每一次调用lock都要调用unlock来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用相同的锁的方法。
例如,transfer方法调用getTotalBalance方法,这也会封锁bankLock对象,此时bankLock对象的持有计数为2。当getTotalBalance方法退出的时候,持有计数变回1。当transfer方法退出的时候,持有计数变为0。线程释放锁。
通常,可能想要保护需若干个操作来更新或检査共享对象的代码块。要确保这些操作完成后,另一个线程才能使用相同对象。
警告:要留心临界区中的代码,不要因为异常的抛出而跳出了临界区。如果在临界区代码结束之前抛出了异常,finally子句将释放锁,但会使对象可能处于一种受损状态。
API:java.util.concurrent.locks.ReentrantLock 5.0
-
ReentrantLock():构建一个可以被用来保护临界区的可重入锁。
-
ReentrantLock(boolean fair):构建一个带有公平策略的锁。一个公平锁偏爱等待时间最长的线程,但是,这一公平的保证将大大降低性能。所以,默认情况下,锁没有被强制为公平的。
警告:听起来公平锁更合理一点,但是使用公平锁比使用常规锁要慢很多。只有当你确实了解自已要做什么并且对于你要解决的问题有一个特定的理由必领使用公平锁的时候,才可以使用公平锁。即使使用公平锁,也无法确保线程调度器是公平的。如果线程调度器选择忽略一个线程,而该线程为了这个锁已经等待了很长时间,那么就没有机会公平地处理这个锁了。
14.5.4 条件对象
通常,线程进入临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。在这一节里,我们介绍Java库中条件对象的实现。(由于历史的原因,条件对象经常被称为条忤变量。)
现在来细化银行的模拟程序。我们避免选择没有足够资金的账户作为转出账户。注意不能使用下面这样的代码:
if (bank.getBalance(from) >= amount)
bank.transfer(from, tO, amount);
当前线程完全有可能在成功地完成测试,且在调用transfer方法之前将被中断。
if (bank.getBalance(from)> = amount)
// thread might be deactivated at this point
bank.transfer(from, tO, amount);
在线程再次运行前,账户余额可能已经低于提款金额。必须确保没有其他线程在本检査余额与转账活动之间修改余额。通过使用锁来保护检査与转账动作来做到这—点:
public void transfer(int from, int to, double amount) {
bankLock.lock();
try {
while (accounts[from] < amout) {
// wait
}
// transfer funds
} finally {
bankLock.unlock();
}
}
现在,当账户中没有足够的余额时,应该做什么呢?等待直到另一个线程向账户中注入了资金。但是,这一线程刚刚获得了对bankLock的排它性访问,因此别的线程没有进行存款操作的机会。这就是为什么我们需要条件对象的原因。
一个锁对象可以有一个或多个相关的条件对象。你可以用newCondition方法获得一个条件对象。习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。例如,在此设置一个条件对象来表达“余额充足”条件。
public class Bank {
public Bank() {
sufficientFunds = bankLock.newCondition();
}
private Condition sufficientFunds;
}
如果transfer方法发现余额不足,它调用sufficientFunds.await();当前线程现在被阻塞了,并放弃了锁。我们希望这样可以使得另一个线程可以进行增加用户余额的操作。
等待获得锁的线程和调用await方法的线程存在本质上的不同。一旦一个线程调用await方法,它进入该条件的等待集。当锁可用时,该线程不能马上解除阻塞。相反,它处于阻塞状态,直到另一个线程调用同一条件上的signalAll方法时为止。当另一个线程转账时,它应该调用
sufficientFunds.signalAll();
这一调用重新激活因为这一条件而等待的所有线程。当这些线程从等待集当中移出时,它们再次成为可运行的,调度器将再次激活它们。同时,它们将试图重新进入该对象,一旦锁成为可用的,它们中的某个将从await调用返回,获得该锁并从被阻塞的地方继续执行。
此时,线程应该再次测试该条件,由于无法确保该条件被满足——signalAll方法仅仅是通知正在等待的线程:此时有可能已经满足条件,值得再次去检测该条件。
注释:通常,对await的调用应该在如下形式的循环体中
while (!okToProceed())
condition.await();
至关重要的是最终需要某个其他线程调用signalAll方法,当一个线程调用await时,它没有办法重新激活自身。它寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不再运行了,这将导致令人不快的死锁现象。如果所有其他线程被阻塞,最后一个活动线程在解除其他线程的阻塞状态之前就调用await方法,那么它也被阻塞。没有任何线程可以解除其他线程的阻塞,那么该程序就挂起了。
应该何时调用signalAll呢?经验上讲,在对象的状态有利于等待线程的方向改变时调用signalAll。例如,当一个账户余额发生改变时,等待的线程会应该有机会检査余额。在例子中,当完成了转账时,调用signalAll方法。
public void transfer(int from, int to, double amount) {
bankLock.lock();
try {
while (accounts[from] < amout)
sufficientFunds.await();
// transfer funds
sufficientFunds.signalAll();
} finally {
bankLock.unlock();
}
}
注意调用signalAll不会立即激活一个等待线程。它仅仅解除等待线程的阻塞,以便这些线程可以在当前线程退出同步方法之后,通过竞争实现对对象的访问。
另一个方法signal,则是随机解除等待集中某个线程的阻塞状态。这比解除所有线程的阻塞更加有效,但也存在危险。如果随机选择的线程发现自己仍然不能运行,那么它再次被阻塞。如果没有其他线程再次调用signal,那么系统就死锁了。
警告:当一个线程拥有某个条件的锁时,它仅仅可以在该条件上调用await、signalAll或signal方法。
如果你运行例14-8中的程序,会注意到没有出现任何错误。总余额永远是$100 000。没有任何账户曾出现负的余额(但是,你还是需要按下CTRL+C键来终止程序)。你可能还注意到这个程序运行起来稍微有些慢——这是为同步机制中的簿记操作所付出的代价。
实际上,正确地使用条件是富有挑战性的。在开始实现自己的条件对象之前,应该考虑使用“同步器”中描述的结构。
例14-8 Bank.java
import java.util.concurrent.locks.*;
/**
* A bank with a number of bank accounts that uses locks for serializing access.
*
* @version 1.30 2004-08-01
* @author Cay Horstmann
*/
public class Bank {
/**
* Constructs the bank.
*
* @param n
* the number of accounts
* @param initialBalance
* the initial balance for each account
*/
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();
}
/**
* Transfers money from one account to another.
*
* @param from
* the account to transfer from
* @param to
* the account to transfer to
* @param amount
* the amount to transfer
*/
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();
}
}
/**
* Gets the sum of all account balances.
*
* @return the total balance
*/
public double getTotalBalance() {
bankLock.lock();
try {
double sum = 0;
for (double a : accounts)
sum += a;
return sum;
} finally {
bankLock.unlock();
}
}
/**
* Gets the number of accounts in the bank.
*
* @return the number of accounts
*/
public int size() {
return accounts.length;
}
private final double[] accounts;
private Lock bankLock;
private Condition sufficientFunds;
}
API:java.util.concurrent.locks.Lock 5.0
-
Condition newCondition():返回一个与该锁相关的条件对象。
API:java.util.concurrent.locks.Condition 5.0
-
void await():将该线程放到条件的等待集中。
-
void signalAll():解除该条件的等待集中的所有线程的阻塞状态。
-
void signal():从该条件的等待集中随机地选择一个线程,解除其阻塞状态。
14.5.5 synchronized关键字
在前面一节中,介绍了如何使用Lock和Condition对象。在进一步深入之前,总结一下有关锁和条件的关键之处:
-
锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。
-
锁可以管理试图进入被保护代码段的线程。
-
锁可以拥有一个或多个相关的条件对象。
-
每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。
Lock和Condition接口被添加到Java SE 5.0中,这也向程序设计人员提供了高度的封锁控制。然而,大多数情况下,并不需要那样的控制,并且可以使用一种嵌入到Java语言内部的机制。从1.0版开始,Java中的每一个对象都有一个内部锁。如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。
换句话说,
public synchronized void method() {
// method body
}
等价于
public void methoda() {
this.intrinsicLock.lock();
try {
// method body
} finally {
this.intrinsicLock.unlock();
}
}
例如,可以简单地声明Bank类的的transfer方法为synchronized,而不是使用一个显式的锁。
内部对象锁只有一个相关条件。wait方法添加一个线程到等待集中,notifyAll/notify方法解除等待线程的阻塞状态。换句话说,调用wait或notifyAll等价于
intrinsicCondition.await();
intrinsicCondition.signalAll();
注释:wait、notifyAll以及notify方法是Object类的final方法。Condition方法必须被命名为await、signallAll和signal以使它们不会与那些方法发生冲突。
例如,可以用Java实现Bank类如下:
public class Bank {
public synchronized void transfer(int from, int to, double amount)
throws InterruptedException {
while (accounts[from] < amount)
wait();
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());
notifyAll();
}
public synchronized double getTotalBalance() { ... }
private final double[] accounts;
}
可以看到,使用synchronized关键字来编写代码要简洁得多。当然,要理解这一代码,你必须了解每一个对象有一个内部锁,并且该锁有一个内部条件。由锁来管理那些试图进入synchronized方法的线程,由条件来管理那些调用wait的线程。
提示:synchronized方法是相对简单的。但是,初学者常常对条件感到困惑。在使用wait/notifyAll之前,应该考虑使用第14.10节描述的结构之一。
将静态方法声明为synchronized也是合法的。如果调用这种方法,该方法获得相关的类对象的内部锁。例如,如果Bank类有一个静态同步的方法,那么当该方法被调用时,Bank.class对象的锁被锁住。因此,没有其他线程可以调用同一个类的这个或任何其他的同步静态方法。
内部锁和条件存在一些局限。包括:
-
不能中断一个正在试图获得锁的线程。
-
试图获得锁时不能设定超时。
-
每个锁仅有单一的条件,可能是不够的。
在代码中应该使用哪一种?Lock和Condition对象还是同步方法?下面是一些建议:
-
最好既不使用Lock和Condition也不使用synchronized关键字。在许多情况下你可以使用java.util.concureent包中的一种机制,它会为你处理所有的加锁。例如,在第14.6节你会看到如何使用阻塞队列来同步完成一个共同任务的线程。
-
如果synchronized关键字适合你的程序,那么请尽量使用它,这样可以减少编写的代码数量,减少出错的几率,例14-9给出了用同步方法实现的银行实例。
-
如果特别需要Lock和Condition结构提供的独有特性时,才使用Lock和Condition。
例14-9 Bank.java
/**
* A bank with a number of bank accounts that uses synchronization primitives.
*
* @version 1.30 2004-08-01
* @author Cay Horstmann
*/
public class Bank {
/**
* Constructs the bank.
*
* @param n
* the number of accounts
* @param initialBalance
* the initial balance for each account
*/
public Bank(int n, double initialBalance) {
accounts = new double[n];
for (int i = 0; i < accounts.length; i++)
accounts[i] = initialBalance;
}
/**
* Transfers money from one account to another.
*
* @param from
* the account to transfer from
* @param to
* the account to transfer to
* @param amount
* the amount to transfer
*/
public synchronized void transfer(int from, int to, double amount)
throws InterruptedException {
while (accounts[from] < amount)
wait();
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());
notifyAll();
}
/**
* Gets the sum of all account balances.
*
* @return the total balance
*/
public synchronized double getTotalBalance() {
double sum = 0;
for (double a : accounts)
sum += a;
return sum;
}
/**
* Gets the number of accounts in the bank.
*
* @return the number of accounts
*/
public int size() {
return accounts.length;
}
private final double[] accounts;
}
API:java.lang.Object 1.0
-
void notifyAll():解除那些在该对象上调用wait方法的线程的阻塞状态。该方法只能在同步方法或同步块内部调用,如果当前线程不是对象锁的持有者,该方法抛出一个IllegalMonitorStateException异常。
-
void notify():随机选择一个在该对象上调用wait方法的线程,解除其阻塞状态。该方法只能在一个同步方法或同步块中调用。如果当前线程不是对象锁的持有者,该方法抛出一个IllegalMonitorStateException异常。
-
void wait():导致线程进入等待状态直到它被通知。该方法只能在一个同步方法中调用。如果当前线程不是对象锁的持有者,该方法抛出一个IllegalMonitorStateException异常。
-
void wait(long millis)
-
void wait(long millis, int nanos):导致线程进入等待状态直到它被通知或者经过指定的时间。这些方法只能在一个同步方法中调用。如果当前线程不是对象锁的持有者该方法抛出一个IllegalMonitorStateException异常。参数:millis:毫秒数;nanos纳秒数,<1 000 000。
14.5.6 同步阻塞
正如刚刚讨论的,每一个Java对象有一个锁。线程可以通过调用同步方法获得锁。还有另一种机制可以获得锁,通过进入一个同步阻塞。当线程进入如下形式的阻塞:
synchronized (obj) // this is the syntax for a synchronized block
{
// critical section
}
于是它获得obj的锁。
有时会发现“特殊的”锁,例如:
public class Bank {
public void transfer(int from, int to, int amount) {
synchronized (lock) // an ad-hoc lock
{
accounts[from] -= amount;
accounts[to] += amount;
}
System.out.println("...");
}
private double[] accounts;
private Object lock = new Object();
}
在此,lock对象被创建仅仅是用来使用每个Java对象持有的锁。
有时程序员使用一个对象的锁来实现额外的原子操作,实际上称为客户端锁定。例如,考虑Vector类,一个列表,它的方法是同步的。现在,假定在Vector<Double>中存储银行余额。这里有一个transfer方法的原始实现:
public void transfer(int from, int to, int amount) // ERROR
{
accounts.set(from, accounts.get(from) - amount);
accounts.set(to, accounts.get(to) + amount);
System.out.println("...");
}
Vector类的get和set方法是同步的,但是,这对于我们并没有什么帮助。在第一次对get的调用已经完成之后,一个线程完全可能在transfer方法中被剥夺运行权。于是,另一个线程可能在相同的存储位置存入不同的值。但是,我们可以截获这个锁:
public void transfer(int from, int to, int amount) {
synchronized (accounts) {
accounts.set(from, accounts.get(from) - amount);
accounts.set(to, accounts.get(to) + amount);
}
System.out.println("...");
}
这个方法可以工作,但是它完全依赖于这样一个事实,Vector类对自己的所有可修改方法都使用内部锁。然而,这是真的吗?Vector类的文挡没有给出这样的承诺。不得不仔细研究源代码并希望将来的版本能介绍非同步的可修改方法。如你所见,客户端锁定是非常脆弱的,通常不推荐使用。
14.5.7 监视器概念
锁和条件是线程同步的强大工具,但是,严格地讲,它们不是面向对象的。多年来,研究人员努力寻找一种方法,可以在不需要程序员考虑如何加锁的情况下,就可以保证多线程的安全性。最成功的解决方案之一是监视器,这一概念最早是由Per Brinch Hansen和Tony Hoare在20世纪70年代提出的。用Java的术语来讲,监视器具有如下特性:
-
监视器是只包含私有域的类。
-
每个监视器类的对象有一个相关的锁。
-
使用该锁对所有的方法进行加锁。换句话说,如果客户端调用obj.method(),那么obj对象的锁是在方法调用开始时自动获得,并且当方法返回时自动释放该锁。因为所有的域是私有的,这样的安排可以确保一个线程在对对象操作时,没有其他线程能访问该域。
-
该锁可以有任意多个相关条件。
监视器的早期版本只有单一的条件,使用一种很优雅的句法。可以简单地调用await accounts[from] >= balance而不使用任何显式的条件变量。然而,研究表明盲目地重新测试条件是低效的。显式的条件变量解决了这一问题。每一个条件变量管理一个独立的线程集。
Java设计者以不是很精确的方式采用了监视器概念,Java中的每一个对象有一个内部的锁和内部的条件。如果一个方法用synchronized关键字声明,那么,它表现的就像是一个监视器方法。通过调用wait/notifyAll/notify来访问条件变量。
然而,在下述的3个方面Java对象不同于监视器,从而使得线程的安全性下降:
-
域不要求必须是private。
-
方法不要求必须是synchronized。
-
内部锁对客户是可用的。
这种对安全性的轻视激怒了Per Brinch Hansen。他在一次对原始Java中的多线程的严厉评论中,写到:“这实在是令我震惊,在监视器和并发Pascal出现四分之一个世纪后,Java的这种不安全的并行机制被编程社区接受。这没有任何益处。”[Java's Insecure Parallelism, ACM SIGPLAN Notices 34:38-45, April 1999.]
14.5.8 Volatile域
有时,仅仅为了读写一个或两个实例域就使用同步,显得开销过大了。毕竟,什么地方能出错呢?遗憾的是,使用现代的处理器与编译器,出错的可能性很大。
-
多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
-
编译器可以改变指令执行的顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义,但是编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变。然而,内存的值可以被另一个线程改变!
如果你使用锁来保护可以被多个线程访问的代码,那么可以不考虑这种问题。编译器被要求通过在必要的时候刷新本地缓存来保持锁的效应,并且不能不正当地重新排序指令。详细的解释见JSR 133的Java内存模型和线程规范(参看http://www.jcp.org/en/jsr/detail?id=133)。该规范的大部分很复杂而且技术性强,但是文挡中也包含了很多解释得很清晰的例子。在http://www-106.ibm.com/developerworks/java/library有Brian Geotz写的一个更易懂的概要介绍。
注释:Brian Geotz给出了下述“同步格言”:“如果向一个变量写入值,而这个变量接下来可能会被另一个线程读取,或者,从一个变量读值,而这个变量可能是之前被另一个线程写入的,此时必须使用同步”。
volatile关键字为实例域的同步访问提供了—种免锁机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。
例如,假定一个对象有一个布尔标记done,它的值被一个线程设置却被另一个线程査询,如同我们讨论过的那样,你可以使用锁:
public synchronized boolean isDone() { return done; }
public synchronized void setDone() { done = true; }
private boolean done;
或许使用内部锁不是个好主意。如果另一个线程已经对该对象加锁,isDonw和setDone方法可能阻塞。如果注意到这个方面,一个线程可以为这一变量使用独立的Lock。但是,这也会带来许多麻烦。
在这种情况下,将域声明为volatile是合理的:
public synchronized boolean isDone() { return done; }
public synchronized void setDone() { done = true; }
private volatile boolean done;
警告:volatile变量不能提供原子性。例如,方法
public void flipDone() { done = !done; } // not atomic
不能确保改变域中的值。
在这样一种非常简单的情况下,存在第3种可能性,使用AtomicBoolean。这个类有方法get和set,且确保是原子的(就像它们是同步的一样)。该实现使用有效的机器指令,在不使用锁的情况下确保原子性。在java.util.concurrent.atomic中有许多包装器类用于原子的整数、浮点数、数组等。这些类是为编写并发实用程序的系统程序员提供使用的,而不是应用程序员。
总之,在以下3个条件下,域的并发访问是安全的:
-
域是final,并且在构造器调用完成之后被访问。
-
对域的访问由公有的锁进行保护。
-
域是volatiIe的。
注释:Java SE 5.0之前,volatile的语义是允许的。语言的设计者试图在优化使用volatile域的代码的性能方面给实现人员留有余地。但是,旧规范太复杂,实现人员难以理解,这带来了混乱和非预期的行为。例如,不可变对象不是真的不可变。
14.5.9 死锁
锁和条件不能解决多线程中的所有问题。考虑下面的情况:
账户1:$200
账户2:$300
线程1:从账户1转移$300到账户2
线程2:从账户2转移$400到账户1
如图14-6所示,线程1和线程2都被阻塞了。因为账户1以及账户2中的余额都不足以进行转账,两个线程都无法执行下去。
是否会因为每一个线程要等待更多的钱款存入而导致所有线程都被阻塞呢?这样的状态称为死锁。
在这个程序里,死锁不会发生,原因很简单。每一次转账至多$1 000。因为有100个账户,而且所有眹户的总金额是$100 000,在任意时刻,至少有一个账户的余额高于1000。从该账户取钱的线程可以继续运行。
但是,如果修改run方法,把每次转账至多$1 000的限制去掉,死锁很快就会发生。试试看。将NACCOUNTS设为10。每次交易的金额上限设置为2*INITIAL_BALANCE,然后运行该程序,程序将运行一段时间后就会挂起。
提示:当程序挂起时,键入CTRL+\,将得到一个所有线程的列表,每一个线程有一个栈踪迹,告诉你线程被阻塞的位置。像第11章叙述的那样,运行jconsole并参考线程面板(见图14-7)。
导致死锁的另一种途径是让第i个线程负责向第i个账户存钱,而不是从第i个账户取钱。这样一来,有可能将所有的线程都集中到一个账户上,每一个线程都试图从这个账户中取出大于该账户会额的钱。试试看。在SynchBankTest程序中,转用TransferRunnable类的run方法。在调用transfer时,交换fromAccount和toAccount。运行该程序并査看它为什么会立即死锁。
还有一种很容易导致死锁的情况:在SynchBankTest程序中,将signaiAll方法转换为signal,会发现该程序最终会挂起(将NACCOUNTS设为10可以更快地看到结果)。signalAll通知所有等待增加资金的线程,与此不同的是signal方法仅仅对一个线程解锁。如果该线程不能继续运行,所有的线程可能都被阻塞。考虑下面这个会发生死锁的例子。
账户1:$1990
所有其他肤户:每一个$990
线程1:从账户1转移$995到账户2
所有其他线程:从他们的账户转移$995到另一个账户
显然,除了线程1,所有的线程都被阻塞,因为他们的账户中没有足够的余额。
线程1继续执行,运行后出现如下状况:
账户1:$995
账户2:$1 985
所有其他账户:每个$990
然后,线程1调用signal。signal方法随机选择一个线程为它解锁。假定它选择了线程3。该线程被唤醒,发现在它的账户里没有足够的金额,它再次调用await。但是,线程1仍在运行,将随机地产生一个新的交易,例如,
线程1:从账户1转移$997到账户2
现在,线程1也调用await,所有的线程都被阻塞。系统死锁。
问题的起因在于调用signal。它仅仅为一个线程解锁,而且,它很可能选择一个不能继续运行的线程(在我们的例子中,线程2必须把钱从账户2中取出)。
遗憾的是,Java编程语言中没有任何东西可以避免或打破这种死锁现象。必须仔细设计程序,以确保不会出现死锁。
14.5.10 锁测试与超时
线程在调用lock方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。应该更加谨慎地申请锁。tryLock方法试图申请一个锁,在成功获得锁后返回true,否则,立即返回false,而且线程可以立即离开去做其他事情。
if (myLock.tryLock()) {
// now the thread owns the lock
try { ... }
finally { myLock.unlock(); }
} else {
// do something else
}
可以调用tryLock时,使用超时参数,像这样:
if (myLock.tryLock(100, TimeUnit.MILLISECONDS)) ...
TimeUnit是一个枚举类型,可以取的值包括SECONDS、MILLISECONDS、MICROSECONDS和NANOSECONDS。
lock方法不能被中断。如果一个线程在等待获得一个锁时被中断,中断线程在获得锁之前一直处于阻塞状态。如果出现死锁,那么,lock方法就无法终止。
然而,如果调用带有用超时参数的tryLock,那么如果线程在等待期间中断,将抛出InterruptedException异常。这是一个非常有用的特性,因为允许程序打破死锁。
也可以调用lockInterruptibly方法。它就相当于一个超时设为无限的tryLock方法。
在等待一个条件时,也可以提供一个超时:
if (myCondition.await(100, TimeUnit.MILLISECONDS))
如果一个线程被另一个线程通过调用signalAll或signal激活,或者超时时限已达到,或者线程被中断,那么await方法将返回。
如果等待的线程被中断,await方法将抛出一个InterruptedException异常。在你希望出现这种情况时线程继续等待(可能不太合理),可以使用awaitUninterruptibly方法代替await。
API:java.util.concurrent.locks.Lock 5.0
-
boolean tryLock():尝试获得锁而没有发生阻塞;如果成功返回真。这个方法会抢夺可用的锁,即使该锁有公平加锁策略,即便其他线程已经等待很久也是如此。
-
boolean tryLock(long time, TimeUnit unit):尝试获得锁,阻塞时间不会超过给定的值,如果成功返回true。
-
void lockInterruptlbly():获得锁,但是会不确定地发生阻塞。如果线程被中断,抛出一个InterruptedException异常。
API:java.util.concurrent.locks.Condition 5.0
-
boolean await(long time, TimeUnit unit):进入该条件的等待集,直到线程从等待集中移出或等待了指定的时间之后才解除阻塞。如果因为等待时间到了而返回就返回false,否则返回true。
-
void awaitUninterruptibly():进入该条件的等待集,直到线程从等待集移出才解除阻塞。如果线程被中断,该方法不会抛出InterruptedException异常。
14.5.11 读/写锁
java.util.concurrent.locks包定义了两个锁类,我们已经讨论的ReentrantLock类和ReentrantReadWriteLock类。如果很多线程从一个数据结构读取数据而很少线程修改其中数据的话,后者是十分有用的。在这种情况下,允许对读者线程共享访问是合适的当然写者线程依然必须是互斥访问的。
下面是使用读/写锁的必要步骤:
1) 构造一个ReentrantReadWriteLock对象:
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
2) 抽取读锁和写锁:
private Lock readLock = rwl.readLock();
private Lock writeLocfc = rwl.writeLock();
3) 对所有的访问者加读锁:
public double getTotalBalance() {
readLock.lock();
try {
} finally {
readLock.unlock();
}
}
4) 对所有的修改者加写锁:
public void transfer() {
writeLocfc.lock();
try {
} finally {
writeLocfc.unlock();
}
}
API:java.util.concurrent.locks.ReentrantReadWriteLock 5.0
-
Lock readLock():得到一个可以被多个读操作共用的读锁,但会排斥所有写操作。
-
LocK writeLock():得到一个写锁,排斥所有其他的读操作和写操作。
14.5.12 为什么弃用stop和suspend方法
初始的Java版本定义了一个stop方法用来终止一个线程,以及一个suspend方法用来阻塞一个线程直至另一个线程调用resume。stop和suspend方法有一些共同点:都试图控制一个给定线程的行为。
从Java SE 1.2起就弃用了这两个方法。stop方法天生就不安全,经验证明suspend方法会经常导致死锁。在本节,将看到这些方法的问题所在,以及怎样避免这些问题的出现。
首先来看看stop方法,该方法终止所有未结束的方法,包括run方法。当线程被终止,立即释放被它锁住的所有对象的锁。这会导致对象处于不一致的状态。例如,假定TransferThread在从一个账户向另一个账户转账的过程中被终止,钱款已经转出,却没有转入目标账户,现在银行对象就被破坏了。因为锁已经被释放,这种破坏会被其他尚未停止的线程观察到。
当线程要终止另一个线程时,无法知道什么时候调用stop方法是安全的,什么时候导致对象被破坏。因此,该方法被弃用了。在希望停止线程的时候应该中断线程,被中断的线程会在安全的时候停止。
注释:一些作者声称stop方法被弃用是因为它会导致对象被一个已停止的线程永久锁定。但是,这一说法是错误的,从技术上讲,被停止的线程通过抛出ThreadDeath异常退出所有它所调用的同步方法。结果是,该线程释放它持有的内部对象锁。
接下来,看看suspend方法有什么问题。与stop不同,suspend不会破坏对象。但是,如果用suspend挂起一个持有一个锁的线程,那么,该锁在恢复之前是不可用的,如果调用suspend方法的线程试图获得同一个锁,那么程序死锁:被挂起的线程等着被恢复,而将其挂起的线程等待获得锁。
在图形用户界面中经常出现这种情況。假定我们有一个图形化的银行模拟程序。Pause按钮用来挂起转账线程,而resume按钮用来恢复线程。
pauseButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
for (int i = 0; i < threads.length; i++)
threads[i].suspend(); // Don,t do this
}
});
resumeButton.addActionListener(); // calls resume on all transfer threads
假设有一个paintComponent方法,通过调用getBalances方法获得一个余额数组,从而为每一个账户绘制图表。
就像在第14.11节所看到的,按钮动作和重绘动作出现在同一个线程中——事件分配线程。考虑下面的情况:
-
某个转账线程获得bank对象的锁。
-
用户点击Pause按钮。
-
所有转账线程被挂起,其中之一仍然持有bank对象上的锁。
-
因为某种原因,该账户图表需要重新绘制。
-
paintComponent方法调用的getBalances方法。
-
该方法试图获得bank对象的锁。
现在程序被冻结了。
事件分配线程不能继续运行,因为锁由一个被挂起的线程所持有,因此,用户不能点击Resume按钮,并且这些线程无法恢复。
如果想安全地挂起线程,引入一个变量suspendRequested并在run方法的某个安全的地方测试它,安全的地方是指该线程没有封锁其他线程需要的对象的地方。当该线程发现suspendRequested变量已经设置,将会保持等待状态直到它再次获得为止。
下面的代码框架实现这一设计:
public void run() {
while (true)
if (suspendRequested) {
suspendLock.lock();
try {
while (suspendRequested)
suspendCondition.await();
} finally {
suspendLock.unlock();
}
}
}
public void requestSuspend() {
suspendRequested = true;
}
public void requestResume() {
suspendRequested = false;
suspendLock.lock();
try {
suspendCondition.signalAll();
} finally {
suspendLock.unlock();
}
}
private volatile boolean suspendRequested = false;
private Lock suspendLock = new ReentrantLock();
private Condition suspendCondition = suspendLock.newCondition();