6. 在多线程程序中,线程可以从准备就绪队列中得到,并在可获得的系统 CPU 上运行。操作系统
可以将线程从处理器移到准备就绪队列或阻塞队列中,这种情况可以认为是处理器“挂起”了
该线程。同样,Java 虚拟机 (JVM) 也可以控制线程的移动——在协作或抢先模型中——从准备
就绪队列中将进程移到处理器中,于是该线程就可以开始执行它的程序代码。
协作式线程模型允许线程自己决定什么时候放弃处理器来等待其他的线程。程序开发员可以精确
地决定某个线程何时会被其他线程挂起,允许它们与对方有效地合作。 缺点在于某些恶意或是写
得不好的线程会消耗所有可获得的 CPU 时间,导致其他线程“饥饿”。
在抢占式线程模型中,操作系统可以在任何时候打断线程。通常会在它运行了一段时间(就是所
谓的一个时间片)后才打断它。这样的结果自然是没有线程能够不公平地长时间霸占处理器。然
而,随时可能打断线程就会给程序开发员带来其他麻烦。同样使用办公室的例子,假设某个职员
抢在另一人前使用复印机,但打印工作在未完成的时候离开了,另一人接着使用复印机时,该
复印机上可能就还有先前那名职员留下来的资料。 抢占式线程模型要求线程正确共享资源,协作
式模型却要求线程共享执行时间。由于 JVM 规范并没有特别规定线程模型,Java 开发员必须
编写可在两种模型上正确运行的程序。在了解线程以及线程间通讯的一些方面之后,我们可以看
到如何为这两种模型设计程序。
线程和 Java 语言
为了使用 Java 语言创建线程,你可以生成一个 Thread 类(或其子类)的对象,并给这个对
象发送 start() 消息。(程序可以向任何一个派生自 Runnable 接口的类对象发送
start() 消息。)每个线程动作的定义包含在该线程对象的 run() 方法中。run 方法就相当于
传统程序中的 main() 方法;线程会持续运行,直到 run() 返回为止,此时该线程便死了。
上锁
大多数应用程序要求线程互相通信来同步它们的动作。 Java 程序中最简单实现同步的方法就
在
是上锁。为了防止同时访问共享资源,线程在使用资源的前后可以给该资源上锁和开锁。假想给
复印机上锁,任一时刻只有一个职员拥有钥匙。 若没有钥匙就不能使用复印机。给共享变量上锁
就使得 Java 线程能够快速方便地通信和同步。某个线程若给一个对象上了锁,就可以知道没有
其他线程能够访问该对象。即使在抢占式模型中,其他线程也不能够访问此对象,直到上锁的线
程被唤醒、完成工作并开锁。那些试图访问一个上锁对象的线程通常会进入睡眠状态,直到上锁
的线程开锁。一旦锁被打开,这些睡眠进程就会被唤醒并移到准备就绪队列中。
在 Java 编程中,所有的对象都有锁。线程可以使用 synchronized 关键字来获得锁。在任一
时刻对于给定的类的实例,方法或同步的代码块只能被一个线程执行。这是因为代码在执行之前
要求获得对象的锁。继续我们关于复印机的比喻,为了避免复印冲突,我们可以简单地对复印资
源实行同步。如同下列的代码例子,任一时刻只允许一位职员使用复印资源。通过使用方法(在
Copier 对象中)来修改复印机状态。这个方法就是同步方法。只有一个线程能够执行一个
Copier 对象中同步代码,因此那些需要使用 Copier 对象的职员就必须排队等候。
class CopyMachine { public synchronized void
makeCopies(Document d, int nCopies) { //only one thread executes
this at a time } public void loadPaper() { //multiple
7. threads could access this at once! synchronized(this) {
//only one thread accesses this at a time //feel free to use
shared resources, overwrite members, etc. } }}
Fine-grain 锁
在对象级使用锁通常是一种比较粗糙的方法。为什么要将整个对象都上锁,而不允许其他线程短
暂地使用对象中其他同步方法来访问共享资源?如果一个对象拥有多个资源,就不需要只为了
让一个线程使用其中一部分资源,就将所有线程都锁在外面。由于每个对象都有锁,可以如下所
示使用虚拟对象来上锁:
class FineGrainLock { MyMemberClass x, y; Object xlock = new
Object(), ylock = new Object(); public void foo()
{ synchronized(xlock) { //access x here } //do
something here - but don't use shared resources
synchronized(ylock) { //access y here } } public
void bar() { synchronized(this) { //access both x and y
here } //do something here - but don't use shared resources
}}
若为了在方法级上同步,不能将整个方法声明为 synchronized 关键字。它们使用的是成员锁
而不是 synchronized 方法能够获得的对象级锁。
信号量
通常情况下,可能有多个线程需要访问数目很少的资源。假想在服务器上运行着若干个回答客户
端请求的线程。这些线程需要连接到同一数据库,但任一时刻只能获得一定数目的数据库连接。
你要怎样才能够有效地将这些固定数目的数据库连接分配给大量的线程?一种控制访问一组资
源的方法(除了简单地上锁之外),就是使用众所周知的信号量计数 (counting semaphore)。
信号量计数将一组可获得资源的管理封装起来。信号量是在简单上锁的基础上实现的,相当于能
令线程安全执行,并初始化为可用资源个数的计数器。例如我们可以将一个信号量初始化为可获
得的数据库连接个数。一旦某个线程获得了信号量,可获得的数据库连接数减一。线程消耗完资
源并释放该资源时,计数器就会加一。当信号量控制的所有资源都已被占用时,若有线程试图访
问此信号量,则会进入阻塞状态,直到有可用资源被释放。
信号量最常见的用法是解决“消费者-生产者问题”。当一个线程进行工作时,若另外一个线程
访问同一共享变量,就可能产生此问题。消费者线程只能在生产者线程完成生产后才能够访问数
据。使用信号量来解决这个问题,就需要创建一个初始化为零的信号量,从而让消费者线程访问
此信号量时发生阻塞。每当完成单位工作时,生产者线程就会向该信号量发信号(释放资源)。
每当消费者线程消费了单位生产结果并需要新的数据单元时,它就会试图再次获取信号量。因此
信号量的值就总是等于生产完毕可供消费的数据单元数。这种方法比采用消费者线程不停检查是
否有可用数据单元的方法要高效得多。因为消费者线程醒来后,倘若没有找到可用的数据单元,
就会再度进入睡眠状态,这样的操作系统开销是非常昂贵的。
8. 尽管信号量并未直接被 Java 语言所支持,却很容易在给对象上锁的基础上实现。一个简单的实
现方法如下所示:
class Semaphore { private int count; public Semaphore(int n) {
this.count = n; } public synchronized void acquire()
{ while(count == 0) { try { wait(); }
catch (InterruptedException e) { //keep trying }
} count--; } public synchronized void release()
{ count++; notify(); //alert a thread that's blocking on
this semaphore }}
常见的上锁问题
不幸的是,使用上锁会带来其他问题。让我们来看一些常见问题以及相应的解决方法:
死锁。死锁是一个经典的多线程问题,因为不同的线程都在等待那些根本不可能被释放
的锁,从而导致所有的工作都无法完成。假设有两个线程,分别代表两个饥饿的人,他
们必须共享刀叉并轮流吃饭。他们都需要获得两个锁:共享刀和共享叉的锁。假如线程
"A" 获得了刀,而线程 "B" 获得了叉。线程 A 就会进入阻塞状态来等待获得叉,而线程
B 则阻塞来等待 A 所拥有的刀。这只是人为设计的例子,但尽管在运行时很难探测到,
这类情况却时常发生。虽然要探测或推敲各种情况是非常困难的,但只要按照下面几条
规则去设计系统,就能够避免死锁问题:
• 让所有的线程按照同样的顺序获得一组锁。这种方法消除了 X 和 Y 的拥有者
分别等待对方的资源的问题。
•
• 将多个锁组成一组并放到同一个锁下。前面死锁的例子中,可以创建一个银器
对象的锁。于是在获得刀或叉之前都必须获得这个银器的锁。
•
• 将那些不会阻塞的可获得资源用变量标志出来。当某个线程获得银器对象的锁
时,就可以通过检查变量来判断是否整个银器集合中的对象锁都可获得。如果
是,它就可以获得相关的锁,否则,就要释放掉银器这个锁并稍后再尝试。
•
• 最重要的是,在编写代码前认真仔细地设计整个系统。多线程是困难的,在开
始编程之前详细设计系统能够帮助你避免难以发现死锁的问题。
Volatile 变量 . volatile 关键字是 Java 语言为优化编译器设计的。以下面的代码为
例:
class VolatileTest { public void foo() { boolean flag = false;
if(flag) { //this could happen } }}
一个优化的编译器可能会判断出 if 部分的语句永远不会被执行,就根本不会编译这
部分的代码。如果这个类被多线程访问, flag 被前面某个线程设置之后,在它被 if
27. 对于第二个问题,通过限定 final 修饰符指向恒定对象,可以解决此问题。这就是说,对于一
个对象,只有所有的域是 final,并且所有引用的对象的域也都是 final,此对象才真正是恒
定的。为了不打破现有代码,这个定义可以使用编译器加强,即只有一个类被显式标为不变时,
此类才是不变类。方法如下:
$immutable public class Fred { // all fields in this class must
be final, and if the // field is a reference, all fields in the referenced
// class must be final as well (recursively). static int x constant = 0; //
use of `final` is optional when $immutable // is
present. }
有了 $immutable 修饰符后,在域定义中的 final 修饰符是可选的。
最后,当使用内部类(inner class)后,在 Java 编译器中的一个错误使它无法可靠地创建不变
对象。当一个类有重要的内部类时(我的代码常有),编译器经常不正确地显示下列错误信息:
"Blank final variable 'name' may not have been initialized.It must be assigned a
value in an initializer, or in every constructor."
既使空的 final 在每个构造函数中都有初始化,还是会出现这个错误信息。自从在 1.1 版本中
引入内部类后,编译器中一直有这个错误。在此版本中(三年以后),这个错误依然存在。现在
该是改正这个错误的时候了。
对于类级域的实例级访问
除了访问权限外,还有一个问题,即类级(静态)方法和实例(非静态)方法都能直接访问类
级(静态)域。这种访问是非常危险的,因为实例方法的同步不会获取类级的锁,所以一个
synchronized static 方法和一个 synchronized 方法还是能同时访问类的域。改正此
问题的一个明显的方法是,要求在实例方法中只有使用 static 访问方法才能访问非不变类
的 static 域。当然,这种要求需要编译器和运行时间检查。在这种规定下,下面的代码是非
法的:
class Broken { static long x; synchronized static void f()
{ x = 0; } synchronized void g() { x = -1; } };
由于 f() 和 g() 可以并行运行,所以它们能同时改变 x 的值(产生不定的结果)。请记住,
这里有两个锁:static 方法要求属于 Class 对象的锁,而非静态方法要求属于此类实例的
锁。当从实例方法中访问非不变 static 域时,编译器应要求满足下面两个结构中的任意一个
class Broken { static long x; synchronized private static
accessor( long value ) { x = value; } synchronized
static void f() { x = 0; } synchronized void g() {
accessor( -1 ); } }
或则,编译器应获得读/写锁的使用: