13: 并发编程
面向对象使我们能将程序划分成相互独立的模块。但是你时常还会碰到,不但要把程序分解开来,而且还要让它的各个部分都能独立运行的问题。
这种能独立运行的子任务就是线程(thread)。编程的时候,你可以认为线程都是能独立运行的,有自己CPU的子任务。实际上,是一些底层机制在为你分割CPU的时间,只是你不知道罢了。这种做法能简化多线程的编程。
进程(process)是一种有专属地址空间的"自含式(self-contained)"程序。通过在不同的任务之间定时切换CPU,多任务(multitasking)操作系统营造出一种同一个时点可以有多个进程(程序)在同时运行的效果。线程是进程内部的独立的,有序的指令流。由此,一个进程能包含多个并发执行的线程。
多线程的用途很广,但归纳起来不外乎,程序的某一部分正在等一个事件或资源,而你又不想让它把整个程序都给阻塞了。因此你可以创建一个与该事件或资源相关的线程,让它与主程序分开来运行。
学习并发编程就像是去探访一个新的世界,同时学习一种新的编程语言,最起码也得接受一套新的理念。随着绝大多数的微电脑操作系统提供了多线程支持,编程语言和类库也做了相应的扩展。总而言之,多线程编程:
看上去不但神秘,而且还要求你改变编程的观念各种语言对多线程的支持大同小异,所以理解线程就等于掌握了一种通用语言
理解并发编程的难度不亚于理解多态性。多线程看着容易其实很难。
动机
并发编程的一个最主要的用途就是创建反应灵敏的用户界面。试想有这么一个程序,由于要进行大量的CPU密集的运算,它完全忽略了用户输入,以致于变得非常迟钝了。要解决这种问题,关键在于,程序在进行运算的同时,还要时不时地将控制权交还给用户界面,这样才能对用户的操作做出及时的响应。假设有一个"quit"按钮,你总不会希望每写一段代码就做一次轮询的吧,你要的是"quit"能及时响应用户的操作,就像你在定时检查一样。
常规的方法是不可能在运行指令的同时还把控制权交给其他程序的。这听上去简直就是在天方夜谭,就好像CPU能同时出现在两个地方,但是多线程所营造的正是这个效果。
并发编程还能用来优化吞吐率。
如果是多处理器的系统,线程还会被分到多个处理器上。
有一点要记住,那就是多线程程序也必须能运行在单CPU系统上。
多线程最值得称道的还是它的底层抽象,即代码无需知道它是运行在单CPU还是多CPU的系统上。多任务与多线程是充分利用多处理器系统的好办法。
多线程能令你设计出更为松散耦合(more loosely-coupled)的应用程序。
基本线程
要想创建线程,最简单的办法就是继承java.lang.Thread。这个类已经为线程的创建和运行做了必要的配置。run()是Thread最重要的方法,要想让线程替你办事,你就必须覆写这个方法。由此可知,run( )所包含的就是要和程序里其它线程"同时"执行的代码。
main( )创建了Thread,但是却没去拿它的reference。如果是普通对象,这一点就足以让它成为垃圾,但Thread不会。Thread都会为它自己"注册",所以实际上reference还保留在某个地方。除非run( )退出,线程中止,否则垃圾回收器不能动它。
Yielding
如果你知道run( )已经告一段落了,你就可以给线程调度机制作一个暗示,告诉它你干完了,可以让别的线程来使用CPU了。这个暗示(注意,只是暗示——无法保证你用的这个JVM会不会对此作出反映)是用yield()形式给出的。
Java的线程调度机制是抢占式的(preemptive),也就是说,只要它认为有必要,它会随时中断当前线程,并且切换到其它线程。因此,如果I/O(通过main( )线程执行)占用的时间太长了,线程调度机制就会在run( )运行到yield( )之前把它给停下来。总之yield( )只会在很少的情况下起作用,而且不能用来进行很严肃的调校。
Sleeping
还有一种控制线程的办法,就是用sleep( )让它停一段以毫秒计的时间。
sleep( )一定要放在try域里,这是因为有可能会出现时间没到sleep( )就被中断的情况。如果有人拿到了线程的reference,并且调用了它的interrupt( ),这种事就发生了。(interrupt( )也会影响处于wait( )或join( )状态的线程,所以这两个方法也要放在try域里。)如果你准备用interrupt()唤醒线程,那最好是用wait( )而不是sleep( ),因为这两者的catch语句是不一样的。这里我们所遵循的原则是:"除非知道该怎样去处理异常,否则别去捕捉"。所以,我们把它当作RuntimeException往外面抛。
sleep( int x)不是控制线程执行的办法。它只是暂停线程。唯一能保证的事情是,它会休眠至少x毫秒,但是它恢复运行所花的时间可能更长,因为在休眠结束之后,线程调度机制还要花时间来接管。
如果你一定要控制线程的执行顺序,那最彻底的办法还是不用线程。你可以自己写一个协作程序,让它按一定顺序交换程序的运行权。
优先级
线程的优先级(priority)的作用是,告诉线程调度机制这个线程的重要程度的高低。虽然CPU伺候线程的顺序是非决定性的,但是如果有很多线程堵在那里等着启动,线程调度机制会倾向于首先启动优先级最高的线程。但这并不意味着低优先级的线程就没机会运行了(也就是说优先级不会造成死锁)。优先级低只表示运行的机会少而已。
可以用getPriority( )来读取线程的优先级,用setPriority( )随时修改线程的优先级。
虽然JDK提供了10级优先级,但是却不能很好地映射到很多操作系统上。比方说,Windows 2000平台上有7个等级还没固定下来,因此映射是不确定的(虽然Sun的Solaris有231个等级)。要想保持可移植性,唯一的办法就是,在调整优先级的时候,盯住MIN_PRIORITY, NORM_PRIORITY, 和MIN_PRORITY
守护线程
所谓"守护线程(daemon thread)"是指,只要程序还在运行,它就应该在后台提供某种公共服务的线程,但是守护线程不属于程序的核心部分。因此,当所有非守护线程都运行结束的时候,程序也结束了。相反,只要还有非守护线程在运行,程序就不能结束。比如,运行main( )的线程就属于非守护线程。
要想创建守护线程,必须在它启动之前就setDaemon( )。
可以用isDaemon( )来判断一个线程是不是守护线程。守护线程所创建的线程也自动是守护线程。请看下面这个例子:
连接线程
线程还能调用另一个线程的join( ),等那个线程结束之后再继续运行。如果线程调用了调用了另一个线程t的t.join( ),那么在线程t结束之前(判断标准是,t.isAlive( )等于false),主叫线程会被挂起。
调用join( )的时候可以给一个timeout参数,(可以是以毫秒,也可以是以纳秒作单位),这样如果目标线程在时限到期之后还没有结束,join( )就会强制返回了。
join( )调用可以被主叫线程的interrupt( )打断,所以join( )也要用try-catch括起来。
另外一种方式
迄今为止,你所看到的都是些很简单的例子。这些线程都继承了Thread,这种做法很很明智,对象只是作为线程,不做别的事情。但是类可能已经继承了别的类,这样它就不能再继承Thread了(Java不支持多重继承)。这时,你就要用Runnable接口了。Runnable的意思是,这个类实现了run( )方法,而Thread就是Runnable的。
Runnable接口只有一个方法,那就是run( ),但是如果你想对它做一些Thread对象才能做的事情(比方说toString()里面的getName( )),你就必须用Thread.currentThread()去获取其reference。Thread类有一个构造函数,可以拿Runnable和线程的名字作参数。
如果对象是Runnable的,那只说明它有run( )方法。这并没有什么特别的,也就是说,不会因为它是Runnable的,就使它具备了线程的先天功能,这一点同Thread的派生类不同的。所以你必须像例程那样,用Runnable对象去创建线程。把Runnable对象传给Thread的构造函数,创建一个独立的Thread对象。接着再调用那个线程的start( ),由它来进行初始化,然后线程的调度机制就能调用run( )了。
Runnable interface的好处在于,所有东西都属于同一个类;也就是说Runnable能让你创建基类和其它接口的mixin(混合类)。如果你要访问其它东西,直接用就是了,不用再一个一个地打交道。但是内部类也有这个功能,它也可以直接访问宿主类的成员。所以这个理由不足以说服我们放弃Thread的内部类而去使用Runnable的mixin。
Runnable的意思是,你要用代码——也就是run( )方法——来描述一个处理过程,而不是创建一个表示这个处理过程的对象。在如何理解线程方面,一直存在着争议。这取决于,你是将线程看作是对象还是处理过程。如果你认为它是一个处理过程,那么你就摆脱了"万物皆对象"的OO教条。但与此同时,如果你只想让这个处理过程掌管程序的某一部分,那你就没理由让整个类都成为Runnable的。有鉴于此,用内部类的形式将线程代码隐藏起来,通常是个更明智的选择。
除非迫不得已只能用Runnable,否则选Thread。
创建反应敏捷的用户界面
创建反映敏捷的用户界面是多线程的主要用途之一。
要想让程序反应灵敏,可以把运算放进run( )里面,然后让抢占式的调度程序来管理它,。
共享有限的资源
你可以认为单线程程序是一个在问题空间里游走的,一次只作一件事的孤独的个体。由于只有它一个,因此你无需考虑两个实体同时申请同一项资源的问题。这个问题有点像两个人同时把车停在一个车位上,同时穿一扇门,甚至是同时发言。
但是在多线程环境下,事情就不那么简单了,你必须考虑两个或两个以上线程同时申请同一资源的问题。必须杜绝资源访问方面的冲突。
用不正确的方法访问资源
试看下面这段例程。AlwaysEven会"保证",每次调用getValue( )的时候都会返回一个偶数。此外还有一个"Watcher"线程,它会不时地调用getValue( ),然后检查这个数是不是真的是偶数。这么做看上去有些多余,因为从代码上看,很明显这个值肯定是偶数。但是意外来了。下面是源代码:
有些时候,你不用关心别人是不是正在用那个资源。但是对多线程环境,你必须要有办法能防止两个线程同时访问同一个资源,至少别在关键的时候。
要防止这种冲突很简单,只要在线程运行的时候给资源上锁就行了。第一个访问这个资源的线程给它上锁,在它解锁之前,其它线程都不能访问这个资源,接着另一个线程给这个资源上锁然后再使用,如此循环。
测试框架
资源访问的冲突
Semaphore是一种用于线程间通信的标志对象。如果semaphore的值是零,则线程可以获得它所监视的资源,如果不是零,那么线程就无法获取这个资源,于是线程必须等。如果申请到了资源,线程会先对semaphore作递增,再使用这个资源。递增和递减是原子操作(atomicoperation,也就是说不会被打断的操作),由此semaphore就防止两个线程同时使用同一项资源。
如果semaphore能妥善的看护它所监视的资源,那么对象就永远也不会陷入不稳定状态。
解决共享资源的冲突
实际上所有的多线程架构都采用串行访问的方式来解决共享资源的冲突问题。也就是说,同一时刻只有一个线程可以访问这个共享资源。通常是这样实现的,在代码的前后设一条加锁和解锁的语句,这样同一时刻只有一个线程能够执行这段代码。由于锁定语句会产生"互斥(mutual exclusion)"的效果,因此这一机制通常也被称为mutex。
实际上等在外面的线程并没有排成一列,相反由于线程的调度机制是非决定性的,因此谁都不知道谁会是下一个。我们可以用yield( )和setPriority( )来给线程调度机制提一些建议,但究竟能起多大作用,还要看平台和JVM。
Java提供了内置的防止资源冲突的解决方案,这就是synchronized关键词。它的工作原理很像Semaphore类:当线程想执行由synchronized看护的代码时,它会先检查其semaphore是否可得,如果是,它会先获取semaphore,再执行代码,用完之后再释放semaphore。但是和我们写的Semaphore不同,synchronized是语言内置的,因此不会有什么问题。
通常共享资源就是一段内存,其表现形式就是对象,不过也可以是文件,I/O端口或打印机之类的。要想控制对共享资源的访问,先把它放进对象里面。然后把所有要访问这个资源的方法都作成synchronized的。只要有一个线程还在调用synchronized方法,其它线程就不允许访问所有的synchronized方法。
通常你会把类的成员设成private的,然后用方法进行访问,因此你可以把方法做成synchronized。下面就是synchronized方法的声明:
synchronized void f() { /*... */ }
synchronized void g(){ /*... */ }
每个对象都有一个锁(也称监控器monitor),它是对象生来就有的东西(因此你不必为此写任何代码)。当你调用synchronized方法时,这个对象就被锁住了。在方法返回并且解锁之前,谁也不能调用同一个对象的其它synchronized方法。就说上面那两个方法,如果你调用了f( ),那么在f( )返回并且解锁之前,你是不能调用同一个对象的g( )的。因此对任何一个特定的对象,所有的synchronized方法都会共享一个锁,而这个锁能防止两个或两个以上线程同时读写一块共用内存。
一个线程能多次获得对象的锁。也就是说,一个synchronized方法调用了另一个synchronized方法,而后者又调用了另一synchronized方法,诸如此类。JVM会跟踪对象被上锁的次数。如果对象没有被锁住,那么它的计数器应该为零。当线程第一次获得对象的锁时,计数器为一。线程每获一次对象的锁,计数器就加一。当然,只有第一次获得对象锁的线程才能多次获得锁。线程每退出一个synchronized方法,计数器就减一。等减到零了,对象也就解锁了,这时其它线程就可以使用这个对象了。
此外每个类还有一个锁(它属于类的Class对象),这样当类的synchronized static方法读取static数据的时候,就不会相互干扰了。
用Synchronized改写EvenGenerator
一定要记住:所有访问共享资源的方法都必须是synchronized的,否则程序肯定会出错。
原子操作
"原子操作(atomic operation)是不需要synchronized",这是Java多线程编程的老生常谈了。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行倒结束,中间不会有任何context switch(切换到另一个线程)。
通常所说的原子操作包括对非long和double型的primitive进行赋值,以及返回这两者之外的primitive。之所以要把它们排除在外是因为它们都比较大,而JVM的设计规范又没有要求读操作和赋值操作必须是原子操作(JVM可以试着去这么作,但并不保证)。不过如果你在long或double前面加了volatile,那么它就肯定是原子操作了。
如果你是从C++转过来的,或者有其它低级语言的经验,你会认为递增肯定是一个原子操作,因为它通常都是用CPU的指令来实现的。但是在JVM里,递增不是原子操作,它涉及到了读和写。所以即便是这么简单的一个操作,多线程也有机可乘。
如果你把变量定义为volatile的,那么编译器就不会做任何优化了。而优化的意思就是减少数据同步的读写。
最安全的原子操作只有读取和对primitive赋值这两种。但是原子操作也能访问正处于无效状态的对象,所以绝对不能想当然。我们一开头就讲了,long和double型的操作不一定时原子操作(虽然有些JVM能保证long和double也是原子操作,但是如果你真的用了这个特性的话,代码就没有可移植性了。)
最安全的做法还是遵循如下的方针:
如果你要synchronize类的一个方法,索性把所有的方法全都synchronize了。要判断,哪个方法该synchronize,哪个方法可以不synchronize,通常是很难的,而且也没什么把握。 删除synchronized的时候要绝对小心。通常这么做是为了性能,但是synchronized的开销在JDK1.3和1.4里已经大为降低了。此外,只有在用profiler分析过,确认synchronized确实是瓶颈的前提下才能这么作。
千万要牢记并发编程的最高法则:绝对不能想当然。
对象锁和synchronized关键词是Java内置的semaphore,因此没必要再去搞一套了。
关键段
有时你只需要防止多个线程同时访问方法中的某一部分,而不是整个方法。这种需要隔离的代码就被称为关键段(critical section)。创建关键段需要用到synchronized关键词。这里,synchronized的作用是,指明执行下列代码需获得哪个对象的锁。
synchronized(syncObject) {
// This code can be accessed
// by only one thread at a time
}
关键段又被称为"同步块(synchronized block)";线程在执段代码之前,必须先获得syncObject的锁。如果其它线程已经获得这个锁了,那么在它解锁之前,线程不能运行关键段中的代码。
同步分两种,代码的同步和方法的同步。相比同步整个方法,同步一段代码能显著增加其它线程获得这个对象的机会。
当然,最后还是要靠程序员:所有访问共享资源的代码都必须被包进同步段里。
线程的状态
线程的状态可归纳为以下四种:
New: 线程对象已经创建完毕,但尚未启动(start),因此还不能运行。Runnable: 处在这种状态下的线程,只要分时机制分配给它CPU周期,它就能运行。也就是说,具体到某个时点,它可能正在运行,也可能没有运行,但是轮到它运行的时候,谁都不能阻止它;它没有dead,也没有被阻塞。Dead: 要想中止线程,正常的做法是退出run( )。在Java 2以前,你也可以调用stop( ),不过现在不建议用这个办法了,因为它很可能会造成程序运行状态的不稳定。此外还有一个destroy( )(不过它还没有实现,或许将来也不会了,也就是说已经被放弃了)。Blocked: 就线程本身而言,它是可以运行的,但是有什么别的原因在阻止它运行。线程调度机制会直接跳过blocked的线程,根本不给它分配CPU的时间。除非它重新进入runnable状态,否则什么都干不了。
进入阻塞状态
如果线程被阻塞了,那肯定是出了什么问题。问题可能有以下几种:
你用sleep(milliseconds)方法叫线程休眠。在此期间,线程是不能运行的。你用wait( )方法把线程挂了起来。除非收到notify( )或notifyAll( )消息,否则线程无法重新进入runnable状态。这部分内容会在后面讲。线程在等I/O结束。线程要调用另一个对象的synchronized方法,但是还没有得到对象的锁。
或许你还在旧代码里看到过suspend( )和resume( ),不过Java 2已经放弃了这两个方法(因为很容易造成死锁),所以这里就不作介绍了。
线程间的协作
理解了线程会相互冲突以及该如何防止这种冲突之后,下一步就该学习怎样让线程协同工作了。要做到这一点,关键是要让线程能相互"协商(handshaking)"。而这个任务要由Object的wait( )和notify()来完成。
wait与notify
首先要强调,线程sleep( )的时候并不释放对象的锁,但是wait( )的时候却会释放对象的锁。也就是说在线程wait( )期间,别的线程可以调用它的synchronized方法。当线程调用了某个对象wait( )方法之后,它就中止运行并释放那个对象锁了。
Java有两种wait( )。第一种需要一个以毫秒记的时间作参数,它的意思和sleep( )一样,都是:"暂停一段时间。"区别在于:
wait( )会释放对象的锁。除了时间到了,wait( )还可以用notify( )或notifyAll( )来中止
第二种wait( )不需要任何参数;它的用途更广。线程调用了这种wait( )之后,会一直等下去,直到(有别的线程调用了这个对象的)notify( )或notifyAll( )。
和sleep()属于Thread不同,wait( ), notify( ), 和notifyAll( )是根Object的方法。虽然这样做法(把专为多线程服务的方法放到通用的根类里面)看上去有些奇怪,但却是必要的。因为它们所操控的是每个对象都会有的锁。所以结论就是,你可以在类的synchronized方法里调用wait( ),至于它继不继承Thread,实没实现Runnable已经无所谓了。实际上你也只能在synchronized方法里或synchronized段里调用wait( ),notify( )或notifyAll()(sleep( )则没有这个限制,因为它不对锁进行操作)。如果你在非synchronized方法里调用了这些方法,程序还是可以编译的,但是一运行就会出一个IllegalMonitorStateException。这个异常带着一个挺让人费解的"current thread not owner"消息。这个消息的意思是,如果线程想调用对象的wait( ), notify( ),或notifyAll()方法,必须先"拥有"(得到)这个对象的锁。
通常情况下,如果条件是由方法之外的其他力量所控制的(最常见的就是要由其他线程修改),那么你就应该用wait( )。wait( )能让你在等待世道改变的同时让线程休眠,当(其他线程调用了对象的)notify( )或notifyAll( )的时候,线程自会醒来,然后检查条件是不是改变了。所以说wait()提供了一种同步线程间的活动的方法。
用管道进行线程间的I/O操作
在很多情况下,线程也可以利用I/O来进行通信。多线程类库会提供一种"管道(pipes)"来实现线程间的I/O。对Java I/O类库而言,这个类就是PipedWriter(可以让线程往管道里写数据)和PipedReader(让另一个线程从这个管道里读数据)。你可以把它理解成"producer-consumer"问题的一个变型,而管道则提供了一个现成的解决方案。
注意,如果你没创建完对象就启动线程,那么管道在不同的平台上的行为就有可能会不一致。
更复杂的协同
这里只讲了最基本的协同方式(即通常籍由wait( ),notify()/notifyAll( )来实现的producer-consumer模式)。它已经能解决绝大多数的线程协同问题了,但是在高级的教科书里还有很多更复杂协同方式
死锁
由于线程能被阻塞,更由于synchronized方法能阻止其它线程访问本对象,因此有可能会出现如下这种情况:线程一在等线程二(释放某个对象),线程二又在等线程三,这样依次排下去直到有个线程在等线程一。这样就形成了一个环,每个线程都在等对方释放资源,而它们谁都不能运行。这就是所谓的死锁(deadlock)。
如果程序一运行就死锁,那倒也简单了。你可以马上着手解决这个问题。但真正的麻烦在于,程序看上去能正常运行,但是却潜伏着会引起死锁的隐患。或许你认为这里根本就不可能会有死锁,而bug也就这样潜伏下来了。直到有一天,让某个用户给撞上了(而且这种bug还很可能是不可重复的)。所以对并发编程来说,防止死锁是设计阶段的一个重要任务。
下面我们来看看由Dijkstra发现的经典的死锁场景:哲学家吃饭问题。原版的故事里有五个哲学家(不过我们的例程里允许有任意数量)。这些哲学家们只做两件事,思考和吃饭。他们思考的时候,不需要任何共享资源,但是吃饭的时候,就必须坐到餐桌旁。餐桌上的餐具是有限的。原版的故事里,餐具是叉子,吃饭的时候要用两把叉子把面条从碗里捞出来。但是很明显,把叉子换成筷子会更合理,所以:一个哲学家需要两根筷子才能吃饭。
现在引入问题的关键:这些哲学家很穷,只买得起五根筷子。他们坐成一圈,两个人的中间放一根筷子。哲学家吃饭的时候必须同时得到左手边和右手边的筷子。如果他身边的任何一位正在使用筷子,那他只有等着。
这个问题之所以有趣就在于,它演示了这么一个程序,它看上去似乎能正常运行,但是却容易引起死锁。
在告诉你如何修补这个问题之前,先了解一下只有在下述四个条件同时满足的情况下,死锁才会发生:
互斥:也许线程会用到很多资源,但其中至少要有一项是不能共享的。至少要有一个进程会在占用一项资源的同时还在等另一项正被其它进程所占用的资源。(调度系统或其他进程)不能从进程里抢资源。所有进程都必须正常的释放资源。必需要有等待的环。一个进程在一个已经被另一进程抢占了的资源,而那个进程又在等另一个被第三个进程抢占了的资源,以此类推,直到有个进程正在等被第一个进程抢占了的资源,这样就形成了瘫痪性的阻塞了。
由于死锁要同时满足这四个条件,所用只要去掉其中一个就能防止死锁。
Java语言没有提供任何能预防死锁的机制,所以只能靠你来设计了。
停止线程的正确方法
为了降低死锁的发生几率,Java 2放弃了Thread类stop(),suspend( )和resume( )方法。
之所以要放弃stop( )是因为,它不会释放对象的锁,因此如果对象正处于无效状态(也就是被破坏了),其它线程就可能会看到并且修改它了。这个问题的后果可能非常微秒,因此难以察觉。所以别再用stop( )了,相反你应该设置一个旗标(flag)来告诉线程什么时候该停止。
打断受阻的线程
有时线程受阻之后就不能再做轮询了,比如在等输入,这时你就不能像前面那样去查询旗标了。碰到这种情况,你可以用Thread.interrupt( )方法打断受阻的线程。
线程组
线程组是一个装线程的容器(collection)。用JoshuaBloch的话来讲,它的意义可以概括为:
"最好把线程组看成是一次不成功的实验,或者就当它根本不存在。"
线程组还剩一个小用途。如果组里的线程抛出一个没有被(异常处理程序)捕捉到的异常,就会启动ThreadGroup.uncaughtException()。而它会在标准错误流上打印出栈的轨迹。要想修改这个行为,你必须覆写这个方法。
总结
要懂得什么时候用什么时候用并发,什么时候不用并发,这点非常重要。使用并发的主要理由包括:要管理大量的任务,让它们同时运行以提高系统的利用率(包括在多CPU上透明的分配负载);更合理的组织代码;以及方便用户。平衡负载的一个经典案例是在等待I/O的同时做计算。方便用户的经典案例是在用户下载大文件的时候监控"stop"按钮。
线程还有一个额外的好处,那就是它提供了"轻型"(100个指令级的)运行环境(execution context)的切换,而进程环境(process context)的切换则是"重型"的(数千个指令)。由于所有线程会共享进程的内存空间,所以轻型的环境切换只会改变程序执行顺序和本地变量。而重型的进程环境切换则必须交换全部的内存空间。
多线程的主要缺点包括:
等待共享资源的时候,运行速度会慢下来。线程管理需要额外的CPU开销。如果设计得不不合理,程序会变得异常负责。会引发一些不正常的状态,像饥饿(starving),竞争(racing),死锁(deadlock),活锁(livelock)。不同平台上会有一些不一致。比如我在开发本书例程时发现,在有些平台下竞争很快就出现,但是换了台机器,它根本就不出现。如果你在后者搞开发,然后发布到前者,那可就惨了。
线程的难点在于多个线程会共享同一项资源——比如对象的内存——而你又必须确保同一时刻不会有两个或两个以上的线程去访问那项资源。这就需要合理地使用synchronized关键词了,但是用之前必须完全理解,否则它会悄悄地地把死锁了带进来。
此外线程的运用方面还有某种艺术。Java的设计思想是,让你能根据需要创建任意多的对象来解决问题,至少理论上如此。(对Java来说创建数以百万计的对象,比如工程方面的有限元分析,还不太现实。)但是你能创建的线程数量应该还是有一个上限的,因为到了这个数量,线程就僵掉了。这个临界点很难找,通常由OS和JVM决定;或许是一百以内,也可能是几千。不过通常你只需创建几个线程就能解决问题了,所以这还不算是什么限制;但是对于更为通用的设计,这就是一个限制了。
线程方面一个重要,但却不那么直观的结论。那就是,通常你可以在run( )的主循环里插上yield( ),然后让线程调度机制帮你加快程序的运行。这绝对是一种艺术,特别是当等待延长之后,性能却上升了。之所以会这样是因为,较短的延迟会使正在运行的线程还没准备好休眠就收到休眠结束的信号,这样为了能让线程干完工作之后再休眠,调度机制不得不先把它停下来再唤醒它。额外的运行环境的切换会导致运行速度的下降,而yield( )和sleep( )则可以防止这种多余的切换。要理解这个问题有多麻烦还真得好好想想。