当前位置:课程学习>>第十章 多线程>>文本学习>>知识点三
一、线程同步
1. 临界资源
在大多数多线程的应用程序中,两个或两个以上的线程需要共享相同的对象。在这种情况下,每个线程都可能调用改变共享对象的方法,例如对同一个数据区,有些线程对该数据区进行写,有些线程对该数据区进行读,由于对象被访问的次序是不可控制的,将会产生多种不可靠的结果。所以,对共享对象的执行就会互相破坏。在线程程序中,这个共享对象被称为临界资源,或临界区。
【例10.5】模拟一个包含100个账户的银行,随机地在这些账户间进行现金交易。测试程序共拥有100个线程,每个线程对应一个账户。每次交易将把随机数量的金额从一个账户转移到另一个账户。
//BankExample.java
public class BankExample {
public static final int NACCOUNTS = 100;
public static final int INITIAL_BALANCE = 10000;
public static void main(String[] args) {
Bank b = new Bank(NACCOUNTS, INITIAL_BALANCE);
for (int i = 0; i < NACCOUNTS; ++i) {
TransferThread t = new TransferThread(b, i, INITIAL_BALANCE);
t.setPriority(Thread.NORM_PRIORITY + i % 2);
t.start();
}
}
}
class Bank {
public static final int NTEST = 10000;
private int[] accounts;
private long ntransacts = 0;
public Bank(int n, int initialBalance) {
accounts = new int[n];
for (int i = 0; i < accounts.length; ++i)
accounts[i] = initialBalance;
ntransacts = 0;
}
public void transfer(int from, int to, int amount) {
if (accounts[from] < amount) return;
accounts[from] -= amount;
accounts[to] += amount;
ntransacts++;
if (ntransacts % NTEST == 0) test();
}
public void test() {
int sum = 0;
for (int i = 0; i < accounts.length; ++i)
sum += accounts[i];
System.out.println("Transactions: " + ntransacts + " Sum: " + sum);
}
public int size() {
return accounts.length;
}
}
class TransferThread extends Thread {
private Bank bank;
private int fromAccount;
private int maxAmount;
public TransferThread(Bank b, int from, int max) {
bank = b;
fromAccount = from;
maxAmount = max;
}
public void run() {
try {
while (!interrupted()) {
int toAccount = (int) (bank.size() * Math.random());
int amount = (int) (maxAmount * Math.random());
bank.transfer(fromAccount, toAccount, amount);
sleep(1);
}
} catch (InterruptedException e) {
}
}
}
在程序中,如果帐户中没有足够的现金,转账方法transfer将直接返回,不执行任何交易。根据计算过程,程序运行中所有帐户中的金额总数应该保持不变。但是在实际的执行过程中,金额总数逐渐发生变化。请看下图所示的过程:
图 10.2 帐户金额变化过程
根据上图显示的执行过程,账户1和2的金额相应减少,而账户3的金额仅增加了一部分。
经过一段时间后,程序运行结果如下:
Transactions: 10000 Sum: 1000000
......
Transactions: 570000 Sum: 1000000
Transactions: 580000 Sum: 1000000
Transactions: 590000 Sum: 998597
Transactions: 600000 Sum: 998597
Transactions: 610000 Sum: 998597
Transactions: 620000 Sum: 998597
Transactions: 630000 Sum: 998659
Transactions: 640000 Sum: 998659
......
在程序中,将不同的线程设置具有不同的优先级,用以测试线程在获得不同处理器执行的情况下的结果。
2. 对象锁机制
我们期待上节中每个线程的转帐过程都是封闭执行的,即确保一个线程在没完成之前不会丧失执行权,这样账户的金额就不会产生错误。
可以使用synchronize修饰符来保证方法在执行期间不会被中断。如:
public synchronized void transfer(int from, int to, int amount)
{
if (accounts[from]<amount) return;
accounts[from] -= amount;
accounts[to] += amount;
ntransacts++;
if (ntransacts % NTEST == 0) test();
}
用synchronized修饰符来修饰一个方法,它在执行期间不会被中断。此时,称该方法为被同步的方法。当某个线程调用被同步的方法来访问数据时,可以保证在被同步的方法完成前,其他线程不允许再调用该方法来访问数据。在这种情况下,线程被阻塞,等待前一个线程执行完方法后,才有可能被执行。如前面的transfer方法,由于多个线程同时访问数据accounts,所以把该方法定义为被同步的方法。
3. 等待和唤醒
在Java中,可以利用程序使线程从执行状态转换到阻塞状态,即调用wait方法。与wait方法对应的唤醒方法,即使用notify和notifyAll方法,这三个方法都是在多个线程同时访问对象资源的情况下使用的。以上三个方法的声明为:
public final void wait()
当前的线程等待,直到其他线程调用此对象的notify方法或notifyAll方法。
当前的线程必须拥有此对象监视器。该线程发布对此监视器的所有权并等待,直到其他线程通过调用notify方法,或notifyAll方法通知在此对象的监视器上等待的线程醒来。然后该线程将等到重新获得对监视器的所有权后才能继续执行。
public final void notify()
唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。
直到当前的线程放弃此对象上的锁定,才能继续执行被唤醒的线程。被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争。
public final void notifyAll()
唤醒在此对象监视器上等待的所有线程。
直到当前的线程放弃此对象上的锁定,才能继续执行被唤醒的线程。被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争。
三个方法只能在一个被同步的方法或块内被调用。
一个等待进入被同步方法的线程和一个调用wait方法的线程有着本质的区别。一旦某个线程调用了wait方法,它将进入一个等待队列,在它离开等待队列前,线程调度程序会忽略它,也就是说这个线程根本没有执行的机会。
从等待队列中删除一个线程,使其返回到执行状态,必须有另外的线程调用notify或notifyAll方法来通知这个线程。当一个线程调用wait方法后,它无法自动地离开阻塞状态,只能等待其他线程通知它。所以,其他线程定期地调用notify或notifyAll方法是至关重要的,如果没有线程使一个等待队列上的线程离开,它将永远无法得到执行权,容易导致死锁。实际上,调用notify方法并不安全,因为根本无法控制让哪一个线程离开等待队列,如果一个不合适的线程离开等待队列,它可能仍然得不到需要的资源而无法继续执行。建议使用notifyAll方法使等待队列上的所有线程都离开阻塞状态。通常是当某个对象以一种可能有利于等待队列上的线程的方式改变状态时,就调用notifyAll方法。
例如,在10.5.1节中的例10.5中,考察transfer方法中的代码。当from指定的银行账户要转账的金额不足时,就退出该方法,这不是一种好的解决方案。在下面对transfer方法进行改写。首先,当银行账户的金额不足时,调用wait方法,线程进入等待队列,直到其他线程向这个账户中存入足够的现金为止。在transfer方法中,转账成功后,应当调用notifyAll方法,使等待队列中的线程被唤醒以继续执行现金转账的过程。改写后的transfer方法如下:
public synchronized void transfer(int from, int to, int amount)
{
try
{
while (accounts[from]<amount)
wait();
accounts[from] -= amount;
accounts[to] += amount;
ntransacts++;
notifyAll();
if (ntransacts % NTEST == 0) test();
} catch(InterruptedException e) {}
}
线程的同步化机制是比较复杂的,一般来说,同步化机制可以总结为:
(1) 如果两个或两个以上的线程都要在方法中修改数据对象,那么把执行修改的方法定义为被同步的方法。如果对象更新影响到只读方法,那么只读方法也应当被定义为被同步的。
(2) 如果一个线程必须等待一个对象状态发生变化,它应当通过调用一个被同步的方法,并让这个方法调用wait方法来进入等待队列。wait方法通常放在while循环内。
(3) 每当一个方法改变某个对象的状态时,它应当调用notifyAll方法。这给等待队列中的线程提供机会来考察相应的对象状态是否发生改变。如果改变,线程将继续执行。
4. 死锁
若一个Java程序的所有线程都因为申请不到它们所需要的资源而全部进入阻塞状态时,该Java程序将被挂起,程序不能在继续前进,这种现象称为死锁。
如线程A已拥有资源R1,还需要资源R2,于是线程A申请资源R2。而资源R2已由线程B所拥有,于是线程A进入阻塞状态,被放入等待资源R2的队列中,但线程A进入阻塞状态时还拥有资源R1。同样,线程B已拥有资源R2,还需要资源R1,而R1已由线程A所拥有,于是线程B进入阻塞状态,被放入等待资源R1的队列中。于是,线程A和B都想得到对方的资源并且没有释放自己拥有的资源,结果导致程序进入死锁的状态。
如不配套使用wait/notify/notifyAll方法,就很容易导致死锁。Java语言中没有技术自动发现死锁,也没有技术避免死锁。需要由设计人员在设计多线程程序时谨慎注意。
5. 线程同步的实例:生产者和消费者
在本节中,利用线程间的同步机制来模拟生产和消费的过程。首先建立一个具有一定空间的共享区域,允许向其中写入和读取数据。然后将生产者和消费者分别设计在两个线程中。其中,生产者线程负责向共享区域中写入数据,而消费者线程负责从共享区域中读取数据,并将读出数据的空间清空。主程序中分别创建一个生产者线程和一个消费者线程,当生产者线程在执行过程中发现共享区域满的话,调用wait方法进入等待队列;类似地,当消费者线程在执行过程中发现共享区域为空时,同样调用wait方法进入等待队列。注意,在被同步的方法中使用notifyAll方法来唤醒线程。
//SyncExample.java
public class SyncExample {
public static void main(String[] args) {
ShareArea sa = new ShareArea();
Producer p = new Producer(sa);
Consumer c = new Consumer(sa);
p.start();
c.start();
}
}
class ShareArea {
private int num = 100;
private int[] data = new int[num];
private int pos = 0;
// 写入数据
public synchronized void send(int v) {
try {
while (pos >= num)
wait();
data[pos++] = v;
notifyAll();
} catch (InterruptedException e) {
}
}
// 读取数据
public synchronized int receive() {
int value = 0;
try {
while (pos <= 0)
wait();
value = data[--pos];
notifyAll();
} catch (InterruptedException e) {
}
return value;
}
}
class Producer extends Thread {
private ShareArea pro_sa;
public Producer(ShareArea sa) {
pro_sa = sa;
}
public void run() {
for (int i = 0; i < 100; i++) {
pro_sa.send(i);
System.out.println("producer send :" + i);
}
}
}
class Consumer extends Thread {
private ShareArea con_sa;
public Consumer(ShareArea sa) {
con_sa = sa;
}
public void run() {
for (int i = 0; i < 100; i++) {
int v = con_sa.receive();
System.out.println("consumer receive :" + v);
}
}
}
运行结果:
producer send :0
consumer receive :0
producer send :1
producer send :2
producer send :3
consumer receive :3
producer send :4
consumer receive :4
producer send :5
consumer receive :5
producer send :6
consumer receive :6
producer send :7
consumer receive :7
……
6. 线程组
对于一些复杂度较高的程序来说,往往需要许多活动线程的参与。Java用java.lang.ThreadGroup类实现了线程组的功能,为多个线程提供集合的形式,并对整个集合应用某些操作,例如可以同时终止线程组内的所有线程。
可以用构造方法ThreadGroup来构造一个线程组。
public ThreadGroup(String groupName);
其中,groupName指定了线程组的名称,因此必须是唯一的。
Thread的构造方法可以向线程组添加新的线程。
public Thread(ThreadGroup group, String threadName);
其中,group指定了线程所属的线程组,threadName指定了线程的名称。例如:
ThreadGroup g = new ThreadGroup(“thread group”);
Thread thread1 = new Thread(g, “thread1”);
Thread thread2 = new Thread(g, “thread2”);
在一个线程组中中断所有线程,可简单调用组对象的interrupt方法。此方法将对此线程组及其所有子组中的所有线程调用interrupt方法。
public final void interrupt();
例如:
g.interrupt();
可以调用activeCount方法获得某个指定线程组中的活动线程的估计数。结果并不能反映并发活动,并且可能受某些系统线程的存在状态的影响。
public int activeCount();