1.什么是线程
多线程在较低层次扩展了多任务的概念:一个程序同时执行多个任务。通常,每一个任务称为一个线程(thread),它是线程控制的简称。可以同时运行一个以上线程的程序称为多线程程序(multithreaded)。
那么,多进程与多线程有哪些区别呢?本质的区别在于每个进程拥有自己的一套变量,而线程则共享数据。共享变量使线程之间的通信比进程之间的通信更有效,更容易。此外,在有些操作系统中,与进程相比较,线程更“轻量级”,创建,撤销一个线程比启动新进程的开销要小得多。
这里从察看一个没有使用多线程的程序开始。用户很难让它执行多个任务。在对其进行剖析之后,将展示让这个程序运行几个彼此独立的多个线程是很容易的。这个程序采用不断地移动位置的方式实现球跳动的动画效果,如果发现球碰到墙壁,将进行重绘。
当点击Start按钮时,程序将从屏幕的左上角弹出一个球,这个球便开始弹跳。Start按钮的处理程序将调用addBall方法。这个方法循环运行1000次move。每调用一次move,球就会移动一点,当碰到墙壁时,球将调整方向,并重新绘制面板。
Ball ball=new Ball(); panel.add(ball); for(int i=1;i<=STEPS;i++){ ball.move(panel.getBounds()); panel.paint(panel.getGraphics()); Thread.sleep(DELAY); }
调用Thread.sleep不会创建一个新线程,sleep是Thread类的静态方法,用于暂停当前线程的活动。
sleep方法可以抛出一个InterruptedException异常。稍后将讨论这个异常以及对它的处理。现在,只是在发生异常时简单地终止弹跳。
如果运行这个程序,球会自如地来回弹跳,但是,这个程序完全控制了整个应用程序。如果你在球完成1000次弹跳之前已经感到厌倦了,并点击Close按钮会发现球仍然还在弹跳。在球自己结束弹跳之前无法与程序进行交互。
显然,这个程序的性能相当糟糕。人们肯定不愿意让程序用这种方式完成一个非常耗时的工作,毕竟,当通过网络连接数据时,阻塞其他任务是经常发生的,有时确实想要中断读取操作。下一节将介绍如何通过运行一个线程中的关键代码来保持用户对程序的控制权。
1)使用多线程给其他任务提供机会
可以将移动球的代码放置子一个独立的线程中,运行这段代码可以提供弹跳球的响应能力。实际上,可以发起多个球,每个球都在自己的线程中运行。另外,AWT的事件分派线程(event dispatch thread)将一直地并行运行,以处理用户界面的事件。由于每个线程都有机会得以运行,所有在球弹跳期间,当用户点击Close按钮时,事件调度线程将有机会关注这个事件,并处理“关闭”这一动作。
这里用球弹跳代码作为示例。通常,人们总会提防长时间的计算。这个计算很可能是某个大框架的一个组成部分,例如,GUI或web框架。无论何时框架调用自身的方法都会很快地返回一个异常。如果需要执行一个比较耗时的任务,应当并发地运行任务。
下面是在一个单独的线程中执行一个任务的简单过程:
a.将任务代码移到实现了Runnable接口的类的run方法中。这个接口非常简单,只有一个方法:
public interface Runnable{ void run(); }
由于Runnable是一个函数式接口,可以用lambda表达式建立一个实例:
Runnable r=()->{task code};
b.由Runnable创建一个Thread对象:
Thread t=new Thread(r);
c.启动线程:
t.start();
要想将弹跳球代码放在一个独立的线程中,只需要实现一个类BallRunnable,然后,将动画代码放在run方法中,如同下面这段代码:
Runnable r=()->{ try{ for(int i=1;i<=STEPS;i++){ ball.move(comp,getBounds()); comp.repaint(); Thread.sleep(DELAY); } } catch(InterruptedException e){ } }; Thread t=new Thread(r); t.start();
同样地,需要捕获sleep方法可能抛出的异常InterruptedException。在一般情况下,线程在中断时被终止。因此,当发生InterruptedException异常时,run方法将结束执行。无论何时点击Start按钮,球会移入一个新线程。
注:1.也可以通过构建一个Thread类的子类定义一个线程,如下所示:
class MyThread extends Thread{ public void run(){ task code } }
然后,构造一个子类的对象,并调用start方法。
不过,这种方法已不再推荐。应该将要并行运行的任务与运行机制解耦合。如果有很多任务,要为每个任务创建一个独立的线程所付出的代价太大了。可以使用线程池来解决这个问题。
2.不要调动Thread类或Runnable对象的run方法。直接调用run方法,只会执行同一个线程中的任务,而不会启动新线程。应该调用Thread.start方法,这个方法将创建一个执行run方法的新线程。
Thread API:
Thread(Runnable target)
构造一个新线程,用于调用给定目标的run()方法。
void start()
启动这个线程,将引发调用run()方法。这个方法将立即返回,并且新线程将并发运行。
void run()
调用关联Runnable的run方法。
Runnable API:
void run()
必须覆盖这个方法,并在这个方法中提供所要执行的任务指令。
2.中断线程
当线程的run方法执行方法体中最后一条语句后,并经由执行return语句返回时,或者出现了在方法中没有捕获的异常时,线程将终止。在Java的早期版本,还有一个stop方法,其他线程可以调用它终止线程。但是,这个方法现在已经被弃用了。
没有可以强制线程终止的方法。然而,interrupt方法可以用来请求终止线程。
当对一个线程调用interrupt方法时,线程的中断状态将被置位。这是每一个线程都具有的boolean标志。每个线程都应该不时地检查这个标志,以判断线程是否被中断。
要想弄清楚中断状态是否被置位,首先调用静态的Thread.currentThread方法获得当前线程,然后调用isInterrupted方法:
while(Thread.currentThread().isInterrrupted() && more work to do){ do more work }
但是,如果线程被阻塞,就无法检测中断状态,这是产生InterruptedException异常的地方。当在一个被阻塞的线程(调用sleep或wait)上调用interrupt方法时,阻塞调动将会被InterruptedException异常中断。(存在不能被中断的阻塞I/O调用,应该考虑选择可中断的调用。)
没有任何语言方面的需求要求一个被中断的线程应该终止。中断一个线程不过是引起它的注意。被中断的线程可以决定如何响应中断。某些线程是如此重要以至于应该处理完异常后,继续执行,而不理会中断。但是,更普遍的情况是,线程将简单地将中断作为一个终止的请求。这种线程的run方法具有如下形式:
Runnable r=()->{ try{ ... while(!Thread.currentThread().isInterrupted()&& more work to do){ do more work } } catch(InterruptedException){ //thread was interrupted during sleep or wait } finally{ cleanup,if required } //exiting the run method terminates the thread };
如果每次工作迭代之后都调用sleep方法(或者其他的可中断方法),isInterrupted检测既没有必要也没有用处。如果在中断状态被置位时调用sleep方法,它不会休眠。相反,它将清除这一状态(!)并抛出InterruptedException。因此,如果你的循环调用sleep,不会检测中断状态,相反,要如下所示捕获InterruptedException异常:
Runnable r=()->{ try{ ... while(more work to do){ do more work Thread.sleep(delay); } } catch(InterruptedException e){ //thread was interrupted during sleep } finally{ cleanup,if required } //exiting the run method terminates the thread };
注:有两个非常类似的方法,interrupted和isInterrupted。Interrupted方法是一个静态方法,它检测当前的线程是否被中断。而且,调用interrupted方法会清除该线程的中断状态。另一方面,isInterrupted方法是一个实例方法,可用来检测是否有线程被中断。调用这个方法不会改变中断状态。
在很多发布的代码中会发现InterruptedException异常被抑制在很低的层次上,像这样:
void mySubTack(){ ... try{ sleep(delay);} catch(InterruptedException e){ }//Don't ignore! ... }
不要这样做!如果不认为在catch子句中做这一处理有什么好处的话,仍然有两种合理的选择:
1.在catch子句中调用Thread.currentThread().interrupt()来设置中断状态。于是,调用者可以对其进行检测。
void mySubTack(){ ... try{sleep(delay);} catch(InterruptedException e){ Thread.currentThread().interrupt();} ... }
2.或者,更好的选择是,用throws InterruptedException标记你的方法,不采用try语句块捕获异常。于是,调用者(或者,最终的run方法)可以捕获这一异常。
void mySubTack() throws InterruptedException{ ... sleep(delay); ... }
Thread API:
void interrupt()
向线程发送中断请求。线程的中断状态将被设置为true。如果目前该线程被一个sleep调用阻塞,那么,InterruptedException异常被抛出。
static boolean interrupted()
测试当前线程(即正在执行这一命令的线程)是否被中断。注意,这是一个静态方法。这一调动会产生副作用---它将当前线程的中断状态重置为false。
boolean isInterrupted()
测试线程是否被终止。不像静态的中断方法,这一调用不改变线程的中断状态。
static Thread currentThread()
返回代表当前执行线程的Thread对象。
3.线程状态
线程可以有如下6种状态:
a.New(新创建)
b.Runnable(可运行)
c.Blocked(被阻塞)
d.Waiting(等待)
e.Timed waiting(计时等待)
f.Terminated(被终止)
要确定一个线程的当前状态,可调用getState方法。
1)新创建线程
当用new操作符创建一个新线程时,如new Thread(r),该线程还没有开始运行。这意味着它的状态是new。当一个线程处于新创建状态时,程序还没有开始运行线程中的代码。在线程运行之前还有一些基础工作要做。
2)可运行线程
一旦调用start方法,线程处于runnable状态。一个可运行的线程可能正在运行可能没在运行,这取决于操作系统给线程提供的运行的时间。(Java的规范说明没有将它作为一个单独状态。一个正在运行中的线程仍然处于可运行状态。)
一旦一个线程开始运行,它不必始终保持运行。事实上,运行中的线程被中断,目的让其他线程获得运行机会。
线程调度的细节依赖于操作系统提供的服务。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完,操作系统剥夺该线程运行权,并给另一个线程运行机会。当选择下一个线程时,操作系统考虑线程的优先级。
现在所有的桌面以及服务器操作系统都使用抢占式调度。但是,像手机这样的小型设备可能使用协作式调度。在这样的设备中,一个线程只有在调用yield方法,或者被阻塞或等待时,线程才失去控制权。
在具有多个处理器的机器上,每一个处理器运行一个线程,可以有多个线程并行运行。当然,如果线程的数目多于处理器数目,调度器依然采用时间片机制。
3)被阻塞线程和等待线程
当线程处于被阻塞或等待状态时,它暂时不活动。它不运行任何代码且消耗最少的资源。直至线程调度器重新激活它。细节取决于它是怎样达到非活动状态的。
a.当一个线程试图获取一个内部的对象锁(而不是java.util.concurrent库中的锁),而该锁被其他线程持有,则该线程进入阻塞状态。当所有其他线程释放该锁,并且线程调度器允许本线程持有它的时候,该线程将变成非阻塞状态。
b.当线程等待另一个线程通知调度器的一个条件时,它自己进入等待状态。在调用Object.wait方法或Thread.join方法,或者是等待java.util.concurrent库中的Lock或Condition时,就会出现这种情况。实际上,被阻塞状态与等待状态是有很大不同的。
c.有几个方法有一个超时参数。调用它们导致线程进入计时等待(time waiting)状态。这一状态一直保持到超时期满或者接收到适当的通知。带有超时参数的方法有Thread.sleep和Object.wait,Thread.join,Lock.tryLock以及Condition.await的计时版。
下图展示了线程可以具有的状态以及从一个状态到另一个状态可能的转换。当一个线程被阻塞或等待时(或终止时),另一个线程被调度为运行状态。当一个线程被重新激活(例如,因为超时期满或成功获得了一个锁),调度器检查它是否具有比当前运行线程更高的优先级。如果是这样,调度器从当前运行线程中挑选一个,剥夺其运行权,选择一个新的线程运行。
4)被终止的线程
线程因如下两个原因之一而被终止:
a.因为run方法正常退出而自然死亡。
b.因为一个没有捕获的异常终止了run方法而意外死亡。
特别是,可以调用线程的stop方法杀死一个线程。该方法抛出ThreadDeath错误对象,由此杀死线程。但是,stop方法已过时,不要在自己的代码中调用这个方法。
Thread API:
void join()
等待终止指定的线程
void join(long millis)
等待指定的线程死亡或者经过指定的毫秒数
Thread.State getState()
得到这一线程的状态;NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING或TERMINATED之一。
4.线程属性
下面将讨论线程的各种属性,其中包括:线程优先级,守护线程,线程组以及处理未捕获异常的处理器。
1)线程优先级
在Java中,每一个线程有一个优先级。默认情况下,一个线程继承它的父线程的优先级。可以用setPriority方法提高或降低任何一个线程的优先级。可以将优先级设置为在MIN_PRIORITY(在Thread类中定义为1)与MAX_PRIORITY(定义为10)之间的任何值。NORM_PRIORITY被定义为5。
每当线程调度器有机会选择新线程时,它首先选择具有较高优先级的线程。但是,线程优先级是高度依赖于系统的。当虚拟机依赖于宿主机平台的线程实现机制时,Java线程的优先级被映射到宿主机平台的优先级上,优先级个数也许更多,也许更少。
例如,Windows有7个优先级别。一些Java优先级将映射到相同的操作系统优先级。在Oracle为Linux提供的Java虚拟机中,线程的优先级被忽略---所有线程具有相同的优先级。
初级程序员常常过度使用线程优先级。为优先级而烦恼是事出有因的。不要将程序构建为功能的正确性依赖于优先级。
注:如果确实要使用优先级,应该避免初学者常犯的一个错误。如果有几个高优先级的线程没有进入非活动状态,低优先级的线程可能永远也不能执行。每当调度器决定运行一个新线程时,首先会在具有高优先级的线程中进行选择,尽管这样会使低优先级的线程完全饿死。
static void yield()
导致当前执行线程处于让步状态。如果有其他的可运行线程具有至少与此线程同样高的优先级,那么这些线程接下来会被调度。注意,这是一个静态方法。
2)守护线程
可以通过调用
t.setDaemon(true);
将线程转换为守护线程(daemon thread)。这样一个线程没有设么神奇。守护线程的唯一用途是为其他线程提供服务。计时线程就是一个例子,它定时地发送“计时器滴答”信号给其他线程或清空过时的高速缓存项的线程。当只剩下守护线程时,虚拟机就退出了,由于如果只剩下守护线程,就没必要继续运行程序了。
守护线程有时会被初学者错误的使用,它们不打算考虑关机(shutdown)动作。但是,这是很危险的。守护线程应该永远不会访问固有资源,如文件,数据库,因为它会在任何时候甚至在一个操作的中间发生中断。
void setDaemon(boolean isDaemon)
标识该线程为守护线程或用户线程。这一方法必须在线程启动之前调用。
3)未捕获异常处理器
线程的run方法不能抛出任何受查异常,但是,非受查异常会导致线程终止。在这种情况下,线程就死亡了。
但是,不需要任何catch子句来处理可以被传播的异常。相反,就在线程死亡之前,异常被传递到一个用于未捕获异常的处理器。
该处理器必须属于一个实现Thread.UncaughtExceptionHandler接口的类。这个接口只有一个方法。
void uncaughtException(Thread t,Throwable e)
可以用setUncaughtExceptionHandler方法为任何线程安装一个处理器。也可以用Thread类的静态方法setDefaultUncaughtExceptionHandler为所有线程安装一个默认的处理器。替换处理器可以使用日志API发送未捕获异常的报告到日志文件。
如果不安装默认的处理器,默认的处理器为空。但是,如果不为独立的线程安装处理器,此时的处理器就是该线程的ThreadGroup对象。
注:线程组是一个可以统一管理的线程集合。默认情况下,创建的所有线程属于相同的线程组,但是,也可能会建立其他的组。现在引入了更好的特性用于线程集合的操作,所以建议不要在自己的程序中使用线程组。
ThreadGroup类实现Thread.UncaughtExceptionHandler接口。它的uncaughtException方法左如下操作:
1)如果该线程组有父线程组,那么父线程组的uncaughtException方法被调用。
2)否则,如果Thread.getDefaultException方法返回一个非空的处理器,则调用该处理器。
3)否则,如果Throwable是ThreadDeath的一个实例,什么都不做。
4)否则,线程的名字以及Throwable的栈轨迹输出到System.err上。
这是你在程序中肯定看到过许多次的栈轨迹。
Thread API:
static void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler handler)
static Thread.UncaughtExceptionHandler getDefaultUncaughtExceptionHandler()
设置或获取未捕获异常的默认处理器
void setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler handler)
Thread.UncaughtExceptionHandler getUncaughtExceptionHandler()
设置或获取未捕获异常的处理器。如果没有安装处理器,则将线程组对象作为处理器。
Thread.UncaughtExceptionHandler接口的方法:
void uncaughtException(Thread t,Throwable e)
当一个线程因未捕获异常而终止,按规定要将客户报告记录到日志中。
参数:t 由于未捕获异常而终止的线程
e 未捕获的异常对象
ThreadGroup:
void uncaughtException(Thread t,Throwable e)
如果有父线程组,调用父线程组的这一方法;或者,如果Thread类有默认处理器,调用该处理器,否则,输出栈轨迹到标准错误流上(但是,如果e是一个ThreadDeath对象,栈轨迹是被禁用的。ThreadDeath对象由stop方法产生,而该方法已经过时)。
5.同步
在大多数实际的线程应用中,两个或两个以上的线程需要共享对统一数据的读取。如果两个线程存取相同的对象,并且每一个线程都调用了一个修改该对象状态的方法,将会发生什么呢?可以想象,线程彼此踩了对方的脚。根据各线程访问数据的次序,可能会产生讹误的对象。这样一个情况通常称为竞争条件(race condition)。
1)竞争条件的一个例子
为了避免多线程引起的对共享数据的讹误,必须学习如何同步存取。
在下面的测试程序中,模拟一个有若干账户的银行。随机地生成在这些账户之间转移钱的交易。每一个账户有一个交易中,会从线程所服务的账户中随机转移一定数目的钱款到另一个随机账户。
我们有具有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(InterruputedException e){ } };
当这个模拟程序运行时,不清楚在某一时刻某个银行账户中有多少钱。但是,知道所有账户的总金额应该保持不变,
在最初的交易中,银行的余额保持不变。但是过了一段时间,余额的总量有轻微的变化,发生了混乱。
2)竞争条件详解
假定两个线程同时执行指令
account[to] +=amount;
问题在于这不是原子操作。该指令可能被处理如下:
a.将accounts[to]加载到寄存器
b.增加amount
c.将结果写回accounts[to]。
现在,假定第1个线程执行步骤1和2,然后,它被剥夺了运行权。假定第2个线程被唤醒并修改了accounts数组中的一项。然后,第1个线程被唤醒并完成其第3步。
这样,这一动作擦去了第二个线程所做的更新。于是,总金额不再正确。
这里通过将打印语句和更新余额的语句交织在一起执行,增加了发生这种情况的机会。
如果删除打印语句,讹误的风险会降低一点,因为每个线程在再次睡眠之前所做的工作很少,调度器在计算过程中剥夺线程的运行权可能性很小。但是,讹误的风险并没有完全消失。如果在负载很重的机器上运行多线程,那么,即使删除了打印语句,程序依然会出错。
真正的问题是transfer方法的执行过程中可能会被中断。如果能够确保线程在失去控制之前方法运行完成,那么银行账户对象永远不会出现讹误。
3)锁对象
有两种机制防止代码块受并发访问的干扰。Java语言提供一个synchronized关键字达到这一目的,并且Java SE 5.0引入了ReentrantLock类。synchronized关键字自动提供一个锁以及相关的“条件”,对于大多数需要显式锁的情况,这是很便利的。分别阅读了锁和条件的内容之后,理解synchronized关键字是很轻松的事情。
java.util.concurrent框架为这些基础机制提供独立的类。
用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时,它们被阻塞,直到第一个线程释放锁对象。
注:1.把解锁操作括在finally子句之内是至关重要的。如果在临界区的代码抛出异常,锁必须被释放。否则,其他线程将永远堵塞。
2.如果使用锁,就不能使用带资源的try语句。首先,解锁方法名不是close。不过,即使将它重命名,带资源的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方法的执行之后才能再度被激活。当第一个线程释放锁时,那么第二个线程才能开始运行。
注意每一个Bank对象有自己的ReentrantLock对象。如果两个线程试图访问同一个Bank对象,那么锁以串行方式提供服务。但是,如果两个线程访问不同的Bank对象,每一个线程得到不同的锁对象,两个线程都不会发生堵塞。本该如此,因为线程在操纵不同的Bank实例的时候,线程之间不会相互影响。
锁是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个持有计数(hold count)来跟踪对lock方法的嵌套调用。线程在每一次调用lock都要调用unlock来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用相同的锁的方法。
例如,tranfer方法调用getTotalBalance方法,这也会封锁bankLock对象,此时bankLock对象的持有计数为2.当getTotalBalance方法退出的时候,持有计数变回1。当transfer方法退出的时候,持有计数变为0。线程释放锁。
通常,可能想要保护需若干个操作来更新或检查共享对象的代码块。要确保这些操作完成后,另一个线程才能使用相同对象。
注:要留心临界区中的代码,不要因为异常的抛出而跳出临界区。如果在临界区代码结束之前抛出了异常,finally子句将释放锁,但会使对象可能处于一个受损的状态。
java.util.concurrent.locks.Lock:
void lock()
获取这个锁;如果锁同时被另一个线程拥有则发生堵塞。
void unlock()
释放这个锁
java.util.concurrent.locks.ReentrantLock
ReentrantLock()
构建一个可以被用来保护临界区的可重入锁。
ReentrantLock(boolean fair)
构建一个带有公平策略的锁。一个公平策略锁偏爱等待时间最长的线程。但是,这一公平的保证将大大降低性能。所以,默认情况下,锁没有被强制为公平的。
注:听起来公平锁更合理一些,但是使用公平锁比使用常规锁要慢很多。只有当你确实了解自己要做什么并且对于你要了解的问题有一个特定的理由必须使用公平锁的时候,才可以使用公平锁。即使使用公平锁,也无法确保线程调度器是公平的。如果线程调度器选择忽略一个线程,而该线程为了这个锁已经等待了很长时间,那么就没有机会公平地处理这个锁了。
4)条件对象
通常,线程进入临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。
我们介绍Java库中条件对象的实现(由于历史原因,条件对象经常被称为条件变量(conditional variable)。)
现在来细化银行的模拟程序。我们避免选择没有足够资金的账户作为转出账户。注意不能使用下面这样的代码:
if(bank.getBalance(from) >=amount) bank.transfer(from,to,amount);
当前线程完全有可能在成功地完成测试,且在调用transfer方法之前被中断。
在线程再次运行前,账户余额可能已经低于的提款金额。必须确保没有其他线程在本检查余额与转账活动之间修改余额。通过使用锁来保护检查与转账动作来做到这一点。
public void transfer(int from,int to,int amount){ bankLock.lock(); try{ while(account[from] <amount){ //wait ... } //transfer funds ... } finally{ bankLock.unlock(); } }
现在,当账户中没有足够的余额时,应该做什么?等待直到另一个线程向账户注入了资金。但是,这一线程刚刚获得了对bankLock的排它性访问,因此别的线程没有进行存款操作的机会。这就是为什么我们需要条件对象的原因。
一个锁对象可以有一个或多个相关的条件对象。你可以用newCondition方法获得一个条件对象。习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。例如,在此设置一个条件对象来表达“余额充足”条件。
class Bank{ private Condition sufficientFunds; ... public Bank(){ ... sufficientFunds=bankLock.newCondition(); } }
如果transfer方法发现余额不足,它调用
sufficientFunds.await();
当前线程现在被阻塞了,并放弃了锁。我们希望可以使得另一个线程可以进行增加账户余额的操作。
等待获得锁的线程和调用await方法的线程存在本质上的不同。一旦一个线程调用await方法,它进入该条件的等待集。当锁可用时,该线程不能马上解除阻塞。相反,它处于阻塞状态时,直到另一个线程调用同一条件上的signalAll方法为止。
当另一个线程转账时,它应该调用
sufficientFunds.signalAll();
这一调用重新激活因为这一条件而等待的所有线程。当这些线程从等待集当中移出时,它们再次成为可运行的,调度器将再次激活它们。同时,它们将试图重新进入该对象。一旦锁成为可用的,它们中的某个将从await调用返回,获得该锁并从被阻塞的地方继续执行。
此时,线程应该再次测试该条件。由于无法确保该条件被满足---signalAll方法仅仅是通知正在等待的线程:此时有可能已经满足条件,值得再次去检测该条件。
注:通常,对await的调用应该在如下形式的循环体中
while(!(ok to proceed)) condition.await();
至关重要的是最终需要某个其他线程调用signalAll方法。当一个线程调用await时,它没有办法重新激活自身。它寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁(deadlock)现象。如果所有其他线程被阻塞,最后一个活动线程在解除其他线程的阻塞状态之前就调用await方法,那么它也被阻塞。没有任何线程可以解除其他线程的阻塞,那么该程序就挂起了。
应该何时调用signalAll呢?经验上讲,在对象的状态有利于等待线程的方向改变时调动signalAll。例如,当一个账户余额发生改变时,等待的线程会应该有机会检查余额。在例子中,当完成了转账,调用signalAll方法。
public void transfer(int from,int to ,int amount){ bankLock.lock(); try{ while(accounts[from] < amount){ sufficientFunds.await(); } //transfer funds ... sufficientFunds.signalAll(); } finally{ bankLock.unlock(); } }
注意调用signalAll不会立即激活一个等待线程。它仅仅解除等待线程的阻塞,以便这些线程可以在当前线程退出同步方法之后,通过竞争实现对对象的访问。
另一个方法signal,则是随机解除等待集中某个线程的阻塞状态。这比解除所有线程的阻塞更加有效,但也存在危险。如果随机选择的线程发现自己仍然不能运行,那么它再次被阻塞。如果没有其他线程再次调用signal,那么系统就死锁了。
注:当一个线程拥有某个条件的锁时,它仅仅可以在该条件上调用await,signalAll或signal方法。
java.util.concurrent.locks.Lock
Condition newCondition()
返回一个与该锁相关的条件对象
java.util.concurrent.locks.Condition
void await()
将该线程放到条件的等待集中
void signalAll()
解除该条件的等待集中的所有线程的阻塞状态
void signal()
从该条件的等待集中随机地选择一个线程,解除其阻塞状态。
5)synchronized关键字
在进一步深入之前,总结一下有关锁和条件的关键之处:
a.锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。
b.锁可以管理试图进入被保护代码段的线程。
c.锁可以拥有一个或多个相关的条件对象。
d.每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。
Lock和Condition接口为程序设计人员提供了高度的锁定控制。然而,大多数情况下,并不需要那样的控制,并且可以使用一种嵌入到Java语言内部的机制。从1.0版本开始,Java中的每一个对象都有一个内部锁。如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。
换句话说,
public synchronized void method(){ method body }
等价于
public void method(){ this.intrinsicLock.lock(); try{ method body } finally{ this.intrinsicLock.unlock(); } }
例如,可以简单地声明Bank类的transfer方法为synchronized,而不是使用一个显式的锁。
内部对象锁只有一个相关条件。wait方法添加一个线程到等待集中,notifyAll/notify方法解除等待线程的阻塞状态。换句话说,调用wait或notifyAll等价于
intrinsicCondition.await();
intrinsicCondition.signalAll();
注:wait,notifyAll以及notify方法是Object类的final方法。Condition方法必须被命名为await,signalAll和signal以便它们不会与那些方法发生冲突。
例如,可以用Java实现Bank类如下:
class Bank{ private double[] accounts; public synchronized void transfer(int from, int to ,in amount) throws InterruptedException{ while(accounts[from] <amount) wait();//wait on intrinsic object lock's single condition accounts[from] -=amount; accounts[to] +=amount; notifyAll();//notify all threads waiting on the condition } public synchronized double getTotalBalance(){...} }
可以看到,使用synchronized关键字来编写代码要简洁得多。当然,要理解这一代码,你必须了解每一个对象有一个内部锁,并且该锁有一个内部条件。由锁来管理那些试图进入synchronized方法的线程,由条件来管理那些调用wait的线程。
注:Synchronized方法是相对简单的。但是,初学者常常对条件感到困惑。在使用wait/notifyAll之前,应该考虑之前描述的结构之一。
将静态方法声明为synchronized也是合法的。如果调用这种方法,该方法获得相关的类对象的内部锁。例如,如果Bank类有一个静态同步的方法,那么当该方法被调用时,Bank.class对象的锁被锁住。因此,没有其他线程可以调用同一个类的这个或任何其他的同步静态方法。
内部锁和条件存在一些局限。包括:
a.不能中断一个正在试图获得锁的线程。
b.试图获得锁时不能设定超时。
c.每个锁仅有单一的条件,可能是不够的。
在代码中应该使用哪一种?Lock和Condition对象还是同步方法?下面是一些建议:
a.最好既不使用Lock/Condition也不使用synchronized关键字。在许多情况下你可以使用java.util.concurrent包中的一种机制,它会为你处理所有的加锁。
b.如果synchronized关键字适合你的程序,那么请尽量使用它,这样可以减少编写的代码数量,减少出错的几率。
c.如果特别需要Lock/Condition结构提供的独有特性,才使用Lock/Condition。
Object API:
void notifyAll()
解除那些在该对象上调用wait方法的线程的阻塞状态。该方法只能在同步方法或同步块内部调用。如果当前线程不是对象锁的持有者,该方法抛出一个IllegalMonitorStateException异常。
void notify()
随机选择一个在该对象上调用wait方法的线程,解除其阻塞状态。该方法只能在一个同步方法或同步块中调用。如果当线程不是对象锁的持有者,该方法抛出一个IllegalMonitorStateException异常。
void wait()
导致线程进入等待状态直到它被通知。该方法只能在一个同步方法中调用。如果当前线程不是对象锁的持有者,该方法抛出一个IllegalMonitorStateException异常。
void wait(long millis)
void wait(long millis,int nanos)
导致线程进入等待状态直到它被通知。该方法只能在一个同步方法中调用。如果当前线程不是对象锁的持有者,该方法抛出一个IllegalMonitorStateException异常。
参数:millis 毫秒数
nanos 纳秒数,<1000000。
6)同步阻塞
正如刚刚讨论的,每一个Java对象有一个锁。线程可以通过调用同步方法获得锁。还有另一种机制可以获得锁,通过进入一个同步阻塞。当线程进入如下形式的阻塞:
synchronized (obj) //this is the syntax for a synchronized block
{ critical section }
于是它获得obj的锁
有时会发现“特殊的”锁,例如:
public class Bank{ private double[] accounts; private Object lock=new Object(); ... public void transfer(int from,int to ,int amount){ synchronized (lock) // an ad-hoc lock { accounts[from] -=amount; accounts[to] +=amount; } System.out.println(...); } }
在此,lock对象被创建仅仅是用来使用每个Java对象持有的锁。
有时程序员使用一个对象的锁来实现额外的原子操作,实际上称为客户端锁定(client-side locking)。例如,考虑Vector类,一个列表,它的方法是同步的。现在,假定在Vector<Double>中存储银行余额。这里一个transfer方法的原始实现:
public void transfer(Vector<Double> accounts,int from,int to,int amount) //Error { accounts.set(from,accounts.get(from) -amount); accounts.set(to,accounts.get(to)+amount); System.out.println(.."; }
Vecotor类的get和set方法是同步的,但是,这对于我们并没有什么帮助。在第一次对get的调用已经完成之后,一个线程完全可能在transfer方法中被剥夺运行权。于是,另一个线程可能在相同的存储位置存入不同的值。但是,我们可以截获这个锁:
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类的文档没有给出这样的承诺。不得不仔细研究源代码并希望将来的版本能介绍非同步的可修改方法。如你所见,客户端锁定是非常脆弱的,通常不推荐使用。