并发
基本的线程机制
并发编程使我们可以将程序划分为多个分离的、独立运行的任务。通过使用多线程机制,这些独立任务(也被称为子任务)中的每一个都将由执行线程来驱动。一个线程就是在进程中的一个单一的顺序控制流,因此单个进程可以拥有多个并发执行的任务,但是你的程序使得每个任务都好像有其自己的CPU一样。其底层机制是切分CPU时间,但通常你不需要考虑它。
线程模型为编程带来了便利,它简化了在单一程序中同时交织在一起的多个操作的处理。 在使用线程时,CPU将轮流给每个任务分配其占用时间。每个任务都觉得自己在一直占用CPU,但事实上CPU时间是划分成片段分配给了所有的任务(例外情况是程序确实运行在多个CPU之上)。线程的一大好处是可以使你从这个层次抽身出来,即代码不必知道它是运行在具有一个还是多个CPU的机器上。所以,使用线程机制是一种建立透明的、可扩展的程序的方法,如果程序运行得太慢,为机器增添一个CPU就能很容易地加快程序的运行速度。多任务和多线程往往是使用多处理器系统的最合理方式。
定义任务
线程可以驱动任务,因此你需要一种描述任务的方式,这可以由Runnable接口来提供。要想定义任务,只需实现Runnable接口并编写run()方法,使得该任务可以执行你的命令。例:
public class Test{
public static void main(String[] args) {
Task t = new Task();
t.run();
}
//输出:
//#0(0) #0(1) #0(2) #0(3) #0(4) #0(5) #0(6) #0(7) #0(8) #0(9)
}
class Task implements Runnable{
private static int taskCount = 0;
private final int id = taskCount++;
@Override
public void run() {
for(int i = 0 ; i < 10 ;i++){
System.out.print("#"+id+"("+i+") ");
if(i == 8){
Thread.yield();
}
}
}
}
在上面的实例中,这个任务的run()不是由单独的线程驱动的,它是在main()中直接调用的(实际上,这里仍旧使用了线程,即总是分配给main()的那个线程)。
当从Runnable导出一个类时,它必须具有run()方法,但是这个方法并无特殊之处——它不会产生任何内在的线程能力。要实现线程行为,你必须显式地将一个任务附着到线程上。
Thread类
将Runnable对象转变为工作任务的传统方式是把它提交给一个Thread构造器,下面的示例展示了如何使用Thread来驱动Task对象:
public class Test{
public static void main(String[] args) {
for(int i = 0 ; i < 5 ;i++){
Thread t = new Thread(new Task());
t.start();
}
}
//输出:
//#1(0) #4(0) #1(1) #3(0) #0(0) #2(0) #0(1) #3(1) #1(2) #1(3)
//#1(4) #1(5) #1(6) #1(7) #1(8) #4(1) #4(2) #4(3) #4(4) #4(5)
//#4(6) #4(7) #4(8) #3(2) #3(3) #3(4) #3(5) #3(6) #3(7) #3(8)
//#0(2) #0(3) #0(4) #0(5) #0(6) #0(7) #0(8) #2(1) #2(2) #2(3)
//#2(4) #2(5) #2(6) #2(7) #2(8) #1(9) #4(9) #3(9) #0(9) #2(9)
}
我们这里用循环启动了五个任务。并且输出说明了不同任务的执行在线程被换进换出时混在了一起。这种交换是由线程调度器自动控制的。
这个程序一次运行的结果可能与另一次运行的结果不同,因为线程调度机制是非确定性的。事实上,你可以看到在某个版本的JDK与下个版本之间这个简单程序的输出会产生巨大的差异。例如,较早的JDK不会频繁对时间切片,因此线程1可能会首先循坏到尽头,然后线程2会经历其所有循坏,等等。这实际上与调用一个进程去同时执行所有的循环一样,只是启动所有线程的代价要更加高昂。较晩的JDK看起来会产生更好的时间切片行为,因此每个线程看起来都会获得更加正规的服务。通常Sun并未提及这些种类的JDK的行为变化,因此你不能依赖于任何线程行为的一致性。最好的方式是在编写使用线程的代码时,尽可能地保守。
当main()创建Thread对象时,它并没有捕获任何对这些对象的引用。在使用普通对象时,这对于垃圾回收来说是一场公平的游戏,但是在使用Thread时,情况就不同了。每个Thread都“注册”了它自己,因此确实有一个对它的引用,而且在它的任务退出其一run()并死亡之前,垃圾回收器无法清除它。你可以从输出中看到,这些任务确实运行到了结束,因此一个线程会创建一个单独的执行线程,在对start()的调用完成之后,它仍旧会继续存在。
使用Executor
Java SE5的java.util.concurrent包中的执行器(Executor)将为你管理Thread对象,从而简化了并发编程。Executor在客户端和任务执行之间提供了一个间接层;与客户端直接执行任务不同,这个中介对象将执行任务。Executor允许你管理异步任务的执行,而无须显式地管理线程的生命周期。Executor在Java SE5/6中是启动任务的优选方法。
我们可以使用Executor来代替在上例中显示地创建Thread对象。Task对象知道如何运行具体的任务,与命令设计模式一样,它暴露了要执行的单一方法。ExeutorService(具有服务生命周期的Executor,例如关闭)知道如何构建恰当的上下文来执行Runnable对象。在下面的示例中将为每个任务都创建一个线程。注意,ExecutorService对象是使用静态的Executor方法构建的,这个方法可以确定其Executor类型:
public class Test{
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
for(int i = 0 ; i < 5 ;i++){
exec.execute(new Task());
}
exec.shutdown();
}
//输出:
//#0(0) #0(1) #0(2) #0(3) #0(4) #0(5) #0(6) #0(7) #0(8) #3(0)
//#3(1) #3(2) #3(3) #3(4) #3(5) #3(6) #4(0) #4(1) #4(2) #4(3)
//#4(4) #4(5) #4(6) #4(7) #4(8) #2(0) #2(1) #2(2) #2(3) #2(4)
//#2(5) #2(6) #2(7) #2(8) #3(7) #3(8) #0(9) #1(0) #1(1) #1(2)
//#1(3) #1(4) #1(5) #1(6) #1(7) #1(8) #4(9) #2(9) #3(9) #1(9)
}
非常常见的情况是,单个的Executor被用来创建和管理系统中所有的任务。
对shutdown()方法的调用可以防止新任务被提交给这个Executor,当前线程(在本例中,即驱动main()的线程)将继续运行在shutdown()被调用之前提交的所有任务。这个程序将在Executor中的所有任务完成之后尽快退出。
你可以很容易地将前面示例中的CachedThreadPool替换为不同类型的Executor。
FixedThreadPool使用了有限的线程集来执行所提交的任务:
public class Test{
public static void main(String[] args) {
ExecutorService exec = Executors.newFixedThreadPool(5);
for(int i = 0 ; i < 5 ;i++){
exec.execute(new Task());
}
exec.shutdown();
}
//输出:
//#1(0) #0(0) #2(0) #0(1) #1(1) #0(2) #2(1) #0(3) #1(2) #0(4)
//#2(2) #0(5) #1(3) #0(6) #2(3) #0(7) #1(4) #0(8) #1(5) #1(6)
//#1(7) #1(8) #2(4) #2(5) #2(6) #2(7) #2(8) #3(0) #3(1) #3(2)
//#3(3) #3(4) #3(5) #3(6) #3(7) #3(8) #0(9) #4(0) #4(1) #4(2)
//#4(3) #4(4) #4(5) #4(6) #4(7) #4(8) #1(9) #2(9) #4(9) #3(9)
}
有了FixedThreadPool,你就可以一次性预先执行代价高昂的线程分配,因而也就可以限制线程的数量了。这可以节省时间,因为你不用为每个任务都固定地付出创建线程的开销。在事件驱动的系统中,需要线程的事件处理器,通过直接从池中获取线程,也可以如你所愿地尽快得到服务。你不会滥用可获得的资源,因为FixedThreadPool使用的Thread对象的数量是有界的。
注意,在任何线程池中,现有线程在可能的情况下,都会被自动复用。
尽管这里将使用CachedThreadPool,但是也应该考虑在产生线程的代码中使用FixedThreadPool。CachedThreadPool在程序执行过程中通常会创建与所需数量相同的线程,然后在它回收旧线程时停止创建新线程,因此它是合理的Executor的首选。只有当这种方式会引发问题时,你才需要切换到FixedThreadPool。
SingleThreadExecutor就像是线程数量为1的FixedThreadPool。这对于你希望在另一个线程中连续运行的任何事物(长期存活的任务)来说,都是很有用的,例如监听进入的套接字连接的任务。它对于希望在线程中运行的短任务也同样很方便,例如更新本地或远程日志的小任务或者是事件分发线程。
如果向SingleThreadExecutor提交了多个任务,那么这些任务将排队,每个任务都会在下一个任务开始之前运行结束,所有的任务将使用相同的线程。在下面的示例中,你可以看到每个任务都是按照它们被提交的顺序,并且是在下一个任务开始之前完成的。因此SingleThreadExecutor会序列化所有提交给它的任务,并会维护它自己(隐藏)的悬挂任务队列。
public class Test{
public static void main(String[] args) {
ExecutorService exec = Executors.newSingleThreadExecutor();
for(int i = 0 ; i < 5 ;i++){
exec.execute(new Task());
}
exec.shutdown();
}
//输出:
//#0(0) #0(1) #0(2) #0(3) #0(4) #0(5) #0(6) #0(7) #0(8) #0(9)
//#1(0) #1(1) #1(2) #1(3) #1(4) #1(5) #1(6) #1(7) #1(8) #1(9)
//#2(0) #2(1) #2(2) #2(3) #2(4) #2(5) #2(6) #2(7) #2(8) #2(9)
//#3(0) #3(1) #3(2) #3(3) #3(4) #3(5) #3(6) #3(7) #3(8) #3(9)
//#4(0) #4(1) #4(2) #4(3) #4(4) #4(5) #4(6) #4(7) #4(8) #4(9)
}
从任务中产生返回值
Runnable是执行工作的独立任务,但是它不返回任何值。如果你希望任务在完成时能够返回一个值,那么可以实现Callable接口而不是Runnable接口。在Java SE5中引入的Callabel是一种具有类型参数的泛型,它的类型参数表示的是从方法call()(而不是run())中返回的值,并且必须使用ExecutorServica.submit()方法调用它,下面是一个简单示例:
class TaskReturn implements Callable<String>{
private int id;
public TaskReturn(int id) {
this.id = id;
}
@Override
public String call() throws Exception {
return "return id:"+id;
}
}
public class Test{
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool();
List<Future<String>> list = new ArrayList<Future<String>>();
for(int i = 0 ; i < 5 ;i++){
list.add(exec.submit(new TaskReturn(i)));
}
for (Future<String> future : list) {
System.out.println(future.get());
}
}
//输出:
//return id:0
//return id:1
//return id:2
//return id:3
//return id:4
}
submit()方法会产生Future对象,它用Callable返回结果的特定类型进行了参数化。你可以用isDone()方法来査询Future是否已经完成。当任务完成时,它具有一个结果,你可以调用get()方法来获取该结果。你也可以不用isDone()进行检査就直接调用get(),在这种情况下,get()将阻塞直至结果准备就结。你还可以在试图调用get()来获取结果之前,先调用具有超时的get()或者调用isDone()来査看任务是否完成。
休眠
影响任务行为的一种简单方法是调用sleep(),这将使任务中止执行给定的时间。例:
class Task implements Runnable{
private static int taskCount = 0;
private final int id = taskCount++;
@Override
public void run() {
for(int i = 0 ; i < 10 ;i++){
System.out.print("#"+id+"("+i+") ");
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Test{
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool();
List<Future<String>> list = new ArrayList<Future<String>>();
for(int i = 0 ; i < 5 ;i++){
exec.execute(new Task());
}
exec.shutdown();
}
//输出:
//#2(0) #1(0) #0(0) #3(0) #4(0) #0(1) #1(1) #2(1) #3(1) #4(1)
//#0(2) #1(2) #2(2) #4(2) #3(2) #0(3) #1(3) #2(3) #3(3) #4(3)
//#1(4) #0(4) #2(4) #4(4) #3(4) #1(5) #0(5) #2(5) #4(5) #3(5)
//#1(6) #2(6) #0(6) #4(6) #3(6) #1(7) #0(7) #2(7) #4(7) #3(7)
//#1(8) #2(8) #0(8) #4(8) #3(8) #1(9) #2(9) #0(9) #4(9) #3(9)
}
对sleep()的调用可以抛出InterruptedException异常,并且你可以看到,它在run()中被捕获。因为异常不能跨线程传播回main(),所以你必须在本地处理所有在任务内部产生的异常。
JavaSE5引入了更加显式的sleep()版本,作为TimeUnit类的一部分,就像上面示例所示的那样。这个方法允许你指定sleep()延迟的时间単元,因此可以提供更好的可阅读性。
你可能会注意到,这些任务几乎是按照“完美的分布”顺序运行的,即从0到4,然后再回过头从0开始,当然这取决于你的平台。这是有意义的,因为在每个打印语句之后每个任务都将要睡眠(即阻塞),这使得线程调度器可以切换到另一个线程,进而驱动另一个任务。但是顺序行为依赖于底层的线程机制,这种机制在不同的操作系统之间是有差异的,因此你不能依赖于它。如果你必须控制任务执行的顺序,那么最好的押宝就是使用同步控制,或者在某些情况下压根不使用线程,但是要编写自己的协作进程,这些进程将会按照指定的顺序
在互相之间传递控制权。
优先级
线程的优先级将该线程的重要性传递给了调度器。尽管CPU处理现有线程集的顺序是不确定的,但是调度器将倾向于让优先权最高的线程先执行。然而这并不是意味着优先权较低的线程将得不到执行(也就是说,优先权不会导致死锁)。优先级较低的线程仅仅是执行的频率较低。
在绝大多数时间里,所有线程都应该以默认的优先级运行。试图操纵线程优先级通常是一种错误。
下面是一个演示优先级等级的示例:
class Task implements Runnable{
private int priority;
private volatile double d;
public Task(int p) {
this.priority = p;
}
@Override
public void run() {
Thread.currentThread().setPriority(priority);
for(int i = 0 ; i < 10000 ;i++){
d = (Math.PI + Math.E)/i;
}
System.out.println(this+":"+d);
}
@Override
public String toString() {
return Thread.currentThread()+"";
}
}
public class Test{
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool();
for(int i = 0 ; i < 5 ;i++){
exec.execute(new Task(Thread.MIN_PRIORITY));
}
exec.execute(new Task(Thread.MAX_PRIORITY));
exec.shutdown();
}
//输出:
//Thread[pool-1-thread-4,1,main]:5.860460528101648E-4
//Thread[pool-1-thread-2,1,main]:5.860460528101648E-4
//Thread[pool-1-thread-5,1,main]:5.860460528101648E-4
//Thread[pool-1-thread-6,10,main]:5.860460528101648E-4
//Thread[pool-1-thread-1,1,main]:5.860460528101648E-4
//Thread[pool-1-thread-3,1,main]:5.860460528101648E-4
}
toString()方法被覆盖,以便使用Thread.toString()方法来打印线程的名称,线程的优先级以及线程所属的“线程组”。注意,你可以在一个任务的内部,通过调用Thread.currentThread()来获得对驱动该任务的Thread对象的引用。
可以看到,最后一个线程的优先级最高,其余所有线程的优先级被设为最低。注意,优先级是在run()的开头部分设定的,在构造器中设置它们不会有任何好处,因为Executor在此刻还没有开始执行任务。
尽管JDK有10个优先级,但它与多数操作系统都不能映射得很好。比如,windows有7个优先级且不是固定的,所以这种映射关系也是不确定的。Sun的Solaris有2的31次幂个优先级。唯一可移植的方法是当调整优先级的时候,只使用MAX_PRIORITY、 NORM_PRIORITY和MIN_PRIORITY三种级别。
让步
如果知道已经完成了在run()方法的循环的一次迭代过程中所需的工作,就可以给线程调度机制一个暗示:你的工作已经做得差不多了,可以让别的线程使用CPU了。这个暗示将通过调用yield()方法来作出(不过这只是一个暗示,没有任何机制保证它将会被采纳)。当调用yield0 时,你也是在建议具有相同优先级的其他线程可以运行。
但是,大体上对于任何重要的控制或在调整应用时,都不能依赖于yield()。
class Task implements Runnable{
private static int taskCount = 0;
private final int id = taskCount++;
@Override
public void run() {
for(int i = 0 ; i < 1000 ;i++){
if(i == 500){
Thread.yield();
}
}
}
}
public class Test{
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool();
for(int i = 0 ; i < 5 ;i++){
exec.execute(new Task());
}
exec.shutdown();
}
//输出:
//thinkinjava.Task@5b941dc9
//thinkinjava.Task@2ac510e3
//thinkinjava.Task@3acff49f
//thinkinjava.Task@47415dbf
//thinkinjava.Task@592fa617
}
后台线程
所谓后台(daemon)线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且这种线程并不属于程序中不可或缺的部分。因此,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。反过来说,只要有任何非后台线程还在运行,程序就不会终止。比如执行main()的就是一个非后台线程
class Task implements Runnable{
@Override
public void run() {
try {
while(true){
TimeUnit.MILLISECONDS.sleep(1000);
System.out.println(Thread.currentThread()+" "+this);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class Test{
public static void main(String[] args) throws Exception {
for(int i = 0 ; i < 10; i++){
Thread t = new Thread(new Task());
t.setDaemon(false);
t.start();
}
System.out.println("All daemons start");
Thread.sleep(1000);
}
//输出:
//All daemons start
//Thread[Thread-8,5,main] thinkinjava.Task@3da3da69
//Thread[Thread-4,5,main] thinkinjava.Task@529e0c79
//Thread[Thread-6,5,main] thinkinjava.Task@5058431c
//Thread[Thread-7,5,main] thinkinjava.Task@157ee3e5
//Thread[Thread-5,5,main] thinkinjava.Task@157ee3e5
//Thread[Thread-9,5,main] thinkinjava.Task@3da3da69
//...
}
必须在线程启动之前调用setDaemon()方法,才能把它设置为后台线程。
一旦main()完成其工作,就没什么能阻止程序终止了,因为除了后台线程之外,已经没有线程在运行了。main()线程被设定为短暂睡眠,所以可以观察到所有后台线程启动后的结果。不这样的话你就只能看见一些后台线程创建时得到的结果。
可以通过调用isDaemon()方法来确定线程是否是一个后台线程。
你应该意识到后台进程在不执行finany子句的情况下就会终止其run()方法。这种行为是正确的。当最后一个非后台线程终止时,JVM就会立即关闭所有的后台进程,而不会有任何你希望出现的确认形式。因为你不能以优雅的方式来关闭后台线程,所以它们几乎不是一种好的思想。非后台的Executor通常是一种更好的方式,因为Executor控制的所有任务可以同时被关闭。
编码的变体
到目前为止,在你所看到的示例中,任务类都实现了Runnable。在非常简单的情况下,你可能会希望使用直接从Thread继承这种可替换的方式,就像下面这样:
class Task extends Thread{
public Task() {
start();
}
@Override
public void run() {
//do something ...
System.out.println(Thread.currentThread());
}
}
public class Test{
public static void main(String[] args) throws Exception {
new Task();
}
//输出:
//Thread[Thread-0,5,main]
}
另一种可能会看到的惯用法是自管理的Runnable:
class Task implements Runnable{
private Thread t = new Thread(this);
public Task() {
t.start();
}
@Override
public void run() {
//do something ...
System.out.println(Thread.currentThread());
}
}
public class Test{
public static void main(String[] args) throws Exception {
new Task();
}
//输出:
//Thread[Thread-0,5,main]
}
这与从Thread继承并没有什么特别的差异,只是语法稍微晦涩一些。但是实现接口使得你可以继承另一个不同的类,而从Thread继承将不行。
注意start()是在构造器中调用的。这个示例相当简单,因此可能是安全的,但是你应该意识到,在构造器中启动线程可能会变得很有问题,因为另一个任务可能会在构造器结束之前开始执行,这意味着该任务能够访问处于不稳定状态的对象。这是优选Executor而不是显式地创建Thread对象的另一个原因。
有时通过使用内部类来将线程代码隐藏在类中将会很有用,就像下面这样:
class InnerThread1{
private Inner i;
public InnerThread1() {
i = new Inner();
}
class Inner extends Thread{
Inner(){
start();
}
@Override
public void run() {
System.out.println(Thread.currentThread());
}
}
}
class InnerThread2{
private Thread t;
public InnerThread2() {
t = new Thread(){
@Override
public void run() {
System.out.println(Thread.currentThread());
}
};
t.start();
}
}
class InnerRunnable1{
private Inner i;
public InnerRunnable1() {
i = new Inner();
}
private class Inner implements Runnable{
public Inner() {
Thread t = new Thread(this);
t.start();
}
@Override
public void run() {
System.out.println(Thread.currentThread());
}
}
}
class InnerRunnable2{
private Thread t;
public InnerRunnable2() {
t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread());
}
});
t.start();
}
}
class ThreadMethod{
private Thread t;
void f(){
if(t == null){
t = new Thread(){
@Override
public void run() {
System.out.println(Thread.currentThread());
}
};
t.start();
}
}
}
public class Test{
public static void main(String[] args) throws Exception {
new InnerThread1();
new InnerThread2();
new InnerRunnable1();
new InnerRunnable2();
new ThreadMethod().f();
}
//输出:
//Thread[Thread-0,5,main]
//Thread[Thread-1,5,main]
//Thread[Thread-2,5,main]
//Thread[Thread-3,5,main]
//Thread[Thread-4,5,main]
}
InnerThread1创建了一个扩展自Thread的匿名内部类,并且在构造器中创建了这个内部类的一个实例。如果内部类具有你在其他方法中需要访问的特殊能力(新方法),那这么做将会很有意义,但是在大多数时候,创建线程的原因只是为了使用Thread的能力,因此不必创建匿名内部类。InnerThread2展示了可替换的方式:在构造器中创建一个匿名的Thread子类并且将其向上转型为Thread引用t。如果类中的其他方法需要访问t,那它们可以通过Thread接口来
实现,并且不需要了解该对象的确切类型。
该示例的第三个和第四个类重复了前面的两个类,但是它们使用的是Runnable接口而不是Thread类。
ThreadMethod类展示了在方法内部如何创建线程。当你准备好运行线程时,就可以调用这个方法,而在线程开始之后该方法将返回。如果该线程只执行辅助操作,而不是该类的重要操作,那么这与在该类的构造器内部启动线程相比,可能是一种更加有用而适合的方式。
加入一个线程
一个线程可以在其他线程之上调用join()方法,其效果是等待一段时间直到第二个线程结束才继续执行。如果某个线程在另一个线程t上调用t.join(),此线程将被挂起,直到目标线程t结束才恢复(即t.isAlive()返回为假)。
也可以在调用join()时带上一个超时参数(单位可以是秒、毫秒或者纳秒),这样如果目标线程在这段时间到期时还没有结束的话,join()方法总能返回。
对join()方法的调用可以被中断,做法是在调用线程上调用interrupt()方法,这时需要用到try-catch子句。例:
class Sleeper extends Thread{
int s;
public Sleeper(int s) {
this.s = s;
start();
}
@Override
public void run() {
try {
sleep(s);
} catch (InterruptedException e) {
System.out.println(Thread.currentThread()+",isInterrupted:"+Thread.currentThread().isInterrupted());
}
}
}
class Joiner extends Thread{
private Sleeper s;
public Joiner(Sleeper s) {
this.s = s;
start();
}
@Override
public void run() {
try {
s.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class Test{
public static void main(String[] args) throws Exception {
Sleeper s = new Sleeper(1500);
Joiner j = new Joiner(s);
s.interrupt();
}
//输出:
//Thread[Thread-0,5,main],isInterrupted:false
}
在run()中,sleep()方法有可能在指定的时间期满时返回,但也可能被中断。在catch子句中,将根据isInterrupted()的返回值报告这个中断。当另一个线程在该线程上调用interrupt()时,将给该线程设定一个标志,表明该线程已经被中断。然而,异常被捕获时将清理这个标志,所以在catch子句中,在异常被捕获的时候这个标志总是为假。除异常之外,这个标志还可用于其他情况,比如线程可能会检査其中断状态。
捕获异常
由于线程的本质特性,使得你不能捕获从线程中逃逸的异常。一但异常逃出任务的run()方法,它就会向外传播到控制台,除非你采取特殊的步骤捕获这种错误的异常。在Java SE5之前,你可以使用线程组来捕获这些异常,但是有了Java SE5就可以用Executor来解决这个问题。
Thread.UncaughtExceptionHandler是Java SE5中的新接口,它允许你在每个Thread对象上都附着一个异常处理器。Thread.UncaughtExceptionHandler.uncaughtException()会在线程因未捕获的异常而临近死亡时被调用。为了使用它,我们需要在启动线程前为它附着一个Thread.UncaughtExceptionHandler。例: .
class Task implements Runnable{
@Override
public void run() {
throw new RuntimeException();
}
}
class TaskExceptionHandler implements Thread.UncaughtExceptionHandler{
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.println(t.currentThread()+","+e);
}
}
class HandlerFactory implements ThreadFactory{
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setUncaughtExceptionHandler(new TaskExceptionHandler());
return t;
}
}
public class Test{
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool(new HandlerFactory());
exec.execute(new Task());
exec.shutdown();
}
//输出:
//Thread[Thread-0,5,main],java.lang.RuntimeException
}
你现在可以看到未捕获的异常是通过UncaughtException来捕获的。
如果你知道将要在代码中处处使用相同的异常处理器,那么更简单的方式是在Thread类中设置一个静态域,并将这个处理器设置为默认的未捕获异常处理器。
public class Test{
public static void main(String[] args) throws Exception {
Thread.setDefaultUncaughtExceptionHandler(new TaskExceptionHandler());
ExecutorService exec = Executors.newCachedThreadPool();
exec.execute(new Task());
exec.shutdown();
}
//输出:
//Thread[pool-1-thread-1,5,main],java.lang.RuntimeException
}
这个处理器只有在不存在线程专有的未捕获异常处理器的情况下才会被调用。 系统会检査线程专有版本,如果没有发现则检査线程组是否有其专有的uncaughtException()方法,如果也没有再调用defaultUncaughtExceptionHandler。
共享受限资源
可以把单线程程序当作在问题域求解的单一实体,每次只能做一件事情。因为只有一个实体,所以永远不用担心诺如“两个实体试图同时使用同一个资源”这样的问题,比如两个人在同一个地方停车,两个人同时走过一扇门等。
有了并发就可以同时做多件事情了,但是两个或多个线程彼此互相干涉的问题也就出现了。如果不防范这种冲突,就可能发生两个线程同时试图访问同一个资源,例如银行账户或向同一个打印机打印等。
解决共享资源竞争
想象一下,你坐在桌边手拿叉子,正要去叉盘子中的最后一片食物:当你的叉子就要够着它时,这片食物突然消失了,因为你的线程被挂起了而另一个餐者进入并吃掉了它。这正是在你编写并发程序时需要处理的问题。对于并发工作,你需要某种方式来防止两个或多个任务同时访问相同的资源,至少在关键阶段不能出现这种情況。
防止这种冲突的方法就是当资源被一个任务使用时,在其上加锁。第一个访问某项资源的任务必须锁定这项资源,使其他任务在其被解锁之前,就无法访问它了,而在其被解锁之时,另一个任务就可以锁定并使用它,以此类推。
基本上所有的并发模式在解决线程冲突问题的时候,都是采用序列化访问共享资源的方案。这意味着在给定时刻只允许一个任务访问共享资源通常这是通过在代码前面加上一条锁语句来实现的,这就使得在一段时间内只有一个任务可以通行这段代码:因为锁语句产生了一种互相排斥的效果,所以这种机制常常称为互斥量(mutex)。
考虑一下屋子里的浴室:多个人(即多个由线程驱动的任务)都希望能单独使用浴室(即共享资源)。为了使用浴室,一个人先敲门看看是否能使用,如果没人的话他就进入浴室并锁上门。这时其他人要使用浴室的话,就会被“阻挡”,所以他们要在浴室门口等待,直到浴室可以使用。当浴室使用完毕,就该把浴室给其他入使用了(别的任务就可以访问资源了),这个比喻就有点不太准确了。事实上人们并没有排队,我们也不能确定谁将是下一个使用浴室的人,因为线程调度机制并不是确定性的。实际情况是:等待使用浴室的人们簇拥在浴室门口,当锁住浴室门的那个人打开锁准备离开的时候,离门最近的那个人可能进入浴室。如前所述,可以通过yield()和setPriotity()来给线程调度器提供建议,但这些建议来必会有多大效果,这取决于你的具体平台和JVM实现。
Java以提供关键字synchronized的形式为防止资源冲突提供了内置支持。当任务要执行被synchronized关键字保护的代码片段的时候,它将检査锁是否可用,然后获取锁,执行代码,释放锁。
共享资源一般是以对象形式存在的内存片段,但也可以是文件、输入/输出端口,或者是打印机。要控制对共享资源的访问,得先把它包装进一个对象。然后把所有要访问这个资源的方法标记为synchronized。如果某个任务处于一个对标记为synchronized的方法的调用中,那么在这个线程从该方法返回之前,其他所有要调用类中任何标记为synchronized方法的线程都会被阻塞。
下面是声明synchronized方法的方式:
synchronized void f(){/…/}
synchronized void g(){/…/}
所有对象都自动含有单一的锁(也称为监视器)。当在对象上调用其任意synchronized方法的时候,此对象都被加锁,这时该对象上的其他synchronized方法只有等到前一个方法调用完毕并得放了锁之后才能被调用。对于前面的方法,如果某个任务对对象调用了f(),对于同一个对象而言,就只能等到f()调用结束并解放了锁之后,其他任务才能调用f()和g()。所以对于某个特定对象来说,其所有synchronized方法共享同一个锁这可以被用来防止多个任务同时访问被编码为对象内存。
注意,在使用并发时,将域设置为private是非常重要的,否则synchronized关关键字就不能防止其他任务直接访问域,这样就会产生冲突。
一个任务可以多次获得对象的锁。如果一个方法在同一个对象上调用了第二个方法,后者又调用了同一对象上的另一个方法,就会发生这种情况。JVM负责跟踪对象被加锁的次数。如果一个对象被解锁(即锁被完全释放),其计数变为0。在任务第一次给对象加锁的时候,计数变为1。每当这个相同的任务在这个对象上获得锁时,计数都会递增。显然只有首先获得了锁的任务才能允许继续获取多个锁。每当任务离开一个synchronized方法,计数递减,当计数为零的时候,锁被完全释放,此时別的任务就可以使用此资源。
针对每个类,也有一个锁(作为类的Class对象的一部分)所以synchronized static方法可以在类的范围内防止对static数据的并发访问。
你应该什么时候同步呢?可以运用Brian的同步规则:
如果你正在写一个变量,可能接下来将被另一个线程读取,或者正在读取一个上一次已经被另一个线程写过的变量,那么你必须使用同步,并且读写线程都必须用相同的监视器锁同步。
如果在你的类中有超过一个方法在处理临界数据,那么你必须同步所有相关的方法。如果只同步一个方法,那么其他方法将会随意地忽略这个对象锁,并可以在无任何惩罚的情况下被调用。这是很重要的一点:每个访问临界共享资源的方法都必须被同步,否则它们就不会正确地工作。
同步控制
加入synchronized关键字,可以防止不希望的线程访问:
public class Test{
private int i = 0;
public synchronized void set(){
i++;
}
public synchronized int get(){
return i;
}
}
任何其他试图获取锁的任务都将从其开始尝试之时被阻塞,直至第一个任务释放锁。通过这种方式,任何时刻只有一个任务可以通过由互斥量看护的代码。
使用显示的Lock对象
Java SE5的java.util.concurrent类库还包含有定义在java.util.concurrent.locks中的显式的互斥机制。Lock对象必须被显式地创建、锁定和释放。因此它与内建的锁形式相比,代码缺乏优雅性。但是,对于解决某些类型的问题来说,它更加灵活。例:
public class Test{
private int i = 0;
private Lock lock = new ReentrantLock();
public void set(){
lock.lock();
i++;
lock.unlock();
}
public int get(){
lock.lock();
try {
return i;
}finally{
lock.unlock();
}
}
}
上例添加了一个被互斥调用的锁,并使用lock()和unlock()方法在get()内部创建了临界资源。当你在使用Lock对象时,将这里所示的惯用法内部化是很重要的:紧接着的对lock()的调用,你必须放置在finally子句中带有unlock()的try-finally语句中。注意return语句必须在try子句中出现,以确保unlock()不会过早发生,从而将数据暴露给了第二个任务。
尽管try-finally所需的代码比synchronized关键字要多,但是这也代表了显式的Lock对象的优点之一。如果在使用synchronized关键字时,某些事物失败了,那么就会抛出一个异常。但是你没有机会去做任何清理工作,以维护系统使其处于良好状态。有了显式的Lock对象,你就可以使用finally子句将系统维护在正确的状态了。
大体上,当你使用synchronized关键字时,需要写的代码量更少,并且用户错误出现的可能性也会降低,因此通常只有在解决特殊问题时,才使用显式的Lock对象。
显式的Lock对象在加锁和释放锁方面,相对于内建的synchronized锁来说,还赋予了你更细粒度的控制力。这对于实现专有同步结构是很有用的,例如用于遍历链接列表中的节点的节节传递的加锁机制(也称为锁耦合),这种遍历代码必须在释放当前节点的锁之前捕获下一个节点的锁。
原子性与易变性
在有关Java线程的讨论中,一个常不正确的知识是“原子操作不需要进行同步控制”。原子操作是不能被线程调度机制中断的操作;一旦操作开始,那么它一定可以在可能发生的“上下文切换”之前(切换到其他线程执行)执行完毕。依赖于原子性是很棘手且很危险的,如果你是一个并发专家,或者你得到了来自这样的专家的帮助,你才应该使用原子性来代替同步。
了解原子性是很有用的,并且要知道原子性与其他高级技术一道,在java.util.concurrent类库中已经实现了某些更加巧妙的构件。但是要坚决抵挡住完全依赖自己的能力去进行处理的这种欲望。请看看之前表述的Brian的同步规则。
原子性可以应用于除Iong和double之外的所有基本类型之上的“简单操作”。对于读取和写入除long和double之外的基本类型变量这样的操作,可以保证它们会被当作不可分(原子)的操作来操作内存。但是JVM可以将64位(long和double变量)的读取和写入当作两个分离的32位操作来执行,这就产生了在一个读取和写入操作中间发生上下文切换,从而导致不同的任务可以看到不正确结果的可能性(这有时被称为字撕裂,因为你可能会看到部分被修改过的数值)。但是,当你定义long或double変量时,如果使用volatile关键字,就会获得(简单的赋值与返回操作的)原子性(注意,在Java SE5之前,volatile一直未能正确地工作)。不同的JVM可以任意地提供更强的保证,但是你不应该依赖于平台相关的特性。
因此,原子操作可由线程机制来保证其不可中断,考家级的程序员可以利用这一点来编写无锁的代码,这些代码不需要被同步。但是即便是这样,它也是一种过于简化的机制。有时,甚至看起来应该是安全的原子操作,实际上也可能不安全。
在多处理器系统(现在以多核处理器的形式出现,即在单个芯片上有多个CPU)上,相对于单处理器系统而言,可视性问题远比原子性问题多得多一个任务做出的修改,即使在不中断的意义上讲是原子性的,对其他任务也可能是不可视的(例如,修改只是暂时性地存储在本地处理器的缓存中),因此不同的任务对应用的状态有不同的视图。另一方面,同步机制强制在处理器系统中,一个任务做出的修改必须在应用中是可视的。如果没有同步机制,那么修改时可视将无法确定。
volatile关键字还确保了应用中的可视性。如果你将一个域声明为volatile的,那么只要对这个域产生了写操作,那么所有的读操作就都可以看到这个修改。即便使用了本地缓存,情况也确实如此,volatile域会立即被写入到主存中,而读取操作就发生在主存中。
理解原子性和易变性是不同的概念这一点很重要。在非volatile域上的原子操作不必刷新到主存中去,因此其他读取该域的任务也不必看到这个新值。如果多个任务在同时访问某个域,那么这个域就应该是volatile的,否则这个域就应该只能经由同步来访向。同步也会导致向主存中刷新,因此如果一个域完全由synchronized方法或语句块来防护,那就不必将其设置为是volatile的。
一个任务所作的任何写入操作对这个任务来说都是可视的,因此如果它只需要在这个任务内部可视,那么你就不需要将其设置为volatile的。
当一个域的值依赖于它之前的值时(例如递增一个计数器),volatile就无法工作了。如果某个域的值受到其他域的值的限制,那么volatile也无法工作,例如Range类的lower和upper边界就必须遵循lower<=upper的限制。
使用volatile而不是synchronized的唯一安全的情况是类中只有一个可变的域。再次提醒,你的第一选择应该是使用synchronized关键字,这是最安全的方式,而尝试其他任何方式都是有风险的。
什么才属于原子操作呢?对域中的值做赋值和返回操作通常都是原子性的。然而Java递增操作不是原子性的,并且涉及一个读操作和一个写操作,所以即便是在这么简单的操作中,也为产生线程问题留下了空间。
基本上,如果一个域可能会被多个任务同时访问,或者这些任务中至少有一个是写入任务,那么你就应该将这个域设置为volatile的。如果你将一个域定义为volatile,那么它就会告诉编译器不要执行任何移除读取和写入操作的优化,这些操作的目的是用线程中的局部变量维护对这个域的精确同步。实际上读取和写入都是直接针对内存的,而却没有被缓存。但是volatile并不能对递增不是原子性操作这一事实产生影响。
原子类
Java SE5引入了诸如AtomicInteger、AtomicLong、AtomicReference等特殊的原子性变量类,它们提供下面形式的原子性条件更新操作:
boolean compareAndSet(expectedValue, updateValue)
这些类被调整为可以使用在某些现代处理器上的可获得的,并且是在机器级别上的原子性,因此在使用它们时通常不需要担心。对于常规编程来说它们很少会派上用场,但是在涉及性能调优时它们就大有用武之地。例:
public class Test{
private AtomicInteger ai = new AtomicInteger(0);
public void set(){
ai.addAndGet(1);
}
public int get(){
return ai.get();
}
}
应该强调的是,Atomic类被设计用来构建java.util.concurrent中的类,因此只有在特殊情况下才在自己的代码中使用它们,即便使用了也需要确保不存在其他可能出现的同题。通常依赖于锁要更安全一些(要么是synchronized关键字,要么是显式的lock对象)。
临界区
有时,你只是希望防止多个线程同时访同方法内部的部分代码而不是防止访问整个方法。通过这种方式分离出来的代码段被称为临界区(critical section),它也使用synchronized关键字建立。这里synchronized被用来指定某个对象,此对象的锁被用来对花括号内的代码进行同步控制:
synchronized(yncObject){
//do something …
}
这也被称为同步控制块;在进入此段代码前,必须得到yncObject对象的锁。如果其他线程已经得到这个锁,那么就得等到锁被释放以后才能进入临界区。通过使用同步控制块,而不是对整个方法进行同步控制,可以使多个任务访问对象的时间性能得到显著提高。
public class Test{
private int i;
public void set(){
synchronized(this){
i++;
}
}
public int get(){
synchronized(this){
return i;
}
}
private Test t = new Test();
public void set2(){
synchronized(t){
i++;
}
}
public int get2(){
synchronized(t){
return i;
}
}
}
当然你还可以声明一个其他的对象用来同步,就行set2()和get()2方法一样。
你还可以使用显式的Lock对象来创建临界区:
public class Test{
private int i;
private Lock lock = new ReentrantLock();
public void set(){
lock.lock();
i++;
lock.unlock();
}
public int get(){
lock.lock();
try {
return i;
}finally{
lock.unlock();
}
}
}
线程本地存储
防止任务在共享资源上产生冲突的第二种方式是根除对变量的共享。线程本地存储是一种自动化机制,可以为使用相同变量的每个不同的线程都创建不同的存储。因此如果你有5个线程都要使用变量x所表示的对象,那线程本地存储就会生成5个用于x的不同的存储块。主要是它们使得你可以将状态与线程关联起来。创建和管理线程本地存储可以由java.lang.ThreadLocal类来实现。例:
class Task implements Runnable{
private int i;
public Task(int i) {
this.i = i;
}
@Override
public void run() {
int i = 2;
while(i >0){
System.out.println("#"+i+ThreadLocal_.get());
ThreadLocal_.set();
--i;
}
}
}
class ThreadLocal_{
private static ThreadLocal<Integer> t = new ThreadLocal<Integer>(){
protected Integer initialValue() {
return 1;
}
};
public static void set(){
t.set(t.get()+1);
}
public static int get(){
return t.get();
}
}
public class Test{
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
for(int i = 0 ; i< 5;i++){
exec.execute(new Task(i));
}
exec.shutdown();
}
//输出:
//#21
//#21
//#21
//#21
//#21
//#12
//#12
//#12
//#12
//#12
}
ThreadLocal对象通常当作静态域存储。在创建ThreadLocal时,你只能通过get()和set()方法来访问该对象的内容,其中get()方法将返回与其线程相关联的对象的副本,而set()会将参数插入到为其线程存储的对象中,并返回存储中原有的对象。set()和get()方法在ThreadLocal_中演示了这一点。注意 set()和get()方法都不是synchronized的,因为ThreadLocal保证不会出现竟争条件。
当运行这个程序时,你可以看到每个单独的线程都被分配了自己的存储,因为它们每个都需要跟踪自己的计数值,即使只有一个ThreadLocal_对象。
终结任务
cancel()和isCanceled()方法被放到了一个所有任务都可以看到的类中。这些任务通过检査isCanceled()来确定何时终止它们自己,这是一种合理的方式。但是,在某些情况下任务必须更加突然地终止。
在阻塞时终结
有时我们必须终止被阻塞的任务。
线程状态
一个线程可以处于以下四种状态之一:
- 新建(new):当线程被创建时,它只会短暂地处于这种状态。此时它已经分配了必需的系统资源,并执行了初始化。此刻线程已经有资格获得CPU时间了,之后调度器将把这个线程转变为可通行状态或阻塞状态。
- 就绪(Runnable):在这种状态下,只要调度器把时间片分配给线程,线程就可以运行。也就是说在任意时刻线程可以运行也可以不运行。只要调度器能分配时间片给线程,它就可以运行,这不同于死亡和阻塞状态。
- 阻塞(Blocked):线程能够运行,但有某个条件阻止它的运行。当线程处于阻塞状态时,调度器将忽略线程,不会分配给线程任何CPU时间。直到线程重新进入了就绪状态,它才有可能执行操作。
- 死亡(Dead):处于死亡或终止状态的线程将不再是可调度的,并且再也不会得到CPU时间,它的任务已结束,或不再是可运行的。任务死亡的通常方式是从run()方法返回,但是任务的线程还可以被中断。
进入阻塞状态
一个任务进入阻塞状态可能有如下原因:
- 通过调用sleep(milliseconds)使任务进入休眠状态,在这种情况下任务在指定的时间内不会运行。
- 通过调用wait()使线程挂起。直到线程得到了notify()或notifyAll()消息(或者在Java SE5的java.util.concurrent类库中等价的signal()或signalAll()消息),线程才会进入就绪状态。
- 任务在等待某个输入/输出完成。
- 任务试图在某个对象上调用其同步控制方法,但是对象锁不可用,因为另一个任务已经获取了这个锁。
在较早的代码中,也可能会看到用suspend()和resume()来阻塞和唤醒线程,但是在现代Java中这些方法被废止了(因为可能导致死锁), stop()方法也已经被废止了,因为它不解放线程获得的锁,并且如果线程处于不一致的状态(受损状态),其他任务可以在这种状态下浏览并修改它们。这样所产生的问题是微妙而难以被发现的。
现在我们需要査看的问题是:有时你希望能够终止处于阻塞状态的任务。如果对于处于阻塞状态的任务,你不能等待其到达代码中可以检査其状态值的某一点,因而决定让它主动地终止,那么你就必须强制这个任务跳出阻塞状态。
中断
当你打断被阻塞的任务时,可能需要清理资源。因此在Java线程中的这种类型的异常中断中用到了异常(这会滑向异常的不恰当用法,因为这意味着你经常用它们来控制执行流程)。为了在以这种方式终止任务时,返回众所周知的良
好状态,你必须仔细考虑代码的执行路径,并仔细编写catch子句以正确清除所有事物。
Thread类包含interrupt()方法,因此你可以终止被阻塞的任务,这个方法将设置线程的中断状态。如果一个线程已经被阻塞,或者试图执行一个阻塞操作,那么设置这个线程的中断状态将抛出InterruptdException。当抛出该异常或者该任务调用Thread.interrupted()时,中断状态将被复位。Thread.interrupted()提供了离开run()中的循环而不抛出异常的第二种方式。
为了调用interrupt()你必须持有Thread对象。你可能已经注意到了,新的concurrent类库似乎在避免对Thread对象的直接操作,转而尽量通过Executor来执行所有操作。如果你在Executor上调用shutdownNow(),那么它将发送一个interrupt()调用给它启动的所有线程。这么做是有意义的,因为当你完成工程中的某个部分或者整个程序时,通常会希望同时关闭某个特定Executor的所有任务。然而你有时也会希望只中断某个单一任务。如果使用Executor,那么通过调用submit()而不是executor()来启动任务,就可以持有该任务的上下文。submit()将返回一个泛型Future<?>,其中有一个未修饰的参数,因为你永远都不会在其上调用get()——持有这种Future的关键在于你可以在其上调用cancel(),并因此可以使用它来中断某个特定任务。如果你将true待递给cancel()那么它就会拥有在该线程上调用interrupt()以停止这个线程的权限。因此,cancel()是一种中断由Executor启动的单个线程的方式。例:
class SleepTask implements Runnable{
@Override
public void run() {
try {
TimeUnit.MILLISECONDS.sleep(2000);
System.out.println("Sleep over");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class IOTask implements Runnable{
static InputStream is = System.in;
@Override
public void run() {
try {
is.read();
} catch (Exception e) {
e.printStackTrace();
}
}
}
class SyncTask implements Runnable{
public synchronized void f(){
while(true){
Thread.yield();
}
}
public SyncTask() {
new Thread(){
@Override
public void run() {
f();
}
}.start();
}
@Override
public void run() {
System.out.println("begin f()");
f();
System.out.println("end f()");
}
}
public class Test{
static ExecutorService exec = Executors.newCachedThreadPool();
static void test(Runnable r) throws Exception{
Future<?> f = exec.submit(r);
TimeUnit.MILLISECONDS.sleep(1000);
f.cancel(true);
}
public static void main(String[] args) throws Exception {
test(new SleepTask());
test(new IOTask());
test(new SyncTask());
/*if(IOTask.is != null){
IOTask.is.close();
}*/
}
}
上面的每个任务都表示了一种不同类型的阻塞。SleepTask是可中断的阻塞示例,而IOTask和SyncTask是不可中断的阻塞示例。这个程序证明I/O和在synchronized 块上的等待是不可中断的,但是通过浏览代码,你也可以预见到这一点——无论是I/O还是尝试调用synchronized方法,都不需要任何InterruptedException处理器。
前两个类很筒单直观:在第一个类中run()方法调用了sleep(),而在第二个类中调用了read()。 但是为了演示SyncTask我们必须首先获取锁。这是通过在构造器中创建匿名的Thread类的实例来实现的,这个匿名Thread类的对象通过调用f()获取了对象锁(这个线程必须有别于为SyncTask驱动run()的线程,因为一个线程可以多次获得某个对象锁)。由于f()永远都不返回,因此这个锁永远不会释放,而SyncTask.run()在试图调用f()并阻塞以等待这个被释放。
从输出中可以看到,你能够中断对sleep()的调用(或者任何要求抛出InterruptedException的调用)。但是你不能中断正在试图获取synchronized锁或者试图执行I/O操作的线程。这有点令人烦恼,特别是在创建执行I/O的任务时,因为这意味着I/O具有锁住你的多线程程序的潜在可能。特别是对于基于Web的程序,这更是关乎利害。
对于这类问题,一个略显笨拙但是有时确实行之有效的解决方案,即关闭任务在其上发生阻塞的底层资源,就像上面注释掉的.close()方法。
被互斥所阻塞
就像前面在不可中断的I/O中所观察到的那样,无论在任何时刻,只要任务以不可中断的方式被阻塞,那么都有潜在的会锁住程序的可能。Java SE5并发类库中添加了一个特性,即在ReentrantLock上阻塞的任务具备可以被中断的能力,这与在synchronized方法或临界区上阻塞的任务完全不同:
class Task implements Runnable{
private Lock lock = new ReentrantLock();
public Task() {
lock.lock();
}
@Override
public void run() {
System.out.println("begin");
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end");
}
}
public class Test{
public static void main(String[] args) throws Exception {
Thread t = new Thread(new Task());
t.start();
TimeUnit.SECONDS.sleep(2);
t.interrupt();
}
}
检查中断
注意,当你在线程上调用interrupt()时,中断发生的唯一时刻是在任务要进入到阻塞操作中,或者已经在阻塞操作内部时(如你所见除了不可中断的I/O或被阻塞的synchronized方法之外,在其余的例外情况下你无可事事)。但是如果根据程序运行的环境,你已经编写了可能会产生这种阻塞调用的代码,那又该怎么办呢?如果你只能通过在阻塞调用上抛出异常来退出,那么你就无法总是可以离开run()循环。因此如果你调用interrupt()以停止某个任务,那么在run()循环碰巧没有产生任何阻塞调用的情況下,你的任务将需要第二种方式来退出。
这种机会是由中断状态来表示的,其状态可以通过调用interrupt()来设置。你可以通过调用interrupted()来检査中断状态,这不仅可以告诉你interrupt()是否被调用过,而且还可以清除中断状态。清除中断状态可以确保并发结构不会就某个任务被中断这个问题通知你两次,你可以经由单一的InterruptdException或单一的成功的Thread.interrupted()测试来得到这种通知。如果想要再次检査以了解是否被中断,则可以在调用Thread.interrupted()时将结果存储起来。例:
class Task implements Runnable{
@Override
public void run() {
while(!Thread.interrupted()){
System.out.println(Thread.currentThread());
}
}
}
public class Test{
public static void main(String[] args) throws Exception {
Thread t = new Thread(new Task());
t.start();
TimeUnit.SECONDS.sleep(2);
t.interrupt();
}
}
线程之间的协作
现在的问题不是彼此之间的干涉,而是彼此之间的协调。如何使任务彼此之间可以协作,以使得多个任务可以一起工作去解决某个问题,某些部分必须在其他部分被解决之前解决。这非常像项目规划:必须先挖房子的地基,但是接下来可以并行地铺设钢结构和构建水泥部件,而这两项任务必须在混凝土浇注之前完成。等等。 在这些任务中,某些可以并行执行,但是某些步骤需要所有的任务都结束之后才能开动。
当任务协作时,关键问题是这些任务之间的提手。为了实现这种握手,我们使用了相同的基础特性:互斥。在这种情况下,互斥能够确保只有一个任务可以响应某个信号,这样就可以根除任何可能的竞争条件。在互斥之上,我们为任务添加了一种途径,可以将其自身挂起,直至某些外部条件发生变化,表示是时候让这个任务向前开动了为止。
这种握手可以通过Object的方法wait()和notify()来安全地实现。Java SE5的并发类库还提供了具有await()和signaI()方法的Condition对象。
wait()与notify()
wait()使你可以等待某个条件发生变化,而改变这个条件超出了当前方法的控制能力。通常这种条件将由另一个任务来改变。你肯定不想在你的任务测试这个条件的同时,不断地进行空循环,这被称为忙等待,通常是一种不良的CPU周期使用方式。因此wait()会在等待外部世界产生变化的时候将任务挂起,并且只有在notify()或notifyAll()发生时,即表示发生了某些感兴趣的事物,这个任务才会被唤醒并去检査所产生的变化。因此wait()提供了一种在任务之间对活动同步的方式。
调用sleep()的时候锁并没有被释放,调用yield()也属于这种情况,理解这一点很重要。另一 方面,当一个任务在方法里遇到了对wait()的调用的时候,线程的执行被挂起,对象上的锁被释放。因为wait()将释放锁,这就意味着另一个任务可以获得这个锁因此在该对象(现在是未锁定的)中的其他synchronized方法可以在wait()期间被调用。这一点至关重要,因为这些其他的方法通常将会产生改变,而这种改变正是使被挂起的任务重新唤醒所感兴趣的变化。因此,当你调用wait()时,就是在声明:“我已经刚刚做完能做的所有事情,因此我要在这里等待,但是我希望其他的synchronized操作在条件适合的情况下能够执行。”
有两种形式的wait()。第一种版本接受毫秒数作为参数,含义与sleep()方法里参数的意思相同,都是指“在此期间暂停”。但是与sleep()不同的是,对于wait()而言:
- 在wait()期间对象锁是释放的。
- 可以通过notify()、notifyAll()或者令时间到期从wait()中恢复执行。
第二种,也是更常用形式的wait()不接受任何参数。这种wait()将无限等待下去,直到线程接收到notify()或者notifyAll()消息。
wait()、notify()以及notifyAll()有一个比较特殊的方面、那就是这些方法是基类Object的一部分而不是属于Thread的一部分。尽管开始看起来有点奇怪——仅仅针对线程的功能却作为通用基类的一部分而实现,不过这是有道理的,因为这些方法操作的锁也是所有对象的一部分。所以,你可以把wait()放进任何同步控制方法里而不用考虑这个类是继承自Thread还是实现了Runnable接口。实际上,只能在同步控制方法或同步控制块里调用wait()、notify()和notifyAll()(因为不用操作锁,所以sleep()可以在非同步控制方法里调用)。如果在非同步控制方法里调用这些方法,程序能通过编译但运行的时候将得到IllegalMonitorStateException异常,并伴随着一些含糊的消息,比如“当前线程不是拥有者”。消息的意思是,调用wait()、notify()和notifyAll()的任务在调用这些方法前必须“拥有”(获取)对象的锁。
可以让另一个对象执行某种操作以维护其自己的锁。要这么做的话,必须首先得到对象的锁。比如如果要向对象x发送notifyAll(),那么就必须在能够取得x的锁的同步控制块中这么做:
synchronized(x){
x.notifyAll();
}
让我们看一个简单的示例:其中有两个过程:一个是将蜡涂到Car上,一个是
抛光它。抛光任务在涂蜡任务完成之前,是不能执行其工作的,而除蜡任务在涂另一层蜡之前必须等待抛光任务完成。
class Car{
private boolean waxOn = false;
public synchronized void waxed(){
waxOn = true;
notifyAll();
}
public synchronized void buffed(){
waxOn = false;
notifyAll();
}
public synchronized void waitForWaxing() throws Exception{
while(!waxOn)
wait();
}
public synchronized void waitForBuffing() throws Exception{
while(waxOn)
wait();
}
}
class WaxOn implements Runnable{
private Car c;
public WaxOn(Car c) {
this.c = c;
}
@Override
public void run() {
while(!Thread.interrupted()){
try {
System.out.println("Wax On!");
TimeUnit.MILLISECONDS.sleep(200);
c.waxed();
c.waitForBuffing();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
class WaxOff implements Runnable{
private Car c;
public WaxOff(Car c) {
this.c = c;
}
@Override
public void run() {
while(!Thread.interrupted()){
try {
c.waitForWaxing();
System.out.println("Wax Off!");
TimeUnit.MILLISECONDS.sleep(200);
c.buffed();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
public class Test{
public static void main(String[] args) throws Exception {
Car c = new Car();
ExecutorService exec = Executors.newCachedThreadPool();
exec.execute(new WaxOff(c));
exec.execute(new WaxOn(c));
TimeUnit.SECONDS.sleep(5);
exec.shutdownNow();
}
}
示例强调你必须用一个检査感兴趣的条件的while循环包围wait()。这很重要,因为:
- 你可能有多个任务出于相同的原因在等待同一个锁,而第一个唤醒任务可能会改变这种状况(即使你没有这么做,有人也会通过继承你的类去这么做)。如果属于这种情况,那么这个任务应该被再次挂起,直至其感兴趣的条件发生变化。
- 在这个任务从其wait()中被唤醒的时刻,有可能会有某个其他的任务已经做出了改变,从而使得这个任务在此时不能执行,或者执行其操作已显得无关紧要。此时应该通过再次调用wait()来将其重新挂起。
- 也有可能某些任务出于不同的原因在等待你的对象上的锁(在这种情况下必须使用notifyAll())。在这种情况下,你需要检査是否已经由正确的原因唤醒,如果不是就再次调用wait()。
错失的信号
当两个线程使用notify()/wait()或notifyAll()/wait()进行协作时,有可能会错过某个信号。假设T1是通知T2的线程,而这两个线程都是使用下面(有缺陷的)方式实现的:
T1:
synchronized(s){
<setup condition for T2>
s.notify();
}
T2:
while(s2){
// Point 1
synchronized(s){
s.wait();
}
}
<Setup condition for T2>是防止T2调用wait()的一个动作,当然前提是T2还没有调用wait()。
假设T2对s2求值并发现其为true。在Point 1线程调度器可能切换到了T1。而T1将执行其设置,然后调用notify()。当T2得以继续执行时,此时对于T2来说时机已经太晩了,以至于不能意识到这个条件已经发生了变化,因此会盲目进入wait()。此时notify()将错失,而T2也将无限地等待这个已经发送过的信号,从而产生死锁。
该问题的解决方案是防止在s2变量上产生竞争条件:
synchronized(s){
while(s2){
s.wait();
}
}
现在,如果T1首先执行,当控制返回T2时,它将发现条件发生了变化,从而不会进入wait()。反过来,如果T2首先执行,那它将进入wait(),并且稍后会由T1唤醒。因此信号不会错失。
notify()于notifyAll()
因为在技术上,可能会有多个任务在单个Car对象上处于wait()状态,因此调用notifyAll()比只调用notify()要更安全。使用notify()而不是notifyAll()是一种优化。使用notify()时,在众多等待同一个锁的任务中只有一个会被唤醒,因此如果你希望使用notify(),就必须保证被唤醒的是恰当的任务。另外,为了使用notify(),所有任务必须等待相同的条件,因为如果你有多个任务在等待不同的条件,那么你就不会知道是否唤醒了恰当的任务。如果使用notify(),当条件发生变化时,必须只有一 个任务能够从中受益。最后,这些限制对所有可能存在的子类都必须总是起作用的。如果这些规则中有任何一条不满足,那么你就必须使用notifyAll()而不是notify()。
在有关Java的线程机制的讨论中,有一个令人困惑的描述:notiflyAll()将唤醒“所有正在等待的任务”。这是否意味着在程序中任何地方,任何处于wait()状态中的任务都将被任何对notifyAll()的调用唤醒呢?情况并非如此,事实上当notifyAll()因某个特定锁而被调用时,只有等待这个锁的任务才会被唤醒。
死锁
现在你理解了一个对象可以有synchronized方法或其他形式的加锁机制来防止別的任务在互斥还没有释放的时候就访问这个对象。我们已经学习过任务可以变成阻塞状态,所以就可能出现这种情况:某个任务在等待另一个任务,而后者又等待别的任务,这样一直下去,直到这个整条上的任务又在等待第一个任务释放锁。这得到了一个任务之间相互等待的连续循环,没有哪个线程能继续。这被称之为死锁。
要修正死锁问题,你必须明白当以下四个条件同时满足时,就会发生死锁:
- 互斥条件。任务使用的资源中至少有一个是不能共享的。
- 至少有一个任务它必须持有一个资源且正在等待获取一个当前被別的任务持有的资源。
- 资源不能被任务抢占,任务必须把资源释放当作普通事件。
- 必须有循坏等待,这时一个任务等待其他任务所持有的资源,后者又在等待另一个任务所持有的资源,这样一直下去直到有一个任务在等待第一个任务所持有的资源,使得大家都被锁住了。
因为要发生死锁的话,所有这些条件必须全部满足;所以要防止死锁的话只需破坏其中一个即可。在程序中防止死锁最容易的方法是破坏第4个条件。
新类库中的构建
Java SE5的java.util.concurrent引入了大量设计用来解决并发问题的新类。学习使用它们将有助于你编写出更加简単而健壮的并发程序。
CountDownLatch
它被用来同步一个或多个任务,强制它们等待由其他任务执行的一组操作完成。
你可以向CountDownLatch对象设置一个初始计数值,任何在这个对象上调用wait()的方法都将阻塞,直至这个计数值到达0。其他任务在结束其工作时,可以在该对象上调用countDown()来减小这个计数值。CountDownLatch被设计为只触发一次,计数值不能被重置。如果你需要能够重置计数值的版本,则可以使用CyclicBarrier。
调用CountDownLatch的任务在产生这个调用时并没有被阻塞,只有对await()的调用会被阻塞,直至计数值到达0。
CountDownLatch的典型用法是将一个程序分为n个互相独立的可解决任务,并创建值为0的CountDownLatch。当每个任务完成时,都会在这个锁存器上调用countDown()。等待问题被解决的任务在这个锁存器上调用await()将它们自己拦住,直至锁存器计数结束。
CyclicBarrier
CyclicBarrier适用于这样的情况:你希望创建一组任务,它们并行地执行工作,然后在进行下一个步骤之前等待,直至所有任务都完成(看起来有些像join())。它使得所有的并行任务都将在栅栏处列队,因此可以一致地向前移动。这非常像CountDownLatch,只是CountDownLatch是只触发一次的事件,而CyclicBarrier可以多次重用。
DelayQueue
这是一个无界的BlockingQueue,用于放置实现了Delayed接口的对象,其中的对象只能在其到期时才能从队列中取走。这种队列是有序的,即队头对象的延迟到期的时间最长。如果没有任何延迟到期,那么就不会有任何头元素,并且poll()将返回null(正因为这样,你不能将null放置到这种队列中)。
PriorityBlockingQueue
这是一个很基础的优先级队列,它具有可阻塞的读取操作。其中在优先级队列中的对象是按照优先级顺序从队列中出现的任务。
Semaphore
正常的锁(来自concurrent.lock或内建的synchronized锁)在任何时刻都只允许一个任务访问一项资源,而计数信号量允许n个任务同时访问这个资源。你还可以将信号量看作是在向外分发使用资源的“许可证”,尽管实际上没有使用任何许可证对象。
Exchanger
Exchanger是在两个任务之间交换对象的栅栏。当这些任务进入栅栏时,它们各自拥有一个对象,当它们离开时,它们都拥有之前由对象持有的对象。Exchanger的典型应用场景是:一个任务在创建对象,这些对象的生产代价很高昂,而另一个任务在消费这些对象。通过这种方式,可以有更多的对象在被创建的同时被消费。
性能调优
比较互斥技术
使用Lock通常会比使用synchronized要高效许多,而且synchronized的开销看起来变化范围太大,而Lock相对比较一致。这是否意味着你永远都不应该使用synchronized关键字呢?这里有两个因素需要考虑:
首先,我们应该编写非常之小的互斥方法的方法体。通常,这是一个很好的习惯——只互斥那些你绝对必须互斥的部分。但是在实际中,被互斥部分可能会比想象中的大许多,因此在这些方法体中花费的时间的百分比可能会明显大于进入和退出互斥的开销,这样也就湮灭了提高互斥速度带来的所有好处。当然,唯一了解这一点的方式是——当你在对性能调优时,应该立即——尝试各种不同的方法并观察它们造成的影响。
其次,synchronized关键字所产生的代码,与Lock所需的“加锁—try/finally—解锁”惯用法所产生的代码相比,可读性提高了很多。在编程时,与其他人交流相对于与计算机交流而言,要重要得多,因此代码的可读性至关重要。因此以synchronized关键字入手,只有在性能调优时才替换为Lock对象这种做法,是具有实际意义的。
最后,当你在自己的并发程序中可以使用Atomic类时,这肯定非常好,但是要意识到,Atomic对象只有在非常简单的情况下才有用,这些情况通常包括你只有一个要被修改的Atomic对象,并且这个对象独立于其他所有的对象。更安全的做法是:以更加传统的互斥方式入手,只有在性能方面的需求能够明确指示时再替换为Atomic。
免锁容器
容器是所有编程中的基础工具,这其中自然也包括并发编程。在Java 1.2中,新的容器类席是不同步的,并且Collections类提供了各种static的同步的装饰方法,从而来同步不同类型的容器。尽管这是一种改进,因为它使你可以选择在你的容器中是否要使用同步,但是这种开销仍旧是基于synchronized加锁机制的。Java SE5特别添加了新的容器,通过使用更灵巧的技术来消除加锁,从而提高线程安全的性能。
这些免锁容器背后的通用策略是:对容器的修改可以与读取操作同时发生,只要读取者只能看到完成修改的结果即可。修改是在容器数据结构的某个部分的一个单独的副本(有时是整个数据结构的副本)上执行的,并且这个副本在修改过程中是不可视的。只有当修改完成时,被修改的结构才会自动地与主数据结构进行交换,之后读取者就可以看到这个修改了。
在CopyOnWriteArrayList中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时读取操作可以安全地执行。当修改完成时,一个原子性的操作将把新的数组换入,使得新的读取操作可以看到这个新的修改。CopyOnWriteArrayList的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出ConcurrentModificationException,因此你不必编写特殊的代码去防范这种异常,就像你以前必须作的那样。
CopyOnWriteArmySet将使用CopyOnWriteArrayList来实现其免锁行为。
ConcurrentHashMap和ConcurrentLinkedQueue使用了类似的技术,允许并发的读取和写入,但是容器中只有部分内容而不是整个容器可以被复制和修改。然而任何修改在完成之前,读取者仍旧不能看到它们。ConcurrentHashMap不会抛出ConcurrentModificationException异常。
乐观加锁
尽管Atomic对象将执行像decrementAndGet()这样的原子性操作,但是某些Atomic类还允许你执行所谓的“乐观加锁”。这意味着当你执行某项计算时,实际上没有使用互斥,但是在这项计算完成,并且你准各更新这个Atomic对象时,你需要使用一个称为compareAndSet()的方法。你将旧值和新值一起提交给这个方法,如果旧值与它在Atomic对象中发现的值不一致,那么这个操作就失败——这意味着某个其他的任务已经于此操作执行期同修改了这个对象。记住,我们在正常情况下将使用互斥(synchronized或Lock)来防止多个任务同时修改一个对象,但是这里我们是“乐观的”因为我们保持数据为未锁定状态,并希望没有任何其他任务插入修改它。所有这些又都是以性能的名义执行的——通过使用Atomic来替代synchronized或Lock,可以获得性能上的好处。
如果compareAndSet()操作失败会发生什么?这正是棘手的地方,也是你在应用这项技术时的受限之处,即只能针对能够吻合这些需求的问题。如果compareAndSet()失败,那么就必须决定做些什么,这是一个非常重要的问题,因为如果不能执行某些恢复操作,那么你就不能使用这项技术,从而必须使用传统的互斥。你可能会重试这个操作,如果在第二次成功那么万事大吉,或者可能会忽略这次失败,直接结束。
ReadWriteLock
ReadWriteLock对向数据结构相对不频繁地写入,但是有多个任务要经常读取这个数据结构的这类情况进行了优化。ReadWriteLock使得你可以同时有多个读取者,只要它们都不试图写入即可。如果写锁已经被其他任务持有,那么任何读取者都不能访问,直至这个写锁被释放为止。
ReadWriteLock是否能够提高程序的性能是完全不可确定的,它取决于诸如数据被读取的频率与被修改的频率相比较的结果,读取和写入操作的时间(锁将更复杂,因此短操作并不能带来好处),有多少线程竟争以及是否在多处理机器上运行等因素。最终唯一可以了解ReadWriteLock是否能够给你的程序带来好处的方式就是用试验来证明。
活动对象
Java中的线程机制看起来非常复杂并难以正确使用。另外,它好像还有点达不到预期效果的味道。尽管多个任务可以并行工作,但是你必须花很大的气力去实现防止这些任务彼此互相干涉的技术。
有一种可替换的方式被称为活动对象或**行动者*。之所以称这些对象是“活动的”,是因为每个对象都维护着它自己的工作器线程和消息队列,并且所有对这种对象的请求都将进入队列排队,任何时刻都只能运行其中的一个。因此有了活动对象,我们就可以串行化消息而不是方法,这意味着不再需要防备一个任务在其循环的中间被中断这种问题了。
当你向一个活动对象发送消息时,这条消息会转变为一个任务,该任务会被插入到这个对象的队列中,等待在以后的某个时刻运行。Java SE5的Future在实现这种模式时将派上用场。
为了能够在不经意间就可以防止线程之间的耦合,任何传递给活动对象方法调用的参数都必须是只读的其他活动对象,或者是不连接对象(我的术语),即没有连接任何其他任务的对象(这一点很难强制保障,因为没有任何语言支持它)。有了活动对象:
- 每个对象都可以拥有自己的工作器线程。
- 每个对象都将维护对它自己的域的全部控制权(这比普通的类要更严苛一些,普通的类只是拥有防护它们的域的选择权)。
- 所有在活动对象之间的通信都将以在这些对象之间的消息形式发生。
- 活动对象之间的所有消息都要排队。
这些结果很吸引人。由于从一个活动对象到另一个活动对象的消息只能被排队时的延迟所阻塞,并且因为这个延迟总是非常短且独立于任何其他对象的,所以发送消息实际上是不可阻塞的(最坏情况也只是很短的延迟)。由于一个活动对象系统只是经由消息来通信,所以两个对象在竟争调用另一个对象上的方法时,是不会被阻塞的,而这意味着不会发生死锁。这是一种巨大的进步。因为在活动对象中的工作器线程在任何时刻只执行一个消息,所以不存在任何资源竞争,而你也不必操心应该如何同步方法。同步仍旧会发生,但是它通过将方法调用排队,使得任何时刻都只能发生一个调用,从而将同步控制在消息级別上发生。
遗憾的是,如果没有直接的编译器支持,这种编码方式实在是太过于麻烦了。但是,这在活动对象和行动者领域或者更有趣的被称为基于代理的编程领域,确实产生了进步。代理实际上就是活动对象,但是代理系统还支持跨网络和机器的透明性。如果代理编程最终成为面向对象编程的继任者,我一点也不会觉得惊讶,因为它把对象和相对容易的并发解决方案结合了起来。
总结
现在我们已经了解了Java线程进行并发程序设计的基础知识。以使你明白什么时候应该使用并发、什么时候应该避免使用并发,这是非常关键的。
使用它的原因主要是:
- 要处理很多任务,它们交织在一起,应用并发能够更有效地使用计算机(包括在多个CPU上透明地分配任务的能力)。
- 要能够更好地组织代码。
- 要更便于用户使用。
均衡资源的经典案例是在等待输入/输出时使用CPU。
线程的一个额外好处是它们提供了轻量级的执行上下文切换(大约100条指令),而不是重量级的进程上下文切换(要上千条指令)。因为一个给定进程内的所有线程共享相同的内存空间,轻量级的上下文切换只是改变了程序的执行序列和局部变量。进程切换(重量级的上下文切换)必须改变所有内存空间。
多线程的主要缺陷有:
- 等待共享资源的时候性能降低。
- 需要处理线程的额外CPU花费。
- 槽糕的程序设计导致不必要的复杂度。
- 有可能产生一些病态行为,如饿死、竞争、死锁和活锁(多个运行各自任务的线程使得整体无法完成)。
- 不同平台导致的不一致性。比如我在编写这些案例时发现,竞争条件在某些机器上很快出现。但在别的机器上根本不出现。如果你在后一种机器上做开发,那么当你发布程序的时候就要大吃一惊了。
因为多个线程可能共享一个资源,比如一个对象的内存,而且你必须确定多个线程不会同时读取和改变这个资源,这就是线程产生的最大难题。这需要明智地使用可用的加锁机制(例如synchronized关键字),它们仅仅是个工具,同时它们会引入潜在的死锁条件,所以要对它们有透彻的理解。
- 本文来源《Java编程思想(第四版)》