在几乎所有编程语言中
由于多线程引发的错误都有着难以再现的特点
程序的死锁或其它多线程错误可能只在某些特殊的情形下才出现
或在不同的VM上运行同一个程序时错误表现不同
因此
在编写多线程程序时
事先认识和防范可能出现的错误特别重要
无论是客户端还是服务器端多线程Java程序最常见的多线程问题包括死锁隐性死锁和数据竞争
一死锁
死锁是这样一种情形多个线程同时被阻塞它们中的一个或者全部都在等待某个资源被释放由于线程被无限期地阻塞因此程序不可能正常终止
导致死锁的根源在于不适当地运用synchronized关键词来管理线程对特定对象的访问synchronized关键词的作用是确保在某个时刻只有一个线程被允许执行特定的代码块因此被允许执行的线程首先必须拥有对变量或对象的排他性的访问权当线程访问对象时线程会给对象加锁而这个锁导致其它也想访问同一对象的线程被阻塞直至第一个线程释放它加在对象上的锁
由于这个原因在使用synchronized关键词时很容易出现两个线程互相等待对方做出某个动作的情形代码一是一个导致死锁的简单例子
//代码一
class Deadlocker {
int field_;
private Object lock_ = new int[];
int field_;
private Object lock_ = new int[];
public void method(int value) {
synchronized (lock_) {
synchronized (lock_) {
field_ = ; field_ = ;
}
}
}
public void method(int value) {
synchronized (lock_) {
synchronized (lock_) {
field_ = ; field_ = ;
}
}
}
}
参考代码一考虑下面的过程
◆一个线程(ThreadA)调用method()
◆ThreadA在lock_上同步但允许被抢先执行
◆另一个线程(ThreadB)开始执行
◆ThreadB调用method()
◆ThreadB获得lock_继续执行企图获得lock_但ThreadB不能获得lock_因为ThreadA占有lock_
◆现在ThreadB阻塞因为它在等待ThreadA释放lock_
◆现在轮到ThreadA继续执行ThreadA试图获得lock_但不能成功因为lock_已经被ThreadB占有了
◆ThreadA和ThreadB都被阻塞程序死锁
当然大多数的死锁不会这么显而易见需要仔细分析代码才能看出对于规模较大的多线程程序来说尤其如此好的线程分析工具例如JProbe Threadalyzer能够分析死锁并指出产生问题的代码位置
二隐性死锁
隐性死锁由于不规范的编程方式引起但不一定每次测试运行时都会出现程序死锁的情形由于这个原因一些隐性死锁可能要到应用正式发布之后才会被发现因此它的危害性比普通死锁更大下面介绍两种导致隐性死锁的情况加锁次序和占有并等待
加锁次序
当多个并发的线程分别试图同时占有两个锁时会出现加锁次序沖突的情形如果一个线程占有了另一个线程必需的锁就有可能出现死锁考虑下面的情形ThreadA和ThreadB两个线程分别需要同时拥有lock_lock_两个锁加锁过程可能如下
◆ThreadA获得lock_
◆ThreadA被抢占VM调度程序转到ThreadB
◆ThreadB获得lock_
◆ThreadB被抢占VM调度程序转到ThreadA
◆ThreadA试图获得lock_但lock_被ThreadB占有所以ThreadA阻塞
◆调度程序转到ThreadB
◆ThreadB试图获得lock_但lock_被ThreadA占有所以ThreadB阻塞
◆ThreadA和ThreadB死锁
必须指出的是在代码丝毫不做变动的情况下有些时候上述死锁过程不会出现VM调度程序可能让其中一个线程同时获得lock_和lock_两个锁即线程获取两个锁的过程没有被中断在这种情形下常规的死锁检测很难确定错误所在
占有并等待
如果一个线程获得了一个锁之后还要等待来自另一个线程的通知可能出现另一种隐性死锁考虑代码二
//代码二
public class queue {
static javalangObject queueLock_;
Producer producer_;
Consumer consumer_;
public class Producer {
void produce() {
while (!done) {
synchronized (queueLock_) {
produceItemAndAddItToQueue();
synchronized (consumer_) {
consumer_notify();
}
}
}
}
public class Consumer {
consume() {
while (!done) {
synchronized (queueLock_) {
synchronized (consumer_) {
consumer_wait();
}
removeItemFromQueueAndProcessIt();
}
}
}
}
}
}
在代码二中Producer向队列加入一项新的内容后通知Consumer以便它处理新的内容问题在于Consumer可能保持加在队列上的锁阻止Producer访问队列甚至在Consumer等待Producer的通知时也会继续保持锁这样由于Producer不能向队列添加新的内容而Consumer却在等待Producer加入新内容的通知结果就导致了死锁
在等待时占有的锁是一种隐性的死锁这是因为事情可能按照比较理想的情况发展—Producer线程不需要被Consumer占据的锁尽管如此除非有绝对可靠的理由肯定Producer线程永远不需要该锁否则这种编程方式仍是不安全的有时占有并等待还可能引发一连串的线程等待例如线程A占有线程B需要的锁并等待而线程B又占有线程C需要的锁并等待等
要改正代码二的错误只需修改Consumer类把wait()移出synchronized()即可
三数据竞争
数据竞争是由于访问共享资源(例如变量)时缺乏或不适当地运用同步机制引起如果没有正确地限定某一时刻某一个线程可以访问变量就会出现数据竞争此时赢得竞争的线程获得访问许可但会导致不可预知的结果
由于线程的运行可以在任何时候被中断(即运行机会被其它线程抢占)所以不能假定先开始运行的线程总是比后开始运行的线程先访问到两者共享的数据另外在不同的VM上线程的调度方式也可能不同从而使数据竞争问题更加复杂
有时数据竞争不会影响程序的最终运行结果但在另一些时候有可能导致不可预料的结果
良性数据竞争
并非所有的数据竞争都是错误考虑代码三的例子假设getHouse()向所有的线程返回同一House可以看出这里会出现竞争BrickLayer从HousefoundationReady_读取而FoundationPourer写入到HousefoundationReady_
//代码三
public class House {
public volatile boolean foundationReady_ = false;
}
public class FoundationPourer extends Thread {
public void run() {
House a = getHouse();
afoundationReady_ = true;
}
}
public class BrickLayer extends Thread {
public void run() {
House a = getHouse();
while (!afoundationReady_) {
try {
Threadsleep();
}
catch (Exception e) {
Systemerrprintln(Exception: + e);
}
}
}
}
}
尽管存在竞争但根据Java VM规范Boolean数据的读取和写入都是原则性的也就是说VM不能中断线程的读取或写入操作一旦数据改动成功不存在将它改回原来数据的必要(不需要回退)所以代码三的数据竞争是良性竞争代码是安全的
恶性数据竞争
首先看一下代码四的例子
//代码四
public class Account {
private int balance_; // 账户余额
public int getBalance(void) {
return balance_;
}
public void setBalance(int setting) {
balance_ = setting;
}
}
public class CustomerInfo {
private int numAccounts_;
private Account[] accounts_;
public void withdraw(int accountNumber int amount) {
int temp = accounts_[accountNumber]getBalance();
temp = temp amount;
accounts_[accountNumber]setBalance(temp);
}
public void deposit(int accountNumber int amount) {
int temp = accounts_[accountNumber]getBalance();
temp = temp + amount;
accounts_[accountNumber]setBalance(temp);
}
}
如果丈夫A和妻子B试图通过不同的银行柜员机同时向同一账户存钱会发生什么事情?让我们假设账户的初始余额是元看看程序的一种可能的执行经过
B存钱元她的柜员机开始执行deposit()首先取得当前余额把这个余额保存在本地的临时变量然后把临时变量加临时变量的值变成现在在调用setBalance()之前线程调度器中断了该线程
A存入元当B的线程仍处于挂起状态时A这面开始执行deposit()getBalance()返回(因为这时B的线程尚未把修改后的余额写入)A的线程在现有余额的基础上加得到并把这个值保存到临时变量接着A的线程在调用setBalance()之前也被中断执行
现在B的线程接着运行把保存在临时变量中的值()写入到余额柜员机告诉B说交易完成账户余额是元接下来A的线程继续运行把临时变量的值()写入到余额柜员机告诉A说交易完成账户余额是元
最后得到的结果是什么?B的存款消失不见就像B根本没有存过钱一样
也许有人会认为可以把getBalance()和setBalance()改成同步方法保护Accountbalance_解决数据竞争问题其实这种办法是行不通的synchronized关键词可以确保同一时刻只有一个线程执行getBalance()或setBalance()方法但这不能在一个线程操作期间阻止另一个线程修改账户余额
要正确运用synchronized关键词就必须认识到这里要保护的是整个交易过程不被另一个线程干扰而不仅仅是对数据访问的某一个步骤进行保护
所以本例的关键是当一个线程获得当前余额之后要保证其它的线程不能修改余额直到第一个线程的余额处理工作全部完成正确的修改方法是把deposit()和withdraw()改成同步方法
死锁隐性死锁和数据竞争是Java多线程编程中最常见的错误要写出健壮的多线程代码正确理解和运用synchronized关键词是很重要的另外好的线程分析工具例如JProbe Threadalyzer能够极大地简化错误检测对于分析那些不一定每次执行时都会出现的错误分析工具尤其有用