驯服Java线程1

来源:百度文库 编辑:神马文学网 时间:2024/04/20 04:08:08
(一)
JAVA 线程架构
Java 多线程编程其实并不象大多数的书描述的那样简单,所有关于UI(用户界面)的Java编程都要涉及多线程。这一章将会通过讨论几种操作系统的线程架构和这些架构将会怎样影响Java多线程编程。按照这样思路,我将介绍一些在Java的入门级书籍中描述的不慎清楚的关键术语和概念。理解这些概念是使你能够看懂本书所提供的例子的必备条件。
多线程编程的问题
象鸵鸟一样的把自己的头埋在沙子里,假装不去考虑多线程的问题其实是目前很多人进行Java编程共同弊病。但是在真正的产品中,你却无法回避这个严重的问题。目前,市面上大多数的书对Java线程的描述都是很肤浅的,甚至它们提供的例子本身就无法在多线程的环境下正确运行。
事实上,多线程是影响所有代码的重要因素。可以极端一点的说,单线程的代码在现实应用中,一钱不值,甚至根本无法运行,更不用说正确性和高效率了。所以你应该从一开始就把多线程作为一个重要的方面,融入你的代码架构。
所有不平凡的Java程序都是多线程的
不管你喜欢与否,所有的Java程序除了小部分非常简单的控制台程序都是基于多线程的。原因在于Java的Abstract Windowing Toolkit ( AWT )和它的扩展Swing,AWT用一个特殊的线程处理所有的操作系统级的事件,这个特殊的线程是在第一个窗口出现的时候产生的。因此,几乎所有的AWT程序都有至少2个线程在运行:一个是main函数所在的线程和处理来自OS的事件和调用注册的监听者的响应方法(也就是回调函数)的AWT线程。必须注意的是所有注册的监听者方法,运行在AWT线程上,而不是人们一般认为的main函数(这也是监听器注册的线程)。
这种架构有两个问题。第一,虽然监听器的方法是运行在AWT线程上的,但是他们其实都是在main线程上声明的内部类(inner-class)。第二,虽然监听器的方法是运行在AWT线程上的,但是它一般会非常频繁的访问它的外部类,也就是运行在main线程上的类的成员变量。当这两个线程竞争(compete)访问同一个对象实例(Object)时,会引起非常严重的线程同步问题。适当的使用关键字synchronized是保证两个线程安全访问共享对象的必要手段。
更糟的是,AWT线程不但止处理监听器方法,还有响应来自操作系统的事件。这就意味着,如果你的监听器方法占用大量的CPU时间来进行处理,则你的程序将无法响应操作系统级的事件(例如鼠标点击事件和键盘事件)。这些事件将会被阻塞在事件队列中,直到监听器方法返回。具体的表现就是UI的死锁。这样会让用户无法接受的。Listing 1.1就是这样一个无响应UI的例子。程序产生一个包含两个按钮的Frame。Sleep按钮使它所在的线程(也就是前面所说的AWT事件处理线程)休眠5秒钟。Hello按钮只是简单的在控制台上打印“Hello World”。在你按下Sleep按钮5秒钟之内,无论你按多少次Hello按钮,程序都不会有任何响应。如果你在这期间按下了Hello按钮5次。那么“Hello World”将会立即被连续打印五次,当你Sleep按钮的监听器方法结束以后。这就证明了5个鼠标点击事件被阻塞在事件队列里,直到Sleep按钮的事件响应完。
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
class Hang extends JFrame
{
public Hang()
{   JButton b1 = new JButton( "Sleep" );
JButton b2 = new JButton( "Hello" );
b1.addActionListener
(   new ActionListener()
{   public void actionPerformed( ActionEvent event )
{   try
{   Thread.currentThread().sleep(5000);
}
catch(Exception e){}
}
}
);
b2.addActionListener
(   new ActionListener()
{   public void actionPerformed( ActionEvent event )
{   System.out.println("Hello world");
}
}
);
getContentPane().setLayout( new FlowLayout() );
getContentPane().add( b1 );
getContentPane().add( b2 );
pack();
show();
}
public static void main( String[] args )
{
new Hang();
}
}
大多数的书籍中讨论的Java GUI都回避了线程的问题。在现实中,对于UI事件采取单线程的方法都是不可取的。所有成功的UI程序都有下面几个共同点:
l         UI必须就程序的运行状态进程,给用户一些回馈信息。简单的弹出一个显示程序正在做的事情的对话框是不足够的。你必须告诉用户操作的运行进度(例如一个带有百分比的进度条)。
l         必须做到当底层系统状态改变时,不会为了更新窗口而把整个UI重绘。
l         你必须使你的程序做到,当用户点击Cancel按钮时,你的程序能够立即响应,并及时终止。
l         必须做到当一个需要长时间运行的操作正在运行时,用户可以在你的UI界面上做其它的操作。
这是条规则可以用一句话来总结:不允许死锁的UI界面出现,不允许当程序运行一个
耗时很长的操作时,忽略掉用户其他的操作,如鼠标点击和键盘事件,因此不允许在监听器窗口中,运行长时间的操作。耗时操作必须在后台的其他线程运行。因此,真正的程序在任何时候都会2个以上的线程在跑。
(二)
Java 线程的支持不是平台独立的
非常不幸,作为Java语言所保证的平台独立性最重要的组成部分-------Java线程,并非是平台独立的。这增加了实现不依赖于平台的线程系统的难度。在实现的时候,不得不考虑每个平台的细微区别,以确保你的程序在每个平台都保持一致。其实,写一个独立于平台的程序,还是有可能的,但必须非常小心。不过你可以放心,这个令人失望的事实,并不是Java的问题。(“Ace”Framework 就是一个非常好的,也非常复杂的平台独立线程系统http://www.cs.wustl.edu/~schmidt/ACE.html)。所以,在我继续讲下去之前,我不得不线讨论一下,由于平台的多样性,而导致的JVM的不一致性。
线程和进程(Threads and Processes)
第一个关键的系统级概念,究竟什么是线程或者说究竟什么是进程?她们其实就是操作系统内部的一种数据结构。
进程数据结构掌握着所有与内存相关的东西:全局地址空间、文件句柄等等诸如此类的东西。当一个进程放弃执行(准确的说是放弃占有CPU),而被操作系统交换到硬盘上,使别的进程有机会运行的时候,在那个进程里的所有数据也将被写到硬盘上,甚至包括整个系统的核心(core memory)。可以这么说,当你想到进程(process),就应该想到内存(memory) (进程 == 内存)。如上所述,切换进程的代价非常大,总有那么一大堆的内存要移来移去。你必须用秒这个单位来计量进程切换(上下文切换),对于用户来说秒意味着明显的等待和硬盘灯的狂闪(对于作者的我,就意味着IBM龙腾3代的烂掉,5555555)。言归正传,对于Java而言,JVM就几乎相当于一个进程(process),因为只有进程才能拥有堆内存(heap,也就是我们平时用new操作符,分出来的内存空间)。
那么线程是什么呢?你可以把它看成“一段代码的执行”---- 也就是一系列由JVM执行的二进制指令。这里面没有对象(Object)甚至没有方法(Method)的概念。指令执行的序列可以重叠,并且并行的执行。后面,我会更加详细的论述这个问题。但是请记住,线程是有序的指令,而不是方法(method)。
线程的数据结构,与进程相反,仅仅只包括执行这些指令的信息。它包含当前的运行上下文(context):如寄存器(register)的内容、当前指令的在运行引擎的指令流中的位置、保存方法(methods)本地参数和变量的运行时堆栈。如果发生线程切换,OS只需把寄存器的值压进栈,然后把线程包含的数据结构放到某个类是列表(LIST)的地方;把另一个线程的数据从列表中取出,并且用栈里的值重新设置寄存器。切换线程更加有效率,时间单位是毫秒。对于Java而言,一个线程可以看作是JVM的一个状态。
运行时堆栈(也就是前面说的存储本地变量和参数的地方)是线程数据结构一部分。这是因为多个线程,每一个都有自己的运行时堆栈,也就是说存储在这里面的数据是绝对线程安全(后面将会详细解释这个概念)的。因为可以肯定一个线程是无法修改另一个线程的系统级的数据结构的。也可以这么说一个不访问堆内存的(只读写堆栈内存)方法,是线程安全的(Thread Safe)。
线程安全和同步
线程安全,是指一个方法(method)可以在多线程的环境下安全的有效的访问进程级的数据(这些数据是与其他线程共享的)。事实上,线程安全是个很难达到的目标。
线程安全的核心概念就是同步,它保证多个线程:
l         同时开始执行,并行运行
l         不同时访问相同的对象实例
l         不同时执行同一段代码
我将会在后面的章节,一一细诉这些问题。但现在还是让我们来看看同步的一种经典的
实现方法——信号量。信号量是任何可以让两个线程为了同步它们的操作而相互通信的对象。Java也是通过信号量来实现线程间通信的。
不要被微软的文档所暗示的信号量仅仅是Dijksta提出的计数型信号量所迷惑。信号量其实包含任何可以用来同步的对象。
如果没有synchronized关键字,就无法用JAVA实现信号量,但是仅仅只依靠它也不足够。我将会在后面为大家演示一种用Java实现的信号量。
同步的代价很高哟!
同步(或者说信号量,随你喜欢啦)的一个很让人头痛的问题就是代价。考虑一下,下面的代码:
Listing 1.2:
import java.util.*;
import java.text.NumberFormat;
class Synch
{
private static long[ ]              locking_time   = new long[100];
private static long[ ]              not_locking_time = new long[100];
private static final long       ITERATIONS = 10000000;
synchronized long locking     (long a, long b){return a + b;}
long              not_locking (long a, long b){return a + b;}
private void test( int id )
{
long start = System.currentTimeMillis();
for(long i = ITERATIONS; --i >= 0 ;)
{     locking(i,i);
}
locking_time[id] = System.currentTimeMillis() - start;
start                      = System.currentTimeMillis();
for(long i = ITERATIONS; --i >= 0 ;)
{     not_locking(i,i);
}
not_locking_time[id] = System.currentTimeMillis() - start;
}
static void print_results( int id )
{
NumberFormat compositor = NumberFormat.getInstance();
compositor.setMaximumFractionDigits( 2 );
double time_in_synchronization = locking_time[id] - not_locking_time[id];
System.out.println( "Pass " + id + ": Time lost: "
+ compositor.format( time_in_synchronization                         )
+ " ms. "
+ compositor.format( ((double)locking_time[id]/not_locking_time[id])*100.0 )
+ "% increase"
);
}
static public void main(String[ ] args) throws InterruptedException
{
final Synch tester = new Synch();
tester.test(0); print_results(0);
tester.test(1); print_results(1);
tester.test(2); print_results(2);
tester.test(3); print_results(3);
tester.test(4); print_results(4);
tester.test(5); print_results(5);
tester.test(6); print_results(6);
final Object start_gate = new Object();
Thread t1 = new Thread()
{     public void run()
{     try{ synchronized(start_gate) {     start_gate.wait(); } }
catch( InterruptedException e ){}
tester.test(7);
}
};
Thread t2 = new Thread()
{     public void run()
{     try{ synchronized(start_gate) {     start_gate.wait(); } }
catch( InterruptedException e ){}
tester.test(8);
}
};
Thread.currentThread().setPriority( Thread.MIN_PRIORITY );
t1.start();
t2.start();
synchronized(start_gate){ start_gate.notifyAll(); }
t1.join();
t2.join();
print_results( 7 );
print_results( 8 );
}
}
这是一个简单的基准测试程序,她清楚的向大家揭示了同步的代价是多么的大。test(…)方法调用2个方法1,000,000,0次。其中一个是同步的,另一个则否。下面是在我的机器上输出的结果(CPU: P4 2.4G(B); Memory: 1GB; OS: windows 2000 server(sp3); JDK: Ver1.4.01 and HotSpot 1.4.01-b01):
C:\>java -verbose:gc Synch
Pass 0: Time lost: 251 ms. 727.5% increase
Pass 1: Time lost: 250 ms. 725% increase
Pass 2: Time lost: 251 ms. 602% increase
Pass 3: Time lost: 250 ms. 725% increase
Pass 4: Time lost: 261 ms. 752.5% increase
Pass 5: Time lost: 260 ms. 750% increase
Pass 6: Time lost: 261 ms. 752.5% increase
Pass 7: Time lost: 1,953 ms. 1,248.82% increase
Pass 8: Time lost: 3,475 ms. 8,787.5% increase
这里为了使HotSpot JVM充分的发挥其威力,test( )方法被多次反复调用。一旦这段程序被彻底优化以后,也就是大约在Pass 6时,同步的代价达到最大。Pass 7 和Pass 8与前面的区别在于,我new了两个新的线程来并行执行test方法,两个线程竞争执行(后面是适当的地方,我会解释什么是“竞争”,如果你已经等不及了,买本大学的操作系统课本看看吧! J),这使结果更加接近真实。同步的代价是如此之高的,应该尽量避免无谓的同步代价。
现在是时候我们更深入的讨论一下同步的代价了。HotSpot JVM一般会使用一到两个方法来实现同步,这主要取决于是否存在线程的竞争。当没有竞争的时候,计算机的汇编指令顺序的执行,这些指令的执行是不被打断。指令试图测试一个比特(bit),然后设置各种二进制位来表示测试的结果,如果这个bit没有被设置,指令就设置它。这可以说是非常原始的信号量,因为当两个线程同步的企图设置一个bit的值时,只有一个线程可以成功,两个线程都会检查结果,看看是不是自己设成功了。
如果bit已经被设置(这里说的是有线程竞争的情况下),失败的JVM(线程)不得不离开操作系统的核心进程等待这个bit位被清零。这样来回的在系统核心中切换是非常耗时的。在NT系统下,需要600次机械指令循环来进入一次系统内核,这还仅仅是进入所耗费的时间还不包括做操作的时间。
是不是觉得很无聊了,呵呵!今天似乎都是些不顶用的东西。但这是必须的,为了使你能够读懂后面的内容。下一篇,我将会谈到一些更有趣的话题,例如如何避免同步,如果大家不反对,我还想讲一些设计模式的东西。下回见!
(三)
避免同步
大部分显示的同步都可以避免。一般不操作对象状态信息(例如数据成员)的方法都不需要同步,例如:一些方法只访问本地变量(也就是说在方法内部声明的变量),而不操作类级别的数据成员,并且这些方法不会通过传入的引用参数来修改外部的对象。符合这些条件的方法都不需要使用synchronization这种重量级的操作。除此之外,还可以使用一些设计模式(Design Pattern)来避免同步(我将会在后面提到)。
你甚至可以通过适当的组织你的代码来避免同步。相对于同步的一个重要的概念就是原子性。一个原子性的操作事不能被其他线程中断的,通常的原子性操作是不需要同步的。
Java定义一些原子性的操作。一般的给变量付值的操作是原子的,除了long和double。看下面的代码:
class Unreliable
{
private long x;
public long get_x( ) {return x;}
public void set_x(long value) { x = value; }
}
线程1调用:
obj.set_x( 0 );
线程2调用:
obj.set_x( 0x123456789abcdef )
问题在于下面这行代码:
x = value;
JVM为了效率的问题,并没有把x当作一个64位的长整型数来使用,而是把它分为两个32-bit,分别付值:
x.high_word = value.high_word;
x.low_word = value.low_word;
因此,存在一个线程设置了高位之后被另一个线程切换出去,而改变了其高位或低位的值。所以,x的值最终可能为0x0123456789abcdef、0x01234567000000、0x00000000abcdef和0x00000000000000。你根本无法确定它的值,唯一的解决方法是,为set_x( )和get_x()方法加上synchronized这个关键字或者把这个付值操作封装在一个确保原子性的代码段里。
所以,在操作的long型数据的时候,千万不要想当然。强迫自己记住吧:只有直接付值操作是原子的(除了上面的例子)。其它,任何表达式,象x = ++y、x += y都是不安全的,不管x或y的数据类型是否是小于64位的。很可能在付值之前,自增之后,被其它线程抢先了(preempted)。
竞争条件
在术语中,对于前面我提到的多线程问题——两个线程同步操作同一个对象,使这个对象的最终状态不明——叫做竞争条件。竞争条件可以在任何应该由程序员保证原子操作的,而又忘记使用synchronized的地方。在这个意义上,可以把synchronized看作一种保证复杂的、顺序一定的操作具有原子性的工具,例如给一个boolean值变量付值,就是一个隐式的同步操作。
不变性
一种有效的语言级的避免同步的方法就是不变性(immutability)。一个自从产生那一刻起就无法再改变的对象就是不变性对象,例如一个String对象。但是要注意类似这样的表达式:string1 += string2;本质上等同于string1 = string1 + string2;其实第三个包含string1和string2的string对象被隐式的产生,最后,把string1的引用指向第三个string。这样的操作,并不是原子的。
由于不变对象的值无法发生改变,所以可以为多个线程安全的同步操作,不需要synchronized。
把一个类的所有数据成员都声明为final就可以创建一个不变类型了。那些被声明为final的数据成员并不是必须在声明的时候就写死,但必须在类的构造函数中,全部明确的初始化。例如:
Class I_am_immutable
{
private final int MAX_VALUE = 10;
private final int blank_final;
public I_am_immutable( int_initial_value )
{
blank_final = initial_value;
}
}
一个由构造函数进行初始化的final型变量叫做blank final。一般的,如果你频繁的只读访问一个对象,把它声明成一个不变对象是个保证同步的好办法,而且可以提高JVM的效率,因为HotSpot会把它放到堆栈里以供使用。
同步封装器(Synchronization Wrappers)
同步还是不同步,是问题的所在。让我们跳出这样的思维模式吧,世事无绝对。有什么办法可以使你的类灵活的在同步与不同步之间切换呢? 有一个非常好的现成例子,就是新近引入JAVA的Collection框架,它是用来取代原本散乱的、繁重的Vector等类型。Vector的任何方法都是同步的,这就是为什么说它繁重了。而对于collections对象,在需要保证同步的时候,一般会由访问它方法来保证同步,因此没有必要两次锁定(一次是锁定包含使用collection对象的方法的对象,一次是锁定collection对象自身)。Java的解决方案是使用同步封装器。其基本原理来自四人帮(Gang-of-Four)的Decorator模式,一个Decorator自身就实现某个接口,而且又包含了实现同样接口的数据成员,但是在通过外部类方法调用内部成员的相同方法的时候,控制或者修改传入的变量。java.io这个包里的所有类都是Decorator:一个BufferedInputStream既实现了虚类InputStream的所有方法,又包含了一个InputStream引用所指向的成员变量。程序员调用外部容器类的方法,实际上是变相的调用内部对象的方法。
我们可以利用这种设计模式。来实现灵活的同步方法。如下例:
Interface Some_interface
{
Object message( );
}
class Not_thread_safe implements Some_interface
{
public Object message( )
{
//实现该方法的代码,省~~~~~~~~~~~
return null;
}
}
class Thread_safe_wrapper implements Some_interface
{
Some_interface  not_thread_safe;
public Thread_safe_wrapper(Some_interface  not_thread_safe)
{
this.not_thread_safe  =  not_thread_safe;
}
public Some_interface extract( )
{
return not_thread_safe;
}
public synchronized Object message( )
{
return not_thread_safe.message( );
}
}
当不存在线程安全的时候,你可以直接使用Not_thread_safe类对象。当需要考虑线程安全的时候,只需要把它包装一下:
Some_interface object = new Not_thread_safe( );
……………
object = new Thread_safe_wrapper(object); //object现在变成线程安全了
当你不需要考虑线程安全的时候,你可以还原object对象:
object = ((Thread_safe_Wrapper)object).extract( );
下一回,我们又要深入底层机制了。呵呵!千万不要闷着大家呀!下回见!
(四)
线程的并发性
下一个与OS平台相关的问题(这也是编写与平台无关的Java程序要面对的问题)是必须确定并发性和并行在该平台的定义。并发的多线程系统总会给人多个任务同时运行的感觉,其实这些任务是被分割为许多的块交错在一起执行的。在一个并行的系统中,两个任务实际上是同时(这里的同时是真正的同时,而不是快速交错执行所产生的并行假象)运行的,这就要求有多个CPU。如图1.1:

图1.1 Concurrency vs Parallelism
多线程其实并不能加快的程序速度。如果你的程序并不需要频繁的等待IO操作完成,那么多线程程序还会比单线程程序更慢些。但在多CPU系统下则反之。
Java线程系统非平台独立的主要原因就是要实现彻底的平行运行的线程,如果不使用OS提供的系统线程模型,是不可能的。对于JAVA而言,在理论上,允许由JVM来模仿整个线程系统,从而避免我在前一篇文章(驯服JAVA线程2)中,所提到的进入OS核心的时间消耗。但是,这样也排除了程序中的并行性,因为如果不使用任何操作系统级的线程(这样是为了保持平台独立性),OS会把JVM的实例当成一个单线程的程序来看待,也就只会分配单个CPU来执行它,从而导致就算运行在多CPU的机器上,而且只有一个JVM实例在单独运行,也不可能出现两个Java线程真正的并行运行(充分的利用两个CPU)。
所以,要真正的实现并行运行,只有存在两个JVM实例,分别运行不同的程序。做的再好一点就是让JVM把Java的线程映射到OS级的线程上去(一个Java线程就是一个系统的线程,让系统进行调配,充分发挥系统对资源的操控能力,这样就不存在只能在一个CPU上运行的问题了)。不幸的是,不同的操作系统实现的线程机制也不同,而且这些区别已经到了在编程时不能忽视的地步了。
由于平台不同而导致的问题
下面,我将会通过比较Solaris和WindowsNT对线程机制实现不同之处,来说明前面提到的问题。
Java,在理论上,至少有10个线程优先等级划分(如果有两个或两个以上的线程都处在on ready状态下,那么拥有高优先级的线程将会先执行)。在Solaris里,支持231个优先等级,当然对于支持Java那10个的等级是没问题的。
在NT下,最多只有7优先级划分,却必须映射到Java的10个等级。这就会出现很多的可能性(可能Java里面的优先级1、2就等同于NT里的优先级1,优先级8、9、10则等于NT里的7级,还有很多的可能性)。因此,在NT下依靠优先级来调度线程时存在很多问题。
更不幸的还在后面呢!NT下的线程优先级竟然还不是固定的!这就更加复杂了!NT提供了一个名叫优先级助推(Priority Boosting)的机制。这个机制使程序员可以通过调用一个C语言的系统Call(Windows NT/2000/XP: You can disable the priority-boosting feature by calling the SetProcessPriorityBoost or SetThreadPriorityBoost function. To determine whether this feature has been disabled, call the GetProcessPriorityBoost or GetThreadPriorityBoost function.)来改变线程优先级,但Java不能这样做。当打开了Priority Boosting功能的时候,NT依据线程每次执行I/O相关的系统调用的大概时间来提高该线程的优先级。在实践中,这意味着一个线程的优先级可能高过你的想象,因为这个线程碰巧在一个繁忙的时刻进行了一次I/O操作。线程优先级助推机制的目的是为了防止一个后台进程(或线程)影响了前台的UI显示进程。其它的操作系统同样有着复杂的算法来降低后台进程的优先级。这个机制的一个严重的副作用就是使我们无法通过优先级来判断即将运行的就绪太线程。
在这种情况下,事态往往会变得更糟。
在Solaris中,也就意味着在所有的Unix系统中,或者说在所有当代的操作系统中,除了微软的以外,每个进程或者线程都有优先级。高优先级的进程时不会被低优先级的进程打断的,此外,一个进程的优先级可以由管理员限制和设定,以防止一个用户进程是打断OS核心进程或者服务。NT对此都无法支持。一个NT的进程就是一个内存的地址空间。它没有固定优先级,也不能被预先编排。而全部交由系统来调度,如果一个线程运行在一个不再内存中的进程下,这个进程将会被切换进内存。NT中进程的优先级被简化为几个分布在实际优先级范围内的优先级类,也就是说他们是不固定的,由系统核心干预调配。如1.2图:

图1.2 Windows NT优先级架构
上图中的列,代表线程的优先级,只有22级是有所有的程序所使用(其它的只能为NT自己使用)。行代表前面提过的优先级类。
一个运行在“Idle”级进程上的线程,只能使用1-6 和 15,这七个优先级别,当然具体的是那一级,还要取决于线程的设定。运行在“Normal”级进程里并且没有得到焦点的一个线程将可能会使用1,6 — 10或15的优先级。如果有焦点而且所在进程使还是“Normal”级,这样里面的线程将会运行在1,7 — 11或者15 级。这也就意味着一个拥有搞优先级但在“Idle”进程内的线程,有可能被一个低优先级但是运行在“Normal”级的线程抢先(Preempt),但这只限于后台进程。还应该注意到一个运行在“High”优先级类的进程只有6个优先级等级而其它优先级类都有7。
NT不对进程的优先级类进行任何限制。运行在任意进程上的任意线程,可以通过优先级助推机制完全控制整个系统, OS核心没有任何的防御。另一方面,Solaris完全支持进程优先级的机制,因为你可能需要设定你的屏幕保护程序的优先级,以防止它阻碍系统重要进程的运行J。因为在一台关键的服务器中,优先级低的进程就不应该也不能占用高优先级的线程执行。由此可见,微软的操作系统根本不适合做高可靠性服务器。
那么我们在编程的时候,怎样避免呢?对于NT的这种无限制的优先级设定和无法控制的优先级助推机制(对于Java程序),事实上就没有绝对安全的方法来使Java程序依赖优先级调度线程的执行。一个折衷的方法,就是在用setPriority( )函数设定线程优先级的时候,只使用Thread.MAX_PRIORITY, Thread.MIN_PRIORITY和Thread.NORM_PRIORITY这几个没有具体指明优先级的参数。这个限制至少可以避免把10级映射为7级的问题。另外,还建议可以通过os.name的系统属性来判定是否是NT,如果是就通过调用一个本地函数来关闭优先级助推机制,但是那对于运行在没有使用Sun的JVM plug-in的IE的Java程序,也是毫无作用的(微软的JVM使用了一个非标准的,本地实现)。最后,还是建议大家在编程的时候,把大多数的线程的优先级设置为NORM_PRIORITY,并且依靠线程调度机制(scheduling)。(我将会再后面的谈到这个问题)
协作!(Cooperate)
一般来说,有两种线程模式:协作式和抢先式。
协作式多线程模型
在一个协作式的系统中,一个线程包留对处理器的控制直到它自己决定放弃(也许它永远不会放弃控制权)。多个线程之间不得不相互合作否则可能只有一个线程能够执行,其它都处于饥饿状态。在大多数的协作式系统中,线程的调度一般由优先级决定。当当前的线程放弃控制权,等待的线程中优先级高的将会得到控制权(一个特例,就是Window 3.x系统,它也是协作式系统,但却没有很多的线程执行进度调节,得到焦点的窗体获得控制权)。
协作式系统相对于抢先式系统的一个主要优点就是它运行速度快、代价低。比如,一个上下文切换——控制权由一个线程转换到另一个线程——可以完全由用户模式子系统库完成,而不需进入系统核心态(在NT下,就相当于600个机械指令时间)。在协作式系统下,用户态上下文切换就相当于C语言调用,setjump / longjump。大量的协作式线程同时运行,也不会影响性能,因为一切由程序员编程掌握,更加不需要考虑同步的问题。程序员只要保证它的线程在没有完成目标以前不放弃对CPU的控制。但世界终归不是完美的,人生总充满了遗憾。协同式线程模型也有自己硬伤:
1.         在协作式线程模型下,用户编程非常麻烦(其实就是系统把复杂度转移到用户身上)。把很长的操作分割成许多小块,是一件需要很小心的事。
2.         协作式线程无法并行执行。
抢先式多线程模型
另一种选择就是抢先式模型,这种模式就好象是有一个系统内部的时钟,由它来触发线程的切换。也就是说,系统可以任意的从线程中夺回对CPU的控制权,在把控制权分给其它的线程。两次切换之间的时间间隔就叫做时间切片(Time Slice)。
抢先式系统的效率不如协作式高,因为OS核心必须负责管理线程,但是这样使用户在编程的时候不用考虑那么多的其它问题,简化了用户的工作,而且使程序更加可靠,因为线程饥饿不再是一个问题。最关键的优势在于抢先式模型是并行的。通过前面的介绍,你可以知道协作式线程调度是由用户子程序完成,而不是OS,因此,你最多可以做到使你的程序具有并发性(如图1.1)。为了达到真正并行的目的,必须有操作系统介入。四个线程并行的运行在四个CPU上的效率要比四个线程并发的运行高的多。
一些操作系统,象Windows 3.1,只支持协作式模型;还有一些,象NT只支持抢先式模型(当然了你也可以通过使用用户模式的库调用在NT上模拟协作式模型。NT就有这么一个库叫“fiber”,但是遗憾的是fiber充满的了Bugs,并且没有彻底的整合到底层系统中去。)Solaris提供了世界上可能是最好的(当然也可能是最差的)线程模型,它既支持协作式,又支持抢先式。
从核心线程到用户进程的映射
最后一个要解决的问题就是核心线程到用户态进程的映射。NT使用的是一对一的映射模式,见图1.3。

图1.3 NT的线程模型
NT的用户线程就相当于系统核心线程。他们被OS直接映射到每一个处理器上,并且总是抢先式的。所有的线程操作和同步都是通过核心调用完成的。这是一个非常直接的模型,但是它既不灵活又低效。
图1.4表现的Solaris模型更加有趣。Solaris增加了一个叫轻量级进程(LWP — lightweight process)的概念。LWP是可以运行一个或多个线程的可调度单元。只在LWP这个程次上进行并行处理。一般的,LWP都存放在缓冲池中,并且按需分配给相应的处理器。如果一个LWP要执行某些时序性要求很高的任务时,它一定要绑定特定处理器,以阻止其它的LWPs使用它。
从用户的角度来看,这是一个既协作又抢先的线程模型。简单来说,一个进程至少有一个LWP供它所包含的所有线程共享使用。每个线程必须通过出让使用权(yield)来让其它线程执行(协作),但是单个LWP又可以为其它的进程的LWP抢先。这样在进程一级上就达到了并行的效果,同时在里面线程又处于协作的工作模式。
一个进程并不限死只有一个LWP,这个进程下的线程们可以共享整个LWP池。一个线程可以通过以下两种方法绑定到一个LWP上:
1.         通过编程显示的绑定一个或多个线程到一个指定的LWP。在这个情况下,同一个LWP下的线程必须协作式工作,但是这些线程又可以抢先其它LWP下的线程。这也就是说如果限制一个LWP只能绑定一个线程,那就变成了NT的抢先式线程系统了。
2.         通过用户态的调度器自动绑定。从编程的角度看,这是一个比较混乱的情况,因为你并不能假设环境究竟是协作式的,还是抢先式的。
线程系统给于用户最大的灵活性。你可以在速度极快的并发协作式系统和较慢但
确是并行的抢先式系统,或者这两者的折衷中选择。但是,Solaris的世界真的是那么完美吗?(我的一贯论调又出现了,呵呵!)这一切一切的灵活性对于一个Java程序员来说等于没有,因为你并不能决定JVM采用的线程模型。例如,早期的Solaris JVM采取严格的协作式机制。JVM相当于一个LWP,所有Java线程共享这唯一一个LWP。现在Solaris JVM又采用彻底的抢先式模型了,所有的线程独占各自的LWP。
那么我们这些可怜的程序员怎么办?我们在这个世上是如此的渺小,就连JVM采用那种模式的线程机制都无法确定。为了写出平台独立的代码,必须做出两个表面上矛盾的假设:
1.         一个线程可以被另一个线程在任何时候抢先。因此,你必须小心的使用synchronized关键字来保证非原子性的操作运行正确。
2.         一个线程永远不会被抢先除非它自己放弃控制权。因此,你必须偶然的执行一些放弃控制权的操作来给机会别的线程运行,适当的使用yield( )和sleep( )或者利用阻塞性的I/O调用。例如当你的线程每进行100次遍例或者相当长度的密集操作后,你应该主动的使你的线程休眠几百个毫秒,来给低优先级的线程以机会运行。注意!yield( )方法只会把控制权交给与你线程的优先级相当或者更高的线程。

图1.4  Solaris 线程模型
总结
由于诸多的OS级因素导致了Java程序员在编写彻底平台独立的多线程程序时,麻烦频频(唉~~~~~~我又想发牢骚了,忍住!)。我们只能按最糟糕的情况打算,例如只能假设你的线程随时都会被抢先,所以必须适当的使用synchronized;又不得不假设你的线程永远不会被抢先,如果你不自己放弃,所以你又必须偶尔使用yield( )和sleep( )或阻塞的I/O操作来让出控制权。还有就是我一开始就介绍的:永远不要相信线程优先级,如果你想真正做到平台独立!