java线程
Java语言的线程模型是此语言的一个最难另人满意的部分。它完全不适合实际复杂程序的要求,而且也完全不是面向对象的。尽管Java语言本身就支持线程编程是件好事,但是它对线程的语法和类包的支持太少,只能适用于极小型的应用环境。
关于Java线程编程的大多数书籍都长篇累牍地指出了Java线程模型的缺陷,并提供了解决这些问题的急救包(Band-Aid/邦迪创可贴)类库。我称这些类为急救包,是因为它们所能解决的问题本应是由Java语言本身语法所包含的。从长远来看,以语法而不是类库方法,将能产生更高效的代码。这是因为编译器和Java虚拟器(JVM)能一同优化程序代码,而这些优化对于类库中的代码是很难或无法实现的。
这里提出的建议是非常大胆的。有些人建议对Java语言规范(JLS)(请参阅参考资料)进行细微和少量的修改以解决当前模糊的JVM行为。
task(任务)的概念
Java线程模型的根本问题是它完全不是面向对象的。面向对象(OO)设计人员根本不按线程角度考虑问题;他们考虑的是同步信息异步信息(同步信息被立即处理--直到信息处理完成才返回消息句柄;异步信息收到后将在后台处理一段时间--而早在信息处理结束前就返回消息句柄)。Java编程语言中的Toolkit.getImage()方法就是异步信息的一个好例子。getImage()的消息句柄将被立即返回,而不必等到整个图像被后台线程取回。
这是面向对象(OO)的处理方法。但是,如前所述,Java的线程模型是非面向对象的。一个Java编程语言线程实际上只是一个run()过程,它调用了其它的过程。在这里就根本没有对象、异步或同步信息以及其它概念。
对于此问题,在书中深入讨论过的一个解决方法是,使用一个Active_object。active对象是可以接收异步请求的对象,它在接收到请求后的一段时间内以后台方式得以处理。在Java编程语言中,一个请求可被封装在一个对象中。例如,你可以把一个通过Runnable接口实现的实例传送给此active对象,该接口的run()方法封装了需要完成的工作。该runnable对象被此active对象排入到队列中,当轮到它执行时,active对象使用一个后台线程来执行它。
在一个active对象上运行的异步信息实际上是同步的,因为它们被一个单一的服务线程按顺序从队列中取出并执行。因此,使用一个active对象以一种更为过程化的模型可以消除大多数的同步问题。
在某种意义上,Java编程语言的整个Swing/AWT子系统是一个active对象。向一个Swing队列传送一条讯息的唯一安全的途径是,调用一个类似SwingUtilities.invokeLater()的方法,这样就在Swing事件队列上发送了一个runnable对象,当轮到它执行时,Swing事件处理线程将会处理它。
那么第一个建议是,向Java编程语言中加入一个task(任务)的概念,从而将active对象集成到语言中。(task的概念是从Intel的RMX操作系统和Ada编程语言借鉴过来的。大多数实时操作系统都支持类似的概念。)
一个任务有一个内置的active对象分发程序,并自动管理那些处理异步信息的全部机制。
定义一个任务和定义一个类基本相同,不同的只是需要在任务的方法前加一个asynchronous修饰符来指示active对象的分配程序在后台处理这些方法。请参考我的书中第九章的基于类方法,再看以下的file_io类,它使用了在《TamingJavaThreads》中所讨论的Active_object类来实现异步写操作:
所有的写请求都用一个dispatch()过程调用被放在active-object的输入队列中排队。在后台处理这些异步信息时出现的任何异常(exception)都由Exception_handler对象处理,此Exception_handler对象被传送到File_io_task的构造函数中。您要写内容到文件时,代码如下:
这种基于类的处理方法,其主要问题是太复杂了--对于一个这样简单的操作,代码太杂了。向Java语言引入$task和$asynchronous关键字后,就可以按下面这样重写以前的代码:
注意,异步方法并没有指定返回值,因为其句柄将被立即返回,而不用等到请求的操作处理完成后。所以,此时没有合理的返回值。对于派生出的模型,$task关键字和class一样同效:$task可以实现接口、继承类和继承的其它任务。标有asynchronous关键字的方法由$task在后台处理。其它的方法将同步运行,就像在类中一样。
$task关键字可以用一个可选的$error从句修饰(如上所示),它表明对任何无法被异步方法本身捕捉的异常将有一个缺省的处理程序。我使用$来代表被抛出的异常对象。如果没有指定$error从句,就将打印出一个合理的出错信息(很可能是堆栈跟踪信息)。
注意,为确保线程安全,异步方法的参数必须是不变(immutable)的。运行时系统应通过相关语义来保证这种不变性(简单的复制通常是不够的)。
所有的task对象必须支持一些伪信息(pseudo-message),例如:
除了常用的修饰符(public等),task关键字还应接受一个$pooled(n)修饰符,它导致task使用一个线程池,而不是使用单个线程来运行异步请求。n指定了所需线程池的大小;必要时,此线程池可以增加,但是当不再需要线程时,它应该缩到原来的大小。伪域(pseudo-field)$pool_size返回在$pooled(n)中指定的原始n参数值。
在《TamingJavaThreads》的第八章中,给出了一个服务器端的socket处理程序,作为线程池的例子。它是关于使用线程池的任务的一个好例子。其基本思路是产生一个独立对象,它的任务是监控一个服务器端的socket。每当一个客户机连接到服务器时,服务器端的对象会从池中抓取一个预先创建的睡眠线程,并把此线程设置为服务于客户端连接。socket服务器会产出一个额外的客户服务线程,但是当连接关闭时,这些额外的线程将被删除。实现socket服务器的推荐语法如下:
Socket_server对象使用一个独立的后台线程处理异步的listen()请求,它封装socket的“接受”循环。当每个客户端连接时,listen()请求一个Client_handler通过调用handle()来处理请求。每个handle()请求在它们自己的线程中执行(因为这是一个$pooled任务)。
注意,每个传送到$pooled$task的异步消息实际上都使用它们自己的线程来处理。典型情况下,由于一个$pooled$task用于实现一个自主操作;所以对于解决与访问状态变量有关的潜在的同步问题,最好的解决方法是在$asynchronous方法中使用this是指向的对象的一个独有副本。这就是说,当向一个$pooled$task发送一个异步请求时,将执行一个clone()操作,并且此方法的this指针会指向此克隆对象。线程之间的通信可通过对static区的同步访问实现。
改进synchronized
虽然在多数情况下,$task消除了同步操作的要求,但是不是所有的多线程系统都用任务来实现。所以,还需要改进现有的线程模块。synchronized关键字有下列缺点:无法指定一个超时值。无法中断一个正在等待请求锁的线程。无法安全地请求多个锁。(多个锁只能以依次序获得。)
解决这些问题的办法是:扩展synchronized的语法,使它支持多个参数和能接受一个超时说明(在下面的括弧中指定)。下面是我希望的语法:
synchronized(x&&y&&z)获得x、y和z对象的锁。
synchronized(x||y||z)获得x、y或z对象的锁。
synchronized((x&&y)||z)对于前面代码的一些扩展。
synchronized(...)[1000]设置1秒超时以获得一个锁。
synchronized[1000]f(){...}在进入f()函数时获得this的锁,但可有1秒超时。
TimeoutException是RuntimeException派生类,它在等待超时后即被抛出。
超时是需要的,但还不足以使代码强壮。您还需要具备从外部中止请求锁等待的能力。所以,当向一个等待锁的线程传送一个interrupt()方法后,此方法应抛出一个SynchronizationException对象,并中断等待的线程。这个异常应是RuntimeException的一个派生类,这样不必特别处理它。
对synchronized语法这些推荐的更改方法的主要问题是,它们需要在二进制代码级上修改。而目前这些代码使用进入监控(enter-monitor)和退出监控(exit-monitor)指令来实现synchronized。而这些指令没有参数,所以需要扩展二进制代码的定义以支持多个锁定请求。但是这种修改不会比在Java2中修改Java虚拟机的更轻松,但它是向下兼容现存的Java代码。
另一个可解决的问题是最常见的死锁情况,在这种情况下,两个线程都在等待对方完成某个操作。设想下面的一个例子(假设的):
设想一个线程调用a(),但在获得 lock1之后在获得lock2之前被剥夺运行权。第二个线程进入运行,调用b(),获得了lock2,但是由于第一个线程占用lock1,所以它无法获得lock1,所以它随后处于等待状态。此时第一个线程被唤醒,它试图获得lock2,但是由于被第二个线程占据,所以无法获得。此时出现死锁。下面的synchronize-on-multiple-objects的语法可解决这个问题:
编译器(或虚拟机)会重新排列请求锁的顺序,使lock1总是被首先获得,这就消除了死锁。
但是,这种方法对多线程不一定总成功,所以得提供一些方法来自动打破死锁。一个简单的办法就是在等待第二个锁时常释放已获得的锁。这就是说,应采取如下的等待方式,而不是永远等待:
如果等待锁的每个程序使用不同的超时值,就可打破死锁而其中一个线程就可运行。我建议用以下的语法来取代前面的代码:
synchronized语句将永远等待,但是它时常会放弃已获得的锁以打破潜在的死锁可能。在理想情况下,每个重复等待的超时值比前一个相差一随机值。
改进wait()和notify()
wait()/notify()系统也有一些问题:无法检测wait()是正常返回还是因超时返回。无法使用传统条件变量来实现处于一个“信号”(signaled)状态。太容易发生嵌套的监控(monitor)锁定。
超时检测问题可以通过重新定义wait()使它返回一个boolean变量(而不是void)来解决。一个true返回值指示一个正常返回,而false指示因超时返回。
基于状态的条件变量的概念是很重要的。如果此变量被设置成false状态,那么等待的线程将要被阻断,直到此变量进入true状态;任何等待true的条件变量的等待线程会被自动释放。(在这种情况下,wait()调用不会发生阻断。)。通过如下扩展notify()的语法,可以支持这个功能:
嵌套监控锁定问题非常麻烦,我并没有简单的解决办法。嵌套监控锁定是一种死锁形式,当某个锁的占有线程在挂起其自身之前不释放锁时,会发生这种嵌套监控封锁。下面是此问题的一个例子(还是假设的),但是实际的例子是非常多的:
此例中,在get()和put()操作中涉及两个锁:一个在Stack对象上,另一个在LinkedList对象上。下面我们考虑当一个线程试图调用一个空栈的pop()操作时的情况。此线程获得这两个锁,然后调用wait()释放Stack对象上的锁,但是没有释放在list上的锁。如果此时第二个线程试图向堆栈中压入一个对象,它会在synchronized(list)语句上永远挂起,而且永远不会被允许压入一个对象。由于第一个线程等待的是一个非空栈,这样就会发生死锁。这就是说,第一个线程永远无法从wait()返回,因为由于它占据着锁,而导致第二个线程永远无法运行到notify()语句。
在这个例子中,有很多明显的办法来解决问题:例如,对任何的方法都使用同步。但是在真实世界中,解决方法通常不是这么简单。
一个可行的方法是,在wait()中按照反顺序释放当前线程获取的所有锁,然后当等待条件满足后,重新按原始获取顺序取得它们。但是,我能想象出利用这种方式的代码对于人们来说简直无法理解,所以我认为它不是一个真正可行的方法。如果您有好的方法,请给我发e-mail。
我也希望能等到下述复杂条件被实现的一天。例如:
其中a、b和c是任意对象。
修改Thread类
同时支持抢占式和协作式线程的能力在某些服务器应用程序中是基本要求,尤其是在想使系统达到最高性能的情况下。我认为Java编程语言在简化线程模型上走得太远了,并且Java编程语言应支持Posix/Solaris的“绿色(green)线程”和“轻便(lightweight)进程”概念(在“(TamingJavaThreads”第一章中讨论)。这就是说,有些Java虚拟机的实现(例如在NT上的Java虚拟机)应在其内部仿真协作式进程,其它Java虚拟机应仿真抢占式线程。而且向Java虚拟机加入这些扩展是很容易的。
一个Java的Thread应始终是抢占式的。这就是说,一个Java编程语言的线程应像Solaris的轻便进程一样工作。Runnable接口可以用于定义一个Solaris式的“绿色线程”,此线程必需能把控制权转给运行在相同轻便进程中的其它绿色线程。
例如,目前的语法:
能有效地为Runnable对象产生一个绿色线程,并把它绑定到由Thread对象代表的轻便进程中。这种实现对于现有代码是透明的,因为它的有效性和现有的完全一样。
把Runnable对象想成为绿色线程,使用这种方法,只需向Thread的构造函数传递几个Runnable对象,就可以扩展Java编程语言的现有语法,以支持在一个单一轻便线程有多个绿色线程。(绿色线程之间可以相互协作,但是它们可被运行在其它轻便进程(Thread对象)上的绿色进程(Runnable对象)抢占。)。例如,下面的代码会为每个runnable对象创建一个绿色线程,这些绿色线程会共享由Thread对象代表的轻便进程。
现有的覆盖(override)Thread对象并实现run()的习惯继续有效,但是它应映射到一个被绑定到一轻便进程的绿色线程。(在Thread()类中的缺省run()方法会在内部有效地创建第二个Runnable对象。)
线程间的协作
应在语言中加入更多的功能以支持线程间的相互通信。目前,PipedInputStream和PipedOutputStream类可用于这个目的。但是对于大多数应用程序,它们太弱了。我建议向Thread类加入下列函数:增加一个wait_for_start()方法,它通常处于阻塞状态,直到一个线程的run()方法启动。(如果等待的线程在调用run之前被释放,这没有什么问题)。用这种方法,一个线程可以创建一个或多个辅助线程,并保证在创建线程继续执行操作之前,这些辅助线程会处于运行状态。(向Object类)增加$send(Objecto)和Object=$receive()方法,它们将使用一个内部阻断队列在线程之间传送对象。阻断队列应作为第一个$send()调用的副产品被自动创建。$send()调用会把对象加入队列。$receive()调用通常处于阻塞状态,直到有一个对象被加入队列,然后它返回此对象。这种方法中的变量应支持设定入队和出队的操作超时能力:$send(Objecto,longtimeout)和$receive(longtimeout)。
对于读写锁的内部支持
读写锁的概念应内置到Java编程语言中。读写器锁在“TamingJavaThreads”(和其它地方)中有详细讨论,概括地说:一个读写锁支持多个线程同时访问一个对象,但是在同一时刻只有一个线程可以修改此对象,并且在访问进行时不能修改。读写锁的语法可以借用synchronized关键字:
对于一个对象,应该只有在$writing块中没有线程时,才支持多个线程进入$reading块。在进行读操作时,一个试图进入$writing块的线程会被阻断,直到读线程退出$reading块。当有其它线程处于$writing块时,试图进入$reading或$writing块的线程会被阻断,直到此写线程退出$writing块。
如果读和写线程都在等待,缺省情况下,读线程会首先进行。但是,可以使用$writer_priority属性修改类的定义来改变这种缺省方式。如:
访问部分创建的对象应是非法的
当前情况下,JLS允许访问部分创建的对象。例如,在一个构造函数中创建的线程可以访问正被创建的对象,既使此对象没有完全被创建。下面代码的结果无法确定:
设置x为-1的线程可以和设置x为0的线程同时进行。所以,此时x的值无法预测。
对此问题的一个解决方法是,在构造函数没有返回之前,对于在此构造函数中创建的线程,既使它的优先级比调用new的线程高,也要禁止运行它的run()方法。
这就是说,在构造函数返回之前,start()请求必须被推迟。
另外,Java编程语言应可允许构造函数的同步。换句话说,下面的代码(在当前情况下是非法的)会象预期的那样工作:
我认为第一种方法比第二种更简洁,但实现起来更为困难。
volatile关键字应象预期的那样工作
JLS要求保留对于volatile操作的请求。大多数Java虚拟机都简单地忽略了这部分内容,这是不应该的。在多处理器的情况下,许多主机都出现了这种问题,但是它本应由JLS加以解决的。如果您对这方面感兴趣,马里兰大学的BillPugh正在致力于这项工作(请参阅参考资料)。
访问的问题
如果缺少良好的访问控制,会使线程编程非常困难。大多数情况下,如果能保证线程只从同步子系统中调用,不必考虑线程安全(threadsafe)问题。我建议对Java编程语言的访问权限概念做如下限制;应精确使用package关键字来限制包访问权。我认为当缺省行为的存在是任何一种计算机语言的一个瑕疵,我对现在存在这种缺省权限感到很迷惑(而且这种缺省是“包(package)”级别的而不是“私有(private)”)。在其它方面,Java编程语言都不提供等同的缺省关键字。虽然使用显式的package的限定词会破坏现有代码,但是它将使代码的可读性更强,并能消除整个类的潜在错误(例如,如果访问权是由于错误被忽略,而不是被故意忽略)。重新引入privateprotected,它的功能应和现在的protected一样,但是不应允许包级别的访问。允许privateprivate语法指定“实现的访问”对于所有外部对象是私有的,甚至是当前对象是的同一个类的。对于“.”左边的唯一引用(隐式或显式)应是this。扩展public的语法,以授权它可制定特定类的访问。例如,下面的代码应允许Fred类的对象可调用some_method(),但是对其它类的对象,这个方法应是私有的。
这种建议不同于C++的"friend"机制。在"friend"机制中,它授权一个类访问另一个类的所有私有部分。在这里,我建议对有限的方法集合进行严格控制的访问。用这种方法,一个类可以为另一个类定义一个接口,而这个接口对系统的其余类是不可见的。一个明显的变化是:
除非域引用的是真正不变(immutable)的对象或staticfinal基本类型,否则所有域的定义应是private。对于一个类中域的直接访问违反了OO设计的两个基本规则:抽象和封装。从线程的观点来看,允许直接访问域只使对它进行非同步访问更容易一些。
增加$property关键字。带有此关键字的对象可被一个“bean盒”应用程序访问,这个程序使用在Class类中定义的反射操作(introspection)API,否则与privateprivate同效。$property属性可用在域和方法,这样现有的JavaBeangetter/setter方法可以很容易地被定义为属性。
不变性(immutability)
由于对不变对象的访问不需要同步,所以在多线程条件下,不变的概念(一个对象的值在创建后不可更改)是无价的。Java编程言语中,对于不变性的实现不够严格,有两个原因:对于一个不变对象,在其被未完全创建之前,可以对它进行访问。这种访问对于某些域可以产生不正确的值。对于恒定(类的所有域都是final)的定义太松散。对于由final引用指定的对象,虽然引用本身不能改变,但是对象本身可以改变状态。
第一个问题可以解决,不允许线程在构造函数中开始执行(或者在构造函数返回之前不能执行开始请求)。
对于第二个问题,通过限定final修饰符指向恒定对象,可以解决此问题。这就是说,对于一个对象,只有所有的域是final,并且所有引用的对象的域也都是final,此对象才真正是恒定的。为了不打破现有代码,这个定义可以使用编译器加强,即只有一个类被显式标为不变时,此类才是不变类。方法如下:
有了$immutable修饰符后,在域定义中的final修饰符是可选的。
最后,当使用内部类(innerclass)后,在Java编译器中的一个错误使它无法可靠地创建不变对象。当一个类有重要的内部类时(我的代码常有),编译器经常不正确地显示下列错误信息:
既使空的final在每个构造函数中都有初始化,还是会出现这个错误信息。自从在1.1版本中引入内部类后,编译器中一直有这个错误。在此版本中(三年以后),这个错误依然存在。现在,该是改正这个错误的时候了。
对于类级域的实例级访问
除了访问权限外,还有一个问题,即类级(静态)方法和实例(非静态)方法都能直接访问类级(静态)域。这种访问是非常危险的,因为实例方法的同步不会获取类级的锁,所以一个synchronizedstatic方法和一个synchronized方法还是能同时访问类的域。改正此问题的一个明显的方法是,要求在实例方法中只有使用static访问方法才能访问非不变类的static域。当然,这种要求需要编译器和运行时间检查。在这种规定下,下面的代码是非法的:
由于f()和g()可以并行运行,所以它们能同时改变x的值(产生不定的结果)。请记住,这里有两个锁:static方法要求属于Class对象的锁,而非静态方法要求属于此类实例的锁。当从实例方法中访问非不变static域时,编译器应要求满足下面两个结构中的任意一个:
或则,编译器应获得读/写锁的使用:
另外一种方法是(这也是一种理想的方法)--编译器应自动使用一个读/写锁来同步访问非不变static域,这样,程序员就不必担心这个问题。
后台线程的突然结束
当所有的非后台线程终止后,后台线程都被突然结束。当后台线程创建了一些全局资源(例如一个数据库连接或一个临时文件),而后台线程结束时这些资源没有被关闭或删除就会导致问题。
对于这个问题,我建议制定规则,使Java虚拟机在下列情况下不关闭应用程序:有任何非后台线程正在运行,或者:有任何后台线程正在执行一个synchronized方法或synchronized代码块。
后台线程在它执行完synchronized块或synchronized方法后可被立即关闭。
重新引入stop()、suspend()和resume()关键字
由于实用原因这也许不可行,但是我希望不要废除stop()(在Thread和ThreadGroup中)。但是,我会改变stop()的语义,使得调用它时不会破坏已有代码。但是,关于stop()的问题,请记住,当线程终止后,stop()将释放所有锁,这样可能潜在地使正在此对象上工作的线程进入一种不稳定(局部修改)的状态。由于停止的线程已释放它在此对象上的所有锁,所以这些对象无法再被访问。
对于这个问题,可以重新定义stop()的行为,使线程只有在不占有任何锁时才立即终止。如果它占据着锁,我建议在此线程释放最后一个锁后才终止它。可以使用一个和抛出异常相似的机制来实现此行为。被停止线程应设置一个标志,并且当退出所有同步块时立即测试此标志。如果设置了此标志,就抛出一个隐式的异常,但是此异常应不再能被捕捉并且当线程结束时不会产生任何输出。注意,微软的NT操作系统不能很好地处理一个外部指示的突然停止(abrupt)。(它不把stop消息通知动态连接库,所以可能导致系统级的资源漏洞。)这就是我建议使用类似异常的方法简单地导致run()返回的原因。
与这种和异常类似的处理方法带来的实际问题是,你必需在每个synchronized块后都插入代码来测试“stopped”标志。并且这种附加的代码会降低系统性能并增加代码长度。我想到的另外一个办法是使stop()实现一个“延迟的(lazy)”停止,在这种情况下,在下次调用wait()或yield()时才终止。我还想向Thread中加入一个isStopped()和stopped()方法(此时,Thread将像isInterrupted()和interrupted()一样工作,但是会检测“stop-requested”的状态)。这种方法不向第一种那样通用,但是可行并且不会产生过载。
应把suspend()和resume()方法放回到Java编程语言中,它们是很有用的,我不想被当成是幼儿园的小孩。由于它们可能产生潜在的危险(当被挂起时,一个线程可以占据一个锁)而去掉它们是没有道理的。请让我自己来决定是否使用它们。如果接收的线程正占据着锁,Sun公司应该把它们作为调用suspend()的一个运行时间异常处理(run-timeexception);或者更好的方法是,延迟实际的挂起过程,直到线程释放所有的锁。
被阻断的I/O应正确工作
应该能打断任何被阻断的操作,而不是只让它们wait()和sleep()。我在“TamingJavaThreads”的第二章中的socket部分讨论了此问题。但是现在,对于一个被阻断的socket上的I/O操作,打断它的唯一办法是关闭这个socket,而没有办法打断一个被阻断的文件I/O操作。例如,一旦开始一个读请求并且进入阻断状态后,除非到它实际读出一些东西,否则线程一直出于阻断状态。既使关掉文件句柄也不能打断读操作。
还有,程序应支持I/O操作的超时。所有可能出现阻断操作的对象(例如InputStream对象)也都应支持这种方法:
这和Socket类的setSoTimeout(time)方法是等价的。同样地,应该支持把超时作为参数传递到阻断的调用。
ThreadGroup类
ThreadGroup应该实现Thread中能够改变线程状态的所有方法。我特别想让它实现join()方法,这样我就可等待组中的所有线程的终止。