Java核心技术 卷1-总结-16
- 线程属性
- 线程优先级
- 守护线程
- 未捕获异常处理器
- 同步
- 竞争条件的一个例子
- 竞争条件详解
- 锁对象
线程属性
线程的各种属性包括:线程优先级、守护线程、线程组以及处理未捕获异常的处理器。
线程优先级
在Java程序设计语言中,每一个线程有一个优先级。默认情况下,一个线程继承它的父线程的优先级。每当线程调度器有机会选择新线程时,它首先选择具有较高优先级的线程。
守护线程
可以通过调用
t.setDaemon(true);
将线程转换为守护线程(daemon thread)。守护线程的唯一用途是为其他线程提供服务。 计时线程就是一个例子,它定时地发送"计时器嘀嗒"信号给其他线程或清空过时的高速缓存项的线程。当只剩下守护线程时,虚拟机就退出了,由于如果只剩下守护线程,就没必要继续运行程序了。守护线程应该永远不去访问固有资源,如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断。
未捕获异常处理器
**线程的run
方法不能抛出任何受查异常,但是,非受查异常会导致线程终止。在这种情况下,线程就死亡了。**但是,不需要任何catch子句来处理可以被传播的异常。相反,就在线程死亡之前,异常被传递到一个用于未捕获异常的处理器。
该处理器必须属于一个实现Thread.UncaughtExceptionHandler
接口的类。这个接口只有一个方法。
void uncaughtException(Thread t, Throwable e)
可以用setUncaughtExceptionHandler
方法为任何线程安装一个处理器。也可以用Thread 类的静态方法setDefaultUncaughtExceptionHandler
为所有线程安装一个默认的处理器。替换处理器可以使用日志API发送未捕获异常的报告到日志文件。
同步
在大多数实际的多线程应用中, 两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取相同的对象,并且每一个线程都调用了一个修改该对象状态的方法,这时根据各线程访问数据的次序,可能会产生讹误的对象。这样一个情况通常称为竞争条件(race condition)。
竞争条件的一个例子
为了避免多线程引起的对共享数据的讹误,必须学习如何同步存取。
在下面的测试程序中,模拟一个有若干账户的银行。随机地生成在这些账户之间转移钱款的交易。每一个账户有一个线程。每一笔交易中,会从线程所服务的账户中随机转移一定数目的钱款到另一个随机账户。
模拟代码具有transfer
方法的Bank
类。该方法从一个账户转移一定数目的钱款到另一个账户。如下是Bank类的transfer方法的代码。
public void transfer(int from, int to, double amount) {
// CAUTION: unsafe when called from multiple threads \
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());
}
这里是Runnable
类的代码。它的run
方法不断地从一个固定的银行账户取出钱款。在每一次迭代中,run
方法随机选择一个目标账户和一个随机账户,调用bank
对象的transfer
方法,然后睡眠。
Runnable r = () -> {
try {
while(true) {
int toAccount=(int)(bank.size() * Math.random();
double amount = MAX_AMOUNT * Math.random();
bank.transfer(fromAccount, toAccount, amount);
Thread.sleep((int)(DELAY * Math.random());
}
}
catch (InterruptedException e)
{
}
};
当这个模拟程序运行时,不清楚在某一时刻某一银行账户中有多少钱。但是,知道所有账户的总金额应该保持不变,因为所做的一切不过是从一个账户转移钱款到另一个账户。
在每一次交易的结尾,transfer
方法重新计算总值并打印出来。本程序永远不会结束。只能手动终止这个程序。下面是程序的输出:
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 Bal ance: 100000.00
Thread[Thread-13,5,main] 359.89 from 13 to 81 Total Bal ance: 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 Bal ance: 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
正如前面所示,出现了错误。在最初的交易中,银行的余额保持在$100000,这是正确的,因为共100个账户,每个账户$1000。但是,过一段时间,余额总量有轻微的变化。当运行这个程序的时候,会发现有时很快就出错了,有时很长的时间后余额发生混乱。这样的状态不会带来信任感。
竞争条件详解
上述的程序,其中有多个线程更新银行账户余额。一段时间之后,错误就会出现,总额要么增加,要么变少。当两个线程试图同时更新同一个账户的时候,这个问题就有可能出现。假定两个线程同时执行指令
accounts[to] += amount;
问题在于这不是原子操作。该指令可能被处理如下:
- 将
accounts[to]
加载到寄存器。 - 增加 amount。
- 将结果写回
accounts[to]
。
现在,假定第1个线程执行步骤1和2,然后,它被剥夺了运行权。假定第2个线程被唤醒并修改了accounts数组中的同一项。然后,第1个线程被唤醒并完成其第3步。这样,这一动作擦去了第二个线程所做的更新。于是,总金额不再正确。
transfer
方法的执行过程中可能会被中断。如果能够确保线程在失去控制之前方法运行完成, 那么银行账户对象的状态永远不会出现讹误。
锁对象
有两种机制防止代码块受并发访问的干扰。Java语言提供一个synchronized
关键字达到这一目的,并且Java SE 5.0引人了ReentrantLock
类。synchronized
关键字自动提供一个锁以及相关的"条件",对于大多数需要显式锁的情况,这是很便利的。 用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子句之内是至关重要的。如果在临界区的代码抛出异常,锁必须被释放。否则,其他线程将永远阻塞。
注意:如果使用锁,就不能使用带资源的try语句。
使用锁来保护Bank类的transfer方法。
public class Bank {
private Lock bankLock = new ReentrantLock();// ReentrantLock implements the Lock interface
public void transfer(int from, int to, int 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();
}
}
}
**假定一个线程调用transfer,在执行结束前被剥夺了运行权。假定第二个线程也调用transfer,由于第二个线程不能获得锁,将在调用lock方法时被阻塞。它必须等待第一个线程完成transfer方法的执行之后才能再度被激活。当第一个线程释放锁时,那么第二个线程才能开始运行。**如下图:
添加加锁代码到transfer方法并且再次运行程序。可以永远运行它,而银行的余额不会出现讹误。
注意每一个Bank对象有自己的ReentrantLock对象。如果两个线程试图访问同一个Bank 对象,那么锁以串行方式提供服务。但是,如果两个线程访问不同的 Bank 对象,每一个线程得到不同的锁对象,两个线程都不会发生阻塞。本该如此,因为线程在操纵不同的Bank实例的时候,线程之间不会相互影响。
锁是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个持有计数(hold count)来跟踪对lock
方法的嵌套调用。线程在每一次调用lock都要调用unlock来释放锁。 由于这一特性,被一个锁保护的代码可以调用另一个使用相同的锁的方法。
例如,transfer
方法调用getTotalBalance
方法,这也会封锁 bankLock
对象,此时 bankLock
对象的持有计数为2。当getTotalBalance
方法退出的时候,持有计数变回1。当transfe
r方法线程1退出的时候, 持有计数变为 0。线程释放锁。
通常, 可能想要保护需若干个操作来更新或检查共享对象的代码块。要确保这些操作完成后, 另一个线程才能使用相同对象。