书籍是培植智慧的工具。 — 夸美绍斯集
写在前面
从上一篇《Java内存模型》中了解到多线程引发的同步问题,本篇文章就来解决这个问题。
在多线程应用中,两个或者两个以上的线程想要共享对同一数据的存取时,如果两个线程存取相同的对象,并且每一个线程都调用了修改该对象的方法,这种情况通常被称为竞争条件。
举个栗子:火车站会有多个窗口同时售票,但是火车的每一个座位是唯一的,也就是说每张票都是唯一的,火车票的资源是有限的并且不能重复出售同一个座位的火车票,这么多窗口同时售票,如果不使用同步则无法保证其原子性(原子性:一旦开始就不能中断,要么不执行,要么执行完)。假如卖出了两张相同座位的火车票,乘客上车后发现座位被别人占了,那还不立刻就火了,遇到脾气大的人动起手来也不一定啊,后果不堪设想,那么售票时就要避免这种情况的发生。把售票窗口比作线程,每一个售票窗口就是一个线程,把火车票资源比作要共享的数据,这种情况的解决办法就是当一个线程想要访问共享数据时,就给这个线程一把锁,等这个线程做完事情后再把锁给另一个想要访问共享数据的线程,只有当前线程对共享数据操作完成后,其他线程才能去操作该共享数据,这样就不会出现售出重复车票的情况。
重入锁和条件对象
synchronized关键字自动提供了锁以及相关的条件,大多数需要使用显示锁的情况使用synchronized关键字非常的方便,为了能够更好的理解synchronized关键字,我们先来了解重入锁和条件对象。
1.重入锁
重入锁ReentrantLock是Java SE 5.0引入的,就是支持重进入的锁,它表示该锁支持一个线程对资源的重复加锁。
ReentrantLock mReentrantLock = new ReentrantLock();
public void method() {
// 得到锁
mReentrantLock.lock();
try {
// 临界区 ......
} finally {
// 释放锁
mReentrantLock.unlock();
}
}
以上的代码结构确保任何时刻只能有一个线程进入临界区,临界区就是同一时刻只能有一个任务访问的代码区。一旦一个线程封锁了锁对象,其他任何线程都无法进入Lock语句。把解锁的操作放在finally中是十分必要的,如果临界区发生了异常,锁是一定要释放的,否则其他线程将会永远被阻塞。
2.条件对象
现在来看另一种情况,进入临界区时,却发现某一条件满足后代码才会执行,这时就可以使用一个条件对象来管理那些已经获得了锁却没有做有用工作的线程,条件对象也可以叫做条件变量。
下面通过一个栗子来说明为何需要条件对象:
先创建一个银行Bank类,并在构造函数中初始化账户数量和金额。
public class Bank {
private double[] mAccounts;
public Bank(int count, double amount) {
mAccounts = new double[count];
for (int i = 0 ; i < mAccounts.length ; i ++) {
mAccounts[i] = amount;
}
}
接下来在写一个转账方法transferAccounts,from是转账方,to是接收方,amount是转账金额:
public class Bank {
private ReentrantLock mReentrantLock;
private double[] mAccounts;
public Bank(int count, double amount) {
mAccounts = new double[count];
for (int i = 0 ; i < mAccounts.length ; i ++) {
mAccounts[i] = amount;
}
mReentrantLock = new ReentrantLock();
}
public void transferAccounts(int from, int to, double amount) {
mReentrantLock.lock();
try {
while (mAccounts[from] < amount) {
// 转账方账户没有足够的金额
}
} finally {
mReentrantLock.unlock();
}
}
}
当转账时发现,转账方的账户中没有足够的金额可供转给接收方,这时如果有其他账户给该转账方账户转入足够的钱就能转账成功了。可是该线程已经获取到了锁,具有排他性,其他线程就无法获取锁进行转账操作,这就是引入条件对象的原因。
一个锁对象拥有多个相关的条件对象,可以通过锁对象newCondition()函数得到一个条件对象,调用该条件对象的await()方法时,当前线程就会被阻塞并放弃了锁。
一旦一个线程调用了条件对象的await()方法,当前线程就会进入条件的等待集并处于阻塞状态,直到另一个线程调用了该条件的signalAll()方法为止。
现在回到之前的问题,转账方的账户中没有足够的金额可供转给接收方,这时可以调用条件对象的await()方法使当前线程被阻塞并放弃锁,另一个线程得到锁给之前的账户转入足够的金额,只要调用了该条件对象的signalAll()方法就会重新激活因为该条件而等待的所有线程。代码如下:
public class Bank {
private ReentrantLock mReentrantLock;
private Condition mCondition;
private double[] mAccounts;
public Bank(int count, double amount) {
mAccounts = new double[count];
for (int i = 0 ; i < mAccounts.length ; i ++) {
mAccounts[i] = amount;
}
// 创建重入锁
mReentrantLock = new ReentrantLock();
// 得到重入锁的一个条件对象
mCondition = mReentrantLock.newCondition();
}
public void transferAccounts(int from, int to, double amount) {
mReentrantLock.lock();
try {
while (mAccounts[from] < amount) {
// 调用条件对象的await()方法,阻塞当前线程并放弃锁
mCondition.await();
}
// 转账操作
mAccounts[from] = mAccounts[from] - amount;
mAccounts[to] = mAccounts[to] + amount;
// 调用条件对象的signalAll()方法,重新激活因为该条件而等待的所有线程
mCondition.signalAll();
} finally {
mReentrantLock.unlock();
}
}
}
当调用条件对象的signalAll()方法时,并不是立即激活等待的线程,它仅仅是解除等待线程的阻塞,以便这些线程能够在当前线程退出同步方法后,通过竞争实现对对象的访问。条件对象还有一个signal()方法,它则是随机解除某个等待的线程。如果解除等待的线程还是无法满足条件,则再次被阻塞。如果被阻塞的线程没有另一个线程再次调用条件对象的signalAll()或signal()方法解除阻塞,则会造成死锁。
同步方法
Lock和Condition接口为程序设计人员提供了高度的锁定控制,然后大多数情况下不需要这样的控制,并且可以使用一种嵌入到Java内部的机制。
从Java 1.0版开始,Java中为每一个对象都提供了一个内部锁,如果一个方法用synchronized关键字声明,那么对象的锁将会保护整个方法,也就是说,如果调用该方法,线程必须获得内部的对象锁。代码如下:
public synchronized void method() {
// 临界区 ......
}
等价于:
ReentrantLock mReentrantLock = new ReentrantLock();
public void method() {
// 得到锁
mReentrantLock.lock();
try {
// 临界区 ......
} finally {
// 释放锁
mReentrantLock.unlock();
}
}
针对之前银行转账的栗子,就可以不再使用显示锁了,而使用synchronized关键字声明转账方法,并且内部的对象锁只有一个条件对象。修改后的代码如下:
public class Bank {
private double[] mAccounts;
public DogBank(int count, double amount) {
mAccounts = new double[count];
for (int i = 0 ; i < mAccounts.length ; i ++) {
mAccounts[i] = amount;
}
}
public synchronized void transferAccounts(int from, int to, double amount) {
try {
while (mAccounts[from] < amount) {
// 调用wait()方法,阻塞当前线程并放弃锁
wait();
}
// 转账操作
mAccounts[from] = mAccounts[from] - amount;
mAccounts[to] = mAccounts[to] + amount;
// 调用notifyAll()方法,重新激活因为该条件而等待的所有线程
notifyAll();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
可以看到使用synchronized关键字来编写代码要简洁的多,我们要记住每一个对象都有一个内部锁,并且每一个内部锁都有一个内部条件。由该锁来管理那些试图进入被synchronized关键字声明的方法的线程,由该锁中的条件来管理那些调用wait()方法的线程。调用wait()方法使当前线程进入等待集并处于阻塞状态,调用notifyAll()会解除所有等待线程的阻塞,调用notify()会随机解除某个等待线程的阻塞,wait()方法等价于condition.await()方法,notifyAll()方法等价于condition.signalAll()方法,notify()等价于condition.signal()方法。
同步代码块
上面同步代码中说过,Java中的每一个对象都有一个内部锁,每一个内部锁都有一个内部条件,线程可以调用同步方法获得锁。还有一种可以获得锁的机制,就是使用同步代码块。代码如下:
Object lock = new Object();
public void method() {
// 使用lock这个对象的内部锁
synchronized (lock) {
// 临界区 ......
}
}
等价于:
public synchronized void method() {
// 临界区 ......
}
等价于:
ReentrantLock mReentrantLock = new ReentrantLock();
public void method() {
// 得到锁
mReentrantLock.lock();
try {
// 临界区 ......
} finally {
// 释放锁
mReentrantLock.unlock();
}
}
现在使用同步代码块的方式改写银行转账的例子。
public class Bank {
private Object lock = new Object();
private double[] mAccounts;
public LionBank(int count, double amount) {
mAccounts = new double[count];
for (int i = 0 ; i < mAccounts.length ; i ++) {
mAccounts[i] = amount;
}
}
public void transferAccounts(int from, int to, double amount) {
// 使用lock这个对象的内部锁
synchronized (lock) {
try {
while (mAccounts[from] < amount) {
// 调用lock对象的锁的条件的wait()方法,阻塞当前线程并放弃锁
wait();
}
// 转账操作
mAccounts[from] = mAccounts[from] - amount;
mAccounts[to] = mAccounts[to] + amount;
// 调用lock对象的锁的条件的notifyAll()方法,重新激活因为该条件而等待的所有线程
notifyAll();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
在这里创建了一个名为lock的Object类,为了使用Object类所持有的内部锁。同步代码块是非常脆弱的,通常不推荐使用。
总结
如果同步方法适合你的程序,那么尽量使用同步方法,这样可以减少编写代码的数量和出错的概率。如果特别需要使用Lock和Condition结构提供的独有特性时方可使用Lock/Condition。一般实现同步最好使用java.util.concurrent包下提供的类,比如阻塞队列。
网友评论