在大多数多线程程序中,都需要两个或多个线程共享数据。当两个线程都修改同一个数据时,这个数据怎么变化呢?数据变化会根据数据修改次序的不同而不同,这种情况称为竞争条件(Race Condition)。
竞争条件的例子
为了避免共享数据的访问冲突,你需要学习如何同步。在这一节,你将会看到如果你不使用同步会发生什么,在下一节,你将会学习如何使用同步。
在下面的程序中,我们模拟一个银行的存款。我们随机地在这些账户中转移钱财,每个账户一个线程。每笔转账发生在两个随机的线程间,转钱数量随机。模拟代码非常直接,我们有一个类叫做Bank,他有一个成员函数,叫transfer。下面是实现
public void transfer(int from,int to,double amount) // 注意,下面的代码在多线程条件下不安全
{
System.out.println(Thread.currentThread());
accounts[from] -= amount;
System.out.println("%10.2f from %d to %d",amount, from,to);
amounts[to] += amount;
System.out.printf(" Total Balanced: %10.2f%n",getTotalBalance());
}
下面是实现了Runnable接口的TransferRunnable类。
class TransferRunnable implements Runnalbe
{
...
public void run()
{
try
{
int toAccount = (int)(back.size() * Math.random());
double amount = maxAmount * Math.random();
back.transfer(fromAccount,toAccount,amount);
Thread.sleep((int)(DELAY * Math.random()));
}
catch(InterruptedException e)
{
}
}
}
我们不知道具体的转账过程,程序最终运行结束之后,你可以看到输出,另外,这个程序永远不会结束,你需要使用Ctrl-C。
下面是其中一种输出
Thread[Thread-11,5,main] 588.48 from 11 to 44 Total Balance : 100000.00
Thread[Thread-12,5,main] 976.11 from 12 to 22 Total Balance: 100000.00
Thread[Thread-14,5,main] 521.51 from 14 to 22 Total Balance: 100000.00
Thread[Thread-13,5,main] 359.89 from 13 to 81 Total Balance: 100000.00
...
Thread[Thread-36,5,main] 401.71 from 36 to 73 Total Balance: 99291.06
Thread[Thread-35,5,main] 691.46 from 35 to 77 Total Balance: 99291.06
Thread[Thread-37,5,main] 78.64 from 37 to 3 Total Balance: 99291.06
Thread[Thread-34,5,main] 197.11 from 34 to 69 Total Balance: 99291.06
Thread[Thread-36,5,main] 85.96 from 36 to 4 Total Balance: 99291.06
. . .
Thread[Thread-4,5,main]Thread[Thread-33,5,main] 7.31 from 31 to 32 Total Balance: 99979.24
627.50 from 4 to 5 Total Balance: 99979.24
你可以看到,事情发生了错误。只有少数的转账会保持总量是100,000美金,也就是初始的每个账户1000美金,一共100个账户。但是操作之后,平衡被打破。经过一段时间之后,总量将会减少。这并不是我们希望看到的,因为钱不翼而飞了。
解释竞争状态
在前面,我们使用多个线程修改银行的钱。一段时间之后,钱总量将会变化。这种情况发生在两个线程同时修改变量值时。假设两个线程同时执行下面的语句。
accounts[to] += amount;
这个操作并不是原子的(Atomic)。这个指令至少包含三个步骤。
- 加载accounts[to]到寄存器。
- 增加amount。
- 将值修改到accounts[to]。
现在,假设第一个线程运行了步骤1,2,然后他被打断了。另外一个线程也在执行这个语句,执行完了之后第一个线程继续执行第三个步骤。下面的图解释了这个过程。
为什么会发生这种冲突呢?其中一个原因是使用print语句占用了大量时间。如果你删掉print语句,那么发生这种冲突的概率将大大减小,原因是在每个线程中,除了print语句外,它做的事实在是太少了。但是这个问题并没有解决,如果你开足够多的线程,或者你让程序运行足够长的时间,这个问题同样会出现。
真正的问题是你的代码会被打断,然后被其他线程抢占。如果你保证这段代码在运行完毕之前不被打断,那么就可以解决这个问题。
锁对象
从Java SE 5.0开始,有两种方法可以保护一段代码。Java提供了synchronized关键字,此外,还引入了ReentrantLock类。synchronized关键字自动提供一个锁和一个相关联的“状态”,在大多数情况下都是够用的。但是,我们认为如果你先了解锁和状态,你将更容易理解synchronized关键字。java.util.concurrent框架提供了这些基础机制的单独的类,我们将在后续介绍。然后,我们就会介绍synchronized关键字。
使用ReentrantLock保护一段代码的基本模式是
myLock.lock(); // 一个ReentrantLock对象
try
{
// 关键代码部分
}
finally
{
myLock.unlock(); // 保证在任何情况下,这段代码都会被执行。
}
这段代码保证了关键代码只有一个线程在执行,在此期间,所有线程都没有办法打断。代码执行完了之后,其他代码才可以执行关键代码。
需要注意的是,unlock操作必须要放在finally代码块中,否则如果程序抛出异常,锁将永远无法被释放,其他线程永远无法访问这段代码。
下面让我们重写transfer方法。
public class Bank
{
public void transfer(int from, int to, int amount)
{
bankLock.lock();
try
{
System.out.println(Thread.currentThread());
accounts[from] -= amount;
System.out.printf("%10.2f from %d to %d",amount,from, to);
accounts[to] += amount;
System.out.printf(" Total Balanced: %10.2f%n",getBalanced());
}
finally
{
backlock.unlock();
}
}
...
private Lock backLock = new ReentrantLock(); // ReentrantLock实现了Lock接口。
}
假设一个线程在调用transfer方法,另外一个线程也想调用,但是此时他没有办法获得锁,于是他进入阻塞状态,等到第一个线程的transfer方法调用完毕后,另外一个线程获得锁,才能够执行transfer方法。这样程序就可以一直运行下去了。
注意,所有线程共享一个锁对象,这样才能保证每个线程串行访问一个资源,如果不同对象使用不同的锁,那么他们就无法实现同步,这是合理的,如果你到一个银行存钱,其他银行的状态不应该影响你的行为。
我们用到的锁对象名字是ReentrantLock(可重入锁),这是有含义的。这个对象维护一个持有量计数变量,每次lock方法必然对应一次unlock方法的调用,这样,你就可以嵌套调用锁对象。
比如,transfer方法调用了getTotalBalanced方法,这个方法也会用到同样的锁,这时候,锁里面的持有量计数变量就变为2,当退出getTotalBalance方法后,计数变量回退到1,当transfer方法退出时,计数变量回退到0,锁被释放。
通常,你希望在修改共享变量时使用锁,这样,其他线程就没有办法在修改的过程中打断线程的执行。
条件对象
通常,你需要使用条件对象使一个线程运行到满足某个条件。我们首先看一个例子。
让我们重新定义Bank类,我们向让账户的余额大于0,也就是每次转账的时候,我们不希望使账户余额为负值,你可能会使用下面的代码
if(bank.getBalance(from) >= amount)
{
bank.transfer(from,to,amount);
}
但是有个小问题,你要考虑线程会在某个地方停止。在其他地方貌似没有什么问题,但是如果在条件判断之后,线程被阻塞,就会出问题,这个时候,你认为条件成立了,但是其他线程可能会修改账户的值,然后你的条件不成立,但是你还是认为你的条件成立。
为了让你的程序可以正常运行,你开始加锁,加完锁之后,你的代码大概是这样的。
public void transfer(int from, int to, int amount)
{
bankLock.lock();
try
{
while(accounts[from] < amount)
{
// 等待
...
}
// 转账
...
}
finally
{
backLock.unlock();
}
}
看起来很棒,但是我们仔细想一想,如果账户中的余额不够,你就会等待,直到其他线程给你的账户里面转账,但是你再仔细想,你会发现你已经给这段代码加了锁,其他线程没有办法转账,于是所有的线程都在等待。为了解决这个问题,你需要使用条件对象。
一个锁对象可以创建多个条件对象,通过使用newCondition()方法,你需要给定一个名字,用来代表这个对象,比如,你可以创建一个“sufficient funds”条件,用于表示是否有足够的余额供转账。
class Bank
{
public Bank()
{
...
sufficientFunds = bankLock.newCondition();
}
...
private Condition sufficientFunds;
}
当程序发现余额不够时,可以调用下面
dufficientFunds.await();
这样当前线程就被阻塞了,而且会释放锁,这样其他线程就可以增加账户余额了。
这种类型的阻塞和其他类型的阻塞有一个重要区别,调用await方法的阻塞会检查一组条件,在条件满足之前,即使锁可以获取,程序也不会运行。相反,只有其他线程在相同的条件上调用了signalAll方法时,程序才有机会运行。
当另一个线程转账完毕后,它应该调用
sufficientFunds.signalAll();
这个调用会使满足条件的线程重新激活,当线程条件满足时,线程再次变为可执行状态。当锁对象可用时,它们就会从停止的地方开始运行。
在这种时候,他们应该再次检查条件是否可行,signalAll方法没有义务检查所有条件。
需要注意最少有一个线程具备唤醒其他线程的能力,不然程序就会进入死锁状态。当程序调用await方法时,他将信任交给其他线程,自己没有任何能力使自己运行。但是如果其他线程都没有使他运行,那么他就会一直等待。当最后一个可运行的线程调用了await方法时,整个程序就没有任何可执行的线程了,也就是说,整个程序都处于挂起状态。
那么你应该在什么时候调用signalAll方法呢?第一法则是任何修改对象的操作之后都应该调用signalAll方法。比如,在我们的银行例子中,我们应该在每次余额改变的时候调用signalAll方法。
public void transfer(int from, int to, int amount)
{
bankLock.lock();
try
{
while(amounts[from] amount)
sufficientFunds.await();
// 转账
...
sufficientFunds.signalAll();
}
finally
{
backLock.unlock();
}
}
需要注意的是,signalAll不会马上激活等待的线程。他只是在当前同步方法运行完成之后,给了其他线程重入的机会。
另外一个方法,signal,只随机地从当前等待的线程中释放一个线程。这种方法更高效,但是有一些危险。如果随机选择的线程发现条件仍然不满足,那么他就还是会被阻塞。如果没有其他的signal方法调用,那么还是会死锁。
最后,需要注意一点,只有当线程抢到了这个条件的锁的时候,才能调用await, signalAll和signal方法。
如果你使用了示例程序,那么你会发现,这段代码可以一直运行下去,但是你会发现代码运行地更慢了,这就是你保持同步的代价。
在实际编码过程中,使用条件变量是很困难的一件事。你可以考虑使用默认的条件。
stnchronized关键字
总结一下,有关Lock和Condition对象的要点包括:
- Lock可以保护一段代码,只允许一个程序访问一段代码
- 锁会管理尝试运行同一段代码的线程。
- 一个锁可以与多个条件对象绑定。
- 每个条件对象管理进入了受保护代码,但是不能继续执行的线程。
Lock和Condition都是在Java SE 5加入的。但是,在大多数时间,你都不需要这种特性,因为Java语言自带处理办法。自Java 1.0开始,Java中的每个对象都有一个锁对象,如果一个方法使用synchronized关键字定义,那么这个锁将会保留整个方法。这就是说,为了调用这个方法,线程必须获取对象的锁。
换句话说
public synchronized void method()
{
// method body
}
等同于
public void method()
{
this.intrinsicLock.lock();
try
{
// 方法处理
}
finally
{
this.intrinsicLock.unlock();
}
}
比如,相比使用一个显式的锁,你可以直接将transfer方法声明为synchronized。
对象内置锁有一个相关联的条件。wait方法将线程加入到等待序列中,notifyAll/notify方法会释放等待的线程。换句话说,调用wait和notifyAll方法,等价于
intrinsicCondition.await();
intrinsicCondition.signalAll();
wait, notifyAll和notify方法都是Object类的final方法,Condition类的await,signalAll和signal方法之所以这样命名,是为了不冲突。
比如,你可以使用下面的方法实现Bank类。
class Bank
{
public synchronized void transfer(int from, int to, int amount) throws InterruptedException
{
while(accounts[from] < amount)
wait();
accounts[from] -= amount;
accounts[to] += amount;
notifyAll(); // 提醒所有线程,应该判断条件是否成立。
}
public synchronized double getTotalBalance() {...}
private double[] accounts;
}
你可以看到,使用synchronized可以使代码更简洁。当然,为了理解这段代码,你必须知道每个对象有一个内置锁,这个锁有一个内置条件。这个锁管理着synchronized修饰的方法,这个条件管理者所有调用了wait方法的方法。
同步的方法看起来很直接,但是,初学者可能会对条件感到困惑。
你也可以使用synchronized修饰静态方法。如果这个方法被调用,那么他会申请内部锁,比如,如果Bank类有一个静态的同步方法,那么调用这个方法时,Bank.class将会被锁住,这将会导致其他任何线程可以调用这个方法,也不能调用这个类的其他同步静态方法。
内置锁和条件有一些限制,比如:
- 你没有办法打断一个正在尝试获取锁的线程。
- 当获取锁时你没有办法设置超时时间。
- 一个锁一个条件效率非常低。
那么你需要在你的方法中使用哪一个呢?synchronized关键字?还是Lock和Condition?下面是推荐的方法: - 最好使用Lock和Condition,而不是synchronized。在很多情况下,你可以使用java.util.concurrent包提供的机制替代所有锁操作。
- 如果synchronized在所有方面都非常适合你的条件,那么你可以使用它。你可以写更少的代码。
- 如果你需要更强的功能,使用Lock和Condition。
同步代码块
就像我们刚才讨论的,每个Java对象有一个锁。一个线程可以通过调用一个同步函数获得锁,还有一种方式,也可以获得锁,叫做同步代码块。当线程调用下面的代码块时
synchronized(obj) // 这是同步代码块的语法
{
// 关键代码
}
那么它也会获得obj对象的锁。你优势会碰到“空对象”锁。比如
public class Bank
{
public void transfer(int from, int yo ,int amount)
{
synchronized(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(Vector<Double> accounts, int from, int to ,int amount) // 错误
{
accounts.set(from,accounts.get(from) - amount);
accounts.set(to,accounts.get(to) + amount);
System.out.println(...);
}
Vector类的get和set方法是同步的,但是他们并不能帮助我们。在Java中,transfer函数在第一个get执行完毕后被抢占是完全有可能的。另外一个线程可能会在同样的地方存储不同的值。但是,我们可以修改这段代码:
public void transfer(Vector<Double> accounts, 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类从来没有这样的承诺。每次你使用多线程之前,你都需要仔细考虑是否引入了不同步的修改方法,你可以看到,客户端测锁很脆弱,通常不考虑使用。
观察器概念
锁和条件都非常强大,但是他们不遵从面向对象的要求。很长时间依赖,设计者都希望设计合理的方法,避免程序员在设计程序时还要考虑同步问题。一个最成功的方法是使用观察器的概念。在Java中,观察器的概念具有以下特征。
- 观察器是只有私有域的类
- 每个观察器都有对应的锁
- 类的所有方法都被这个锁锁住,这意味着只要你调用obj.method,这个方法就会被锁住,再加上所有域都是私有的,没有其他任何线程可以访问这个类。
- 锁可以有任意多的条件
早期的观察器只有一个条件对象,这么做的好处是代码很优雅。你可以直接调用await accounts[from] >= balance,不用显示地使用条件对象。但这么做的问题是条件变量的重新测试将会变得耗时。通过显式地使用条件对象,可以分别管理每个线程,解决这个问题。
Java设计者部分采纳了观察器的设计。每个对象都有一个锁,这个锁对应一个条件对象,如果方法定义为同步方法,那么他就会向观察器一样。条件对象通过wait/notify/notifyAll管理。
但是,Java对象和观察器有三个不同点。 - 域不一定是私有的。
- 方法不一定要定义为同步的。
- 对客户端而言,内部锁也是可见的
这些可能会导致Java对象不安全。
Volatile域
有时候,只要付出同步的代价,就可以保证一个线程对对象的读写。那么,这会不会有问题呢?很不幸,现代处理器和编译器会导致很多错误的发生。
- 拥有多个处理器核心的计算机,会在处理数据时将数据读取到缓冲区或寄存器,在操作完成之后,将数据写回内存。但是在多线程状态下,如果两个CPU同时处理同一个数据,那么他们的数据读取和写回将有可能产生冲突,使数据修改发生错误。
- 编译器为了使IO最大化,会重新排列指令,重排的指令与不重排的指令完成一样的功能,只会影响数据的处理过程,但这个结果成立的条件是,内存的数值在重排的处理结果之间不会发生变化,但在多线程情况下,这种情况有可能发生。
如果你用锁保护你的代码,那么你没有这些问题。根据Java的标准,及时发生指令重排,也要保证锁的功能不会失效。
volatile关键字提供了一种不需要锁的数据同步获取方法。如果你使用volatile关键字定义一个域,那么编译器和虚拟机将会保证多个线程串行地访问数据。比如,如果一个类有一个boolean类型的flag域,他可能会被多个线程访问,那我们之前会使用下面的方法:
public synchronized boolean isDone() { return done; }
public synchronized void setDoen() { done = true; }
private boolean done;
在这种情况下,可以使用关键字volatile关键字定义done,
public synchronized boolean isDone() { return done; }
public synchronized void setDone() { done = true; }
private volatile boolean done;
需要注意的是,volatile关键字没有提供任何非原子性操作,比如
public void flipDone() { done = !done; } // 不是原子操作,因此不安全。
使用volatile不能保证安全。
在上面的例子中,我们还可以考虑使用AtomicBoolean类型。这个类具有原子的get和set操作(就好像他们是同步的)。这个类使用高效的指令,不使用锁,保证操作的原子性。在java.util.concurrent.atomic中,有很多类型的原子类型包装类:包括整型,浮点型,数组等。这些类是给系统程序员使用的,而不是应用开发程序员。
最后总结,域的串行获取在下面情况下是安全的:
- 域使用final修饰,在构造之后,就可以使用。
- 每次使用这个域时,都使用锁保护。
- 使用volatile修饰这个域。
死锁
锁和条件对象不能解决多线程编程的所有问题。考虑下面的问题。
账户1的余额是200,账户2的余额是300。
线程1从账户1转300到账户2,线程2从账户2转400到账户1。
根据之前的分析可以知道,线程1和线程2肯定是阻塞的,他们都没有办法执行,因为他们的余额都不够。
那么是否有一种情况,所有账户都在等待更多的钱转入到自己的账户呢?这种情况就是死锁。
在我们的程序中,死锁会由于一个非常简单的原因发生。每个转最多只能是1000。因为100个账户一共有100,000美金,因此至少有一个账户的余额大于或等于1000,这样至少有一个账户是可以转账的。
但是,如果你没有限制1000美金的转账上限,死锁就会发生。比如,你设置账户数量是10,然后将每次转账最大数量调整为原来的两倍,那么程序一段时间之后就会进入死锁状态。
另外一种产生死锁的方式是你将转账函数的内容改为转入到自己的账户。此外,你也可以将signalAll方法修改为signal方法,也可以制造死锁。你可以尝试。
很不幸,Java没有提供任何机制从死锁中跳出,你必须自己好好设计你的程序。
锁测试和超时
当另外一个线程获得锁时,如果你调用lock方法,那么你就会无限制地等下去。你可以更小心地获得锁,你可以使用tryLock方法尝试获取锁,如果它成功获得锁,那他会返回true,否则他就会马上返回false,你的线程可以做其他事。
if (myLock.tryLock())
// 现在程序已经获得了锁
try {...}
finally { myLock.unlock(); }
else
// 做其他事
你可以在调用tryLock时加入一个时间参数,比如
if(myLock.tryLock(100,TimeUnit.MILLISECONDS)) ...
TimeUnit是一个枚举类,包含有SECONDS, MILLISECONDS, MICROSECONDS,和NANOSECONDS.
lock方法没有办法被打断,如果一个线程在等待线程响应的时候被打断,那么在锁可用之前,他将一直保持被打断。
但是,如果你使用tryLock,在等待期间被打断,那么他会抛出InterruptedException异常。这是一个有用的特性,因为他可以用于防止死锁。你也可以使用lockInterruptibly方法,他就是无限等待版本的tryLock。
当你等待一个条件对象时,你也可以使用超时参数。
myCondition.await(100,TimeUnit.MILLISECONDS);
await方法在其他线程调用signalAll或signal的时候,或者时间达到的时候,或者当前线程被打断的时候返回。
await方法被打断是抛出InterruptedException。
读写锁
java.util.concurrent.locks定义了两种类型的锁,一种是我们之前讨论过的ReentrantLock锁,另外一种时ReentrantReadWriteLock锁,后者当数据进场被读取,而很少被写入(修改)时有用。在这种情况下,线程应该支持多个线程同时读取,当然,写入进程还是需要独占。
下面是使用读写锁的步骤。
- 构造一个RetrantReadWriteLock对象:
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
- 分别导出读锁和写锁
private Lock readLock = rwl.readLock();
private Lock writeLock = rwl.writeLock();
- 在获取器中使用读锁
public double getTotalBalance()
{
readLock.lock();
try { ... }
finally { readLock.unlock(); }
}
- 在修改器中使用写锁
public void transfer(...)
{
writeLock.lock();
try{ ... };
finally { writeLock.unlock(); }
}
为什么stop和suspend方法不推荐使用?
Java的最初版本定义了stop和suspend方法,用于停止线程或使线程进入等待状态,直到其他线程调用resume方法。这两种方法都一个共同特点:在线程不配合的情况下调整线程的状态。
现在,这两个方法都不推荐使用。stop方法对于子类不安全,而经验表明,suspend方法很容易导致死锁,他们都很容易导致问题。