您的位置:首页 > 编程语言 > Java开发

Java编程思想——并发(2)

2010-07-28 08:31 471 查看

共享受限资源

你可以把单线程程序当作在问题域求解的单一实体,每次只能做一件事情。因为只有一个实体,所以你永远不用担心诸如“两个实体试图同时使用同一个资源”这样的问题,比如:两个人在同一个地方停车,两个人同时走过一扇门,甚至是两个人同时说话。

在多线程的环境中,可以同时做多件事情。但是,“两个或多个线程同时使用同一个受限资源”的问题也出现了。必须防止这种资源访问的冲突,否则,就可能发生两个线程同时试图访问同一个银行帐户,向同一个打印机打印,改变同一个值等诸如此类的问题。


不正确地访问资源

考虑下面的例子,这里的类“保证”当你调用getValue( )方法时,总能返回一个偶数。同时,另外一个名为“Watcher”的线程不断调用getValue( )方法并检查返回值是否真的为偶数。这看起来好像没什么意义,因为从代码上看,返回值显然是偶数。但这恰恰是令人惊奇之处。下面是该程序的第一个版本:

//: c13:AlwaysEven.java

// Demonstrating thread collision over resources by

// reading an object in an unstable intermediate state.

public class AlwaysEven {

private int i;

public void next() { i++; i++; }

public int getValue() { return i; }

public static void main(String[] args) {

final AlwaysEven ae = new AlwaysEven();

new Thread("Watcher") {

public void run() {

while(true) {

int val = ae.getValue();

if(val % 2 != 0) {

System.out.println(val);

System.exit(0);

}

}

}

}.start();

while(true)

ae.next();

}

} ///:~

在main( )中,建立了一个AlwaysEven对象,它必须是final的,因为它要被一个继承自Thread的匿名内部类所访问。如果线程读出的值不是偶数,它将把这个值打印出来(以证明它捕获了对象的不稳定状态)并退出程序。

这个例子表明了使用线程会遇到的基本问题。你永远不会知道线程是何时运行的。想象一下,你坐在座子旁边,手里有一把叉子,准备叉起盘子里最后一块食物,当叉子碰到食物的时候,它忽然消失了(因为你的线程被挂起,另一个线程跑进来偷走了食物)。这就是你在写并发程序时要面临的问题。

你试图使用一个资源的同时,有时并不关心它是否正在被访问(比如别的盘子里的食物)。但为了让多线程能工作,你就需要某种方法来防止两个线程访问同一个资源,至少是在某个关键时间段内避免此问题。

要防止这类冲突,只要在线程使用资源的时候给它加一把锁就行了。访问资源的第一个线程给资源加锁,接着其它线程就只能等到锁被解除以后才能访问资源,这时某个线程就可以对资源加锁以进行访问。如果把汽车的前排座椅看成是受限资源的话,那么你的小孩大喊一声“我要坐”,就相当于在声明上锁。


一个资源测试框架

在我们继续之前,先来建立一个小型框架,试试能否简化对这种类型的线程例子的测试工作。我们可以把在多个例子中出现的常用代码分离出来做到这一点。首先,注意到“观察者”线程实际上在观察特定对象内部是否违例了约束条件。也就是说,假设对象具有保持其内部状态的条件,但如果你能从外部观察到对象的非法中间状态,那么从客户的观点来看,约束条件确实遭到了破坏(这并不是说对象在非法的中间状态下就不能存在,而是这种状态不该被客户观察到)。所以,我们不仅要检查约束条件是否被违反,还要知道这个违例的值是多少。要想用一次方法调用就得到这两个结果,我们得把它们捆绑在一个标记接口里,这个接口仅仅用来在代码中提供有意义的名字:

//: c13:InvariantState.java

// Messenger carrying invariant data

public interface InvariantState {} ///:~

在这种模式下,有关成功或是失败的信息被编码到类的名称和类型中,以使结果更可读。表示成功的类是:

//: c13:InvariantOK.java

// Indicates that the invariant test succeeded

public class InvariantOK implements InvariantState {} ///:~

要表示失败,InvariantFailure对象将包括一个对象,此对象表示了有关失败原因的信息,这样就可以显示出来:

//: c13:InvariantFailure.java

// Indicates that the invariant test failed

public class InvariantFailure implements InvariantState {

public Object value;

public InvariantFailure(Object value) {

this.value = value;

}

} ///:~

现在我们可以定义一个接口,任何需要对约束条件进行测试的类必须实现这个接口:

//: c13:Invariant.java

public interface Invariant {

InvariantState invariant();

} ///:~

在创建通用的“观察者”线程之前,要注意到本章中的某些例子也许不能在所有的平台上都按预期效果运行。这里的许多例子是故意演示在多线程环境下对单线程行为的影响,而这种情况并非总是发生2。相反,试图演示这种违例的例子也许不会(或者没有成功)造成任何影响。这时,我们需要某种方法在一段时间后能终止程序。下面的类通过继承标准类库中的Timer类做到了这一点:

//: c13:Timeout.java

// Set a time limit on the execution of a program

import java.util.*;

public class Timeout extends Timer {

public Timeout(int delay, final String msg) {

super(true); // Daemon thread

schedule(new TimerTask() {

public void run() {

System.out.println(msg);

System.exit(0);

}

}, delay);

}

2一些例子是在一台有双处理器的Win2K机器上编写的,这样冲突更容易发生。然而,在单处理器机器上运行同样的程序时可能会在相当长时间内没有冲突,正是这种不确定性使得多线程编程如此困难。你可以想象一下,你在单处理器机器上编写程序,并且认为代码是线程安全的,一旦把程序拿到一台多处理器机器上运行的时候,马上出了问题。

} ///:~

延迟的单位是毫秒,时间到期的话,将打印消息。注意通过调用super(true),此线程将作为一个后台线程而创建,所以要是你的程序采用别的方式结束,此线程将不会阻止程序终止。Timer.schedule( )方法传入了一个TimerTask的子类(此处作为匿名内部类创建)对象,这个对象的run( )方法将在schedule( )的第二个参数delay指定的时间到期之后执行。使用Timer一般来说比直接写代码调用sleep( )要简单和清晰。此外,设计Timer的目的就是承担大量并发调度任务,所以它能成为很有用的工具。

现在我们可以在InvariantWatcher线程里使用Invariant接口和Timeout类了:

//: c13:InvariantWatcher.java

// Repeatedly checks to ensure invariant is not violated

public class InvariantWatcher extends Thread {

private Invariant invariant;

public InvariantWatcher(Invariant invariant) {

this.invariant = invariant;

setDaemon(true);

start();

}

// Stop everything after awhile:

public

InvariantWatcher(Invariant invariant, final int timeOut){

this(invariant);

new Timeout(timeOut,

"Timed out without violating invariant");

}

public void run() {

while(true) {

InvariantState state = invariant.invariant();

if(state instanceof InvariantFailure) {

System.out.println("Invariant violated: "

+ ((InvariantFailure)state).value);

System.exit(0);

}

}

}

} ///:~

构造器接受一个Invariant对象的引用作为参数,它是要测试的对象,然后启动线程。第二个构造器调用第一个构造器,然后创建了一个Timeout,用来在一定的时间延迟之后终止所有的线程,如果程序中没有违反约束条件,那么线程就不可能因违反约束条件而终止,

此时就要用到它Timeout了。在run( )中,当前的InvariantState被获取和测试,如果有违例的话,违例的值将被打印出来。注意,我们不能在此线程里抛出异常,因为这只会终止线程,而不是终止程序。

现在AlwaysEven.java可以用这个框架重写:

//: c13:EvenGenerator.java

// AlwaysEven.java using the invariance tester

public class EvenGenerator implements Invariant {

private int i;

public void next() { i++; i++; }

public int getValue() { return i; }

public InvariantState invariant() {

int val = i; // Capture it in case it changes

if(val % 2 == 0)

return new InvariantOK();

else

return new InvariantFailure(new Integer(val));

}

public static void main(String[] args) {

EvenGenerator gen = new EvenGenerator();

new InvariantWatcher(gen);

while(true)

gen.next();

}

} ///:~

在定义invariant( )方法的时候,你必须把所有相关的值都存放到局部变量中。这样,你才能返回你真正测试的那个值,否则在返回的时候这个值可能已经(被别的线程)改变。

在这种情况下,问题已经不在于对象是否处于违反约束条件的状态,而是当对象处于这种中间的不稳定状态时,别的线程可能会调用它的方法。


资源冲突

对于EvenGenerator,最糟糕的莫过于客户线程可能会发现它处于不稳定的中间状态。尽管对象最终被观察到处于合法的状态,而且其内部一致性能够得到维护。但如果两个线程确实是在修改同一个对象,共享资源的冲突将变得更糟糕,因为这有可能会把对象设置成不正确的状态。

考虑简单的“信号量”(semaphore)概念,它可以看成是在两个线程之间进行通讯的标志对象。如果信号量的值是零,则它监控的资源是可用的,但如果这个值是非零的,则被监

控的资源不可用,所以线程必须等待。当资源可用的时候,线程增加信号量的值,然后继续执行并使用这个被监控的资源。因为把增加和减少当作是原子操作(也就是不能被中断),信号量能够保证两个线程同时访问同一资源的时候不至于冲突。

如果信号量能正确守护它所监控的资源,那么它一定不会处于不稳定的状态。下面是一个信号量概念的简化版本:

//: c13:Semaphore.java

// A simple threading flag

public class Semaphore implements Invariant {

private volatile int semaphore = 0;

public boolean available() { return semaphore == 0; }

public void acquire() { ++semaphore; }

public void release() { --semaphore; }

public InvariantState invariant() {

int val = semaphore;

if(val == 0 || val == 1)

return new InvariantOK();

else

return new InvariantFailure(new Integer(val));

}

} ///:~

类的核心部分很直接,包括了available( ), acquire( ), 和release( )方法。既然线程在获取资源的时候要检查其可用性,所以信号量的值一定不能是0或1以外的值,这将由invariant( )来测试。

但是请看,当测试Semaphore的线程一致性时发生的情况:

//: c13:SemaphoreTester.java

// Colliding over shared resources

public class SemaphoreTester extends Thread {

private volatile Semaphore semaphore;

public SemaphoreTester(Semaphore semaphore) {

this.semaphore = semaphore;

setDaemon(true);

start();

}

public void run() {

while(true)

if(semaphore.available()) {

yield(); // Makes it fail faster

semaphore.acquire();

yield();

semaphore.release();

yield();

}

}

public static void main(String[] args) throws Exception {

Semaphore sem = new Semaphore();

new SemaphoreTester(sem);

new SemaphoreTester(sem);

new InvariantWatcher(sem).join();

}

} ///:~

SemaphoreTester创建了一个线程,此线程不断进行测试,以检查Semaphore对象是否可用,可用的话就获取它,然后释放。注意,semaphore的字段被标记为volatile,以确保编译器不会对任何读取此值的操作进行优化。

在main( )里,建立了两个SemaphoreTester线程,你会发现很快就发生了违反约束条件的事件。其原因是,一个线程可能从对available( )的调用返回为真,但在此线程调用acquire( )的时候,另一个线程可能已经调用了acquire( )并增加了semaphore字段的值。此时InvariantWatcher可能会发现字段的值太大;如果两个线程都调用了release( )以减少字段值后就会出现负数,这时就会出现值太小的情况。注意,主线程在InvariantWatcher上调用了join( ),这将使程序一直运行,直到发生失败。

在我的机器上,我发现如果加上对yield( )的调用,会导致约束条件的违例情况出现得更快,不过这会因操作系统和JVM实现的不同而有所不同。你应该去掉对yield( )的调用,自己试验一下;失败也许要很久才会发生,这也说明了当你编写多线程代码的时候,发现程序的瑕疵是多么困难。

这个类强调了并发编程的风险:如果写这么简单的类也会出问题的话,你永远不能信任对并发编程作出的任何假设。


解决共享资源竞争

基本上所有的多线程模式,在解决线程冲突问题的时候,都是采用“序列化”(serialize)访问共享资源的方案。这意味着在给定时刻只允许一个线程访问共享资源。通常这是通过在代码前面加上一条锁语句来实现的,这就保证了在一段时间内只有一个线程运行这段代码。因为锁语句产生了一种互相排斥的效果,所以常常称为“互斥量”(mutex)。

考虑一下屋子里的浴室:多个人(即多个线程)都希望能单独使用浴室(即共享资源)。为了使用浴室,一个人先敲门,看看是否能使用。如果没人的话,他就进入浴室并且锁上

门。这时其它人要使用浴室的话,就会被“阻挡”,所以他们要在浴室门口等待,直到浴室可以使用。

当浴室使用完毕,就该把浴室给其他人使用了,这个比喻就有点不太准确了。事实上,人们并没有排队,我们也不能确定谁将是下一个使用浴室的人,因为线程调度机制并不是确定性的。实际情况是:等待使用浴室的人们簇拥在浴室门口,当锁住浴室门的那个人打开锁准备离开的时候,离门最近的那个人可能进入浴室。如前所述,可以通过yield( )和setPriority( )来给线程调度机制一些建议,但这些建议未必会有多大效果,这取决于你的具体平台和JVM实现。

Java以提供关键字synchronized的形式,为防止资源冲突提供了内置支持。它的行为很像Semaphore类:当线程要执行被synchronized关键字守护的代码片断的时候,它将检查信号量是否存在,然后获取信号量,执行代码,释放信号量。不同的是,synchronized内置于语言,所以这种防护始终存在,不像Semaphore那样要明确使用才能工作。

典型的共享资源是以对象形式存在的内存片断,但也可以是文件,输入/输出端口,或者是打印机。要控制对共享资源的访问,你得先把它包装进一个对象。然后把所有要访问这个资源的方法标记为synchronized。也就是说,一旦某个线程处于一个标记为synchronized的方法中,那么在这个线程从该方法返回之前,其它要调用类中任何标记为synchronized方法的线程都会被阻塞。

一般来说类的数据成员都被声明为私有的,只能通过方法来访问这些数据。所以你可以把方法标记为synchronized来防止资源冲突。下面是如何声明synchronized方法:

synchronized void f() { /* ... */ }

synchronized void g(){ /* ... */ }

每个对象都含有一个单一的锁(也称为监视器),这个锁本身就是对象的一部分(你不用写任何特殊代码)。当你在对象上调用其任意synchronized方法的时候,此对象都被加锁,这时对象上的其它synchronized方法只有等到前一个方法调用完毕并释放了锁之后才能被调用。在上个例子里,如果对对象调用了f( ),对于这个对象就只能等到f( )调用结束并释放了锁之后,才能调用g( )。所以,对于某个对象,其所有synchronized方法共享同一个锁,这能防止多个线程同时访问对象所在的内存。

一个线程可以多次获得对象的锁。如果一个方法在同一个对象上调用了第二个方法,后者又调用了同一对象上的另一个方法,就会发生这种情况。JVM负责跟踪对象被加锁的次数。如果一个对象被解锁,其计数为0。在线程第一次给对象加锁的时候,计数变为1。每次线程在这个对象上获得了锁,计数都会增加。显然,只有首先获得了锁的线程才能允许继续获取多个锁。每当线程离开一个synchronized方法,计数减少,当计数为零的时候,锁被完全释放,此时别的线程就可以使用此资源。

针对每个类,也有一个锁(作为类的Class对象的一部分),所以synchronized static方法可以在类的范围内防止对静态数据的并发访问。


同步控制EvenGenerator

通过在EvenGenerator.java中加入synchronized关键字,我们就可以防止不希望的线程访问:

//: c13:SynchronizedEvenGenerator.java

// Using "synchronized" to prevent thread collisions

public

class SynchronizedEvenGenerator implements Invariant {

private int i;

public synchronized void next() { i++; i++; }

public synchronized int getValue() { return i; }

// Not synchronized so it can run at

// any time and thus be a genuine test:

public InvariantState invariant() {

int val = getValue();

if(val % 2 == 0)

return new InvariantOK();

else

return new InvariantFailure(new Integer(val));

}

public static void main(String[] args) {

SynchronizedEvenGenerator gen =

new SynchronizedEvenGenerator();

new InvariantWatcher(gen, 4000); // 4-second timeout

while(true)

gen.next();

}

} ///:~

你可以注意到next( )和getValue( )都使用了synchronized进行修饰。要是你只同步控制其中一个方法的话,那么另一个就可以随意地忽略对象锁,从而出现不负责任的调用。关键是:每个访问关键共享资源的方法必须全部是synchronized的,否则就会出错。另一方面,InvariantState没有同步控制,因为它只是进行测试,我们希望它在任意时刻都能被调用,这样它才能真正检查对象的状态。


原子操作

在有关Java线程的讨论中,一个常被提到的认识是“原子操作不需要进行同步控制”。“原子操作”(atomic operation)即不能被线程调度机制中断的操作;一旦操作开始,那

么它一定可以在可能发生的“上下文切换”(context switch)之前(切换到其它线程执行)执行完毕。

还有一个常被提到的知识是,如果问题中的变量类型是除long或double以外的基本类型,对这种变量进行简单的赋值或者返回值操作的时候,才算是原子操作。不包括long和double的原因是因为它们比其它基本类型要大,所以JVM不能把对它的读取或赋值当成是单一原子操作(也许JVM能够这么做,但这并不能保证)。然而,你只要给long或double加上volatile,操作就是原子的了。

如果你把原子操作的概念尝试着应用到SynchronizedEvenGenerator.java,你将注意到

public synchronized int getValue() { return i; }

是符合这个定义的。但如果试着去掉synchronized,测试将会失败。因为尽管return i确实是原子操作,去掉synchronized的话,将会出现当对象还处于不稳定的中间状态的时候就被别的线程读取了。所以在试图做这样的优化之前,你必须真正知道自己在做什么。而这并没有简单可行的规则。

作为第二个例子,考虑一下更简单的情况:一个产生序列号的类3。每次调用nextSerialNumber( ) ,它必须向调用者返回一个唯一值:

//: c13:SerialNumberGenerator.java

public class SerialNumberGenerator {

private static volatile int serialNumber = 0;

public static int nextSerialNumber() {

return serialNumber++;

}

} ///:~

SerialNumberGenerator基本上与你能想到的一样简单,如果你有C++或其它低级语言的背景,你可能认为自增加操作是一个原子操作,因为它通常可以用一条微处理器指令实现。然而,在JVM中的自增加操作并不是原子的,它牵涉到一次读和一次写,所以即使在这样简单的操作中,也为线程出问题提供了空间。

serialNumber字段标记成volatile,其原因是每个线程都可能拥有一个本地栈以维护一些变量的复本。如果把一个变量定义成volatile,就等于告诉编译器不要做任何优化,这些优化可能会移除那些使字段与线程里的本地数据复本保持同步的读写操作。

要证明这一点,我们需要一个不会用完内存的集合,这时,检测到问题要花很长时间。这里的CircularSet重用了用来存储整型数组的内存,并假设在你访问数组时,覆写值时发生冲突的可能性最小。add( )和contains( )方法标记为synchronized以防止线程冲突。

3受到Joshua Bloch的 《Effective Java》的启发, Addison-Wesley 2001,第190页。

//: c13:SerialNumberChecker.java

// Operations that may seem safe are not,

// when threads are present.

// Reuses storage so we don't run out of memory:

class CircularSet {

private int[] array;

private int len;

private int index = 0;

public CircularSet(int size) {

array = new int[size];

len = size;

// Initialize to a value not produced

// by the SerialNumberGenerator:

for(int i = 0; i < size; i++)

array[i] = -1;

}

public synchronized void add(int i) {

array[index] = i;

// Wrap index and write over old elements:

index = ++index % len;

}

public synchronized boolean contains(int val) {

for(int i = 0; i < len; i++)

if(array[i] == val) return true;

return false;

}

}

public class SerialNumberChecker {

private static CircularSet serials =

new CircularSet(1000);

static class SerialChecker extends Thread {

SerialChecker() { start(); }

public void run() {

while(true) {

int serial =

SerialNumberGenerator.nextSerialNumber();

if(serials.contains(serial)) {

System.out.println("Duplicate: " + serial);

System.exit(0);

}

serials.add(serial);

}

}

}

public static void main(String[] args) {

for(int i = 0; i < 10; i++)

new SerialChecker();

// Stop after 4 seconds:

new Timeout(4000, "No duplicates detected");

}

} ///:~

SerialNumberChecker含有一个静态的CircularSet,后者包含了所有已经生成的序列号,以及一个获取序列号并能确保其唯一的嵌套线程。通过建立多个线程来争夺序列号,你会发现线程很快就会得到重复的序列号,(注意这个程序在你的机器上可能并不冲突,但在一台多处理器的机器上确实检查到了冲突)。要解决这个问题,就得给nextSerialNumber( )方法加上synchronized关键字。

原子操作只有在对基本类型进行读取或赋值的时候才被认为是安全的。不过,正如在EvenGenerator.java中所见,原子操作也很容易访问到对象尚出于不稳定状态时的值,所以你不能做任何假设。不仅如此,原子操作也不保证对long和double类型能工作(尽管有些JVM实现确实能保证对long和double类型操作的原子性,但如果你依赖于这一点,你的代码就失去了可移植性)。

最安全的就是使用以下方针:

1。如果你要对类中的某个方法进行同步控制,最好同步所有方法。如果你忽略了其中一个,通常很难确定这么做是否会有负面影响。

2。当去除方法的同步控制时,要非常小心。通常这么做是基于性能方面的考虑,但在JDK1.3和JDK1.4中,同步控制所需的负担已经大大减少。此外,你只应该在使用了性能评价工具证实了同步控制确实是性能瓶颈的时候,才能这么做。


修正信号量

现在考虑Semaphore.java。看起来我们能通过给三个方法加上synchronized标记,来修正这个问题,就像这样:

//: c13:SynchronizedSemaphore.java

// Colliding over shared resources

public class SynchronizedSemaphore extends Semaphore {

private volatile int semaphore = 0;

public synchronized boolean available() {

return semaphore == 0;

}

public synchronized void acquire() { ++semaphore; }

public synchronized void release() { --semaphore; }

public InvariantState invariant() {

int val = semaphore;

if(val == 0 || val == 1)

return new InvariantOK();

else

return new InvariantFailure(new Integer(val));

}

public static void main(String[] args) throws Exception {

SynchronizedSemaphore sem =new SynchronizedSemaphore();

new SemaphoreTester(sem);

new SemaphoreTester(sem);

new InvariantWatcher(sem).join();

}

} ///:~

首先,SynchronizedSemaphore类就显得很奇怪:它是从Semaphore类继承而来,且所有被重载的方法都标记为synchronized,但这些方法的基类版本却不是。Java并不允许你在重载的时候改变方法的签名,但这却没有产生出错信息。这是因为synchronized关键字不属于方法签名的一部分,所以你才能把它加进来,而它也并不局限于重载。

从Semaphore类继承的原因是为了重用SemaphoreTester类。当你运行程序的时候你会发现程序还是会产生InvariantFailure错误。

为什么会失败呢?当线程检测到Semaphore可用时,即调用available( )并且返回为真的时候,对象上的锁已经被释放。这时,另一个线程可能会冲进来,并在前一个线程增加semaphore值的时候抢先增加。同时,前一个线程还在假设Semaphore对象是可用的,所以会继续向前并盲目的进入acquire( )方法,这就使对象处于不稳定的状态。这只不过是并发编程首要规则的又一次教训而已:永远不要做任何假设。

这个问题的唯一解决方案是,把测试可用性操作和获取操作作为一个单一的原子操作,这也就是synchronized关键字与对象的锁协作所共同提供的功能。也就是说,Java的锁和synchronized关键字属于内置的信号量机制,所以你不必自己再去发明一个。


临界区

有时,你只是希望防止多个线程同时访问方法内部的部分代码而不是整个方法。通过这种方式分离出来的代码段被称为“临界区”(critical section),它也使用synchronized关键字建立。这里,synchronized被用来指定某个对象,此对象的锁被用来对花括号内的代码进行同步控制:

synchronized(syncObject) {

// This code can be accessed

// by only one thread at a time

}

这也被称为“同步控制块”(synchronized block),在进入此段代码前,必须得到syncObject对象的锁。如果其它线程已经得到这个锁,那么就得等到锁被释放以后,才能进入临界区。

通过使用同步控制块而不是对整个方法进行同步控制,可以使多个线程访问对象的时间性能得到显著提高,下面的例子比较了这两种同步控制方法。此外,它也演示了如何把一个非保护类型的类,在其它类的保护和控制之下,应用于多线程的环境:

//: c13:CriticalSection.java

// Synchronizing blocks instead of entire methods. Also

// demonstrates protection of a non-thread-safe class

// with a thread-safe one.

import java.util.*;

class Pair { // Not thread-safe

private int x, y;

public Pair(int x, int y) {

this.x = x;

this.y = y;

}

public Pair() { this(0, 0); }

public int getX() { return x; }

public int getY() { return y; }

public void incrementX() { x++; }

public void incrementY() { y++; }

public String toString() {

return "x: " + x + ", y: " + y;

}

public class PairValuesNotEqualException

extends RuntimeException {

public PairValuesNotEqualException() {

super("Pair values not equal: " + Pair.this);

}

}

// Arbitrary invariant -- both variables must be equal:

public void checkState() {

if(x != y)

throw new PairValuesNotEqualException();

}

}

// Protect a Pair inside a thread-safe class:

abstract class PairManager {

protected Pair p = new Pair();

private List storage = new ArrayList();

public synchronized Pair getPair() {

// Make a copy to keep the original safe:

return new Pair(p.getX(), p.getY());

}

protected void store() { storage.add(getPair()); }

// A "template method":

public abstract void doTask();

}

// Synchronize the entire method:

class PairManager1 extends PairManager {

public synchronized void doTask() {

p.incrementX();

p.incrementY();

store();

}

}

// Use a critical section:

class PairManager2 extends PairManager {

public void doTask() {

synchronized(this) {

p.incrementX();

p.incrementY();

}

store();

}

}

class PairManipulator extends Thread {

private PairManager pm;

private int checkCounter = 0;

private class PairChecker extends Thread {

PairChecker() { start(); }

public void run() {

while(true) {

checkCounter++;

pm.getPair().checkState();

}

}

}

public PairManipulator(PairManager pm) {

this.pm = pm;

start();

new PairChecker();

}

public void run() {

while(true) {

pm.doTask();

}

}

public String toString() {

return "Pair: " + pm.getPair() +

" checkCounter = " + checkCounter;

}

}

public class CriticalSection {

public static void main(String[] args) {

// Test the two different approaches:

final PairManipulator

pm1 = new PairManipulator(new PairManager1()),

pm2 = new PairManipulator(new PairManager2());

new Timer(true).schedule(new TimerTask() {

public void run() {

System.out.println("pm1: " + pm1);

System.out.println("pm2: " + pm2);

System.exit(0);

}

}, 500); // run() after 500 milliseconds

}

} ///:~

正如注释中注明的,Pair不是线程安全的,因为它的约束条件(虽然是任意的)需要两个变量维护成相同的值。此外,如本章前面所述,自增操作不是线程安全的,并且因为没有方法被标记为synchronized,所以你不能保证一个Pair对象在多线程程序中不会被破坏。

PairManager类持有一个Pair对象并控制对它的访问。注意唯一的public方法是getPair( ),它是被同步控制的。对于抽象方法doTask( ),它的同步控制将在实现的时候进行处理。

至于PairManager类的结构,它的一些功能已经在基类中实现,并且其一个或多个抽象方法将在派生类中定义,这种结构在“设计模式”(Design Patterns )中称为“模板方法”(Template Method )4。设计模式使你得以把变化封装在代码里;在此,发生变化的部分是模板方法doTask( )。在PairManager1中整个doTask( )方法是被同步控制的,但在PairManager2中的doTask( )方法使用同步控制块进行同步。注意到synchronized关键字不属于方法签名的一部分,所以可以在重载方法的时候加上去。

PairManager2值得注意,store( )是一个protected方法,它不能被一般客户使用,只能被其子类使用。所以,对这个方法的调用没有必要进行同步控制,而是被放在同步控制块外面。

同步控制块必须指定一个对象才能进行同步,通常,最合理的对象就是在其上调用方法的当前对象: synchronized(this),在PairManager2中采用了这种方法。这样,当为同步控制块请求锁的时候,对象的其它同步控制方法就不能被调用了。所以其效果不过是缩小了同步控制的范围。

有时这并不符合你的要求,这时你可以创建一个单独的对象,并对其进行同步控制。下面的例子演示了当对象中的方法在不同的锁上同步的时候,两个线程可以访问同一个对象:

//: c13:SyncObject.java

// Synchronizing on another object

import com.bruceeckel.simpletest.*;

class DualSynch {

private Object syncObject = new Object();

public synchronized void f() {

System.out.println("Inside f()");

// Doesn't release lock:

try {

Thread.sleep(500);

} catch(InterruptedException e) {

throw new RuntimeException(e);

}

System.out.println("Leaving f()");

}

public void g() {

synchronized(syncObject) {

System.out.println("Inside g()");

try {

Thread.sleep(500);

} catch(InterruptedException e) {

throw new RuntimeException(e);

}

4参见《Design Patterns》,Gamma等著,Addison-Wesley 1995。

System.out.println("Leaving g()");

}

}

}

public class SyncObject {

private static Test monitor = new Test();

public static void main(String[] args) {

final DualSynch ds = new DualSynch();

new Thread() {

public void run() {

ds.f();

}

}.start();

ds.g();

monitor.expect(new String[] {

"Inside g()",

"Inside f()",

"Leaving g()",

"Leaving f()"

}, Test.WAIT + Test.IGNORE_ORDER);

}

} ///:~

DualSync 对象的f( )方法在this上同步(通过在整个方法上同步),g( )的同步控制块在syncObject对象上同步。因此,两个同步控制相互独立。在main( )中通过创建调用f( )的线程演示了这一点。main( )线程用来调用g( )。从输出中你能观察到两个方法同时运行,所以它们没有在对方的同步控制上阻塞。

我们回到CriticalSection.java,通过在一个线程中运行doTask( ),在另一个线程中运行内部类PairChecker的实例,创建了PairManipulator来测试两种不同类型的PairManager。要跟踪进行测试的频率,PairChecker在每次成功的时候增加checkCounter计数。在main( )中,建立了2个PairManipulator对象,并让它们运行一段时间。当Timer时间到期时,将执行run( )方法,它将显示每个PairManipulator的结果,然后退出。运行一下程序,你能看到类似下面的结果:

pm1: Pair: x: 58892, y: 58892 checkCounter = 44974

pm2: Pair: x: 73153, y: 73153 checkCounter = 100535

尽管你每次运行的结果可能会有很大不同,但一般来说,对于PairChecker的检查频率,PairManager1.doTask( )不允许有PairManager2.doTask( )那样多。后者采用同步控制块进行同步,所以对象不加锁的时间更长。这也是宁愿使用同步控制块而不是对整个方法进行同步控制的典型原因:使得其它线程能更多地访问(在安全的情况下尽可能多)。

当然,所有的同步控制都要靠程序员的勤奋工作:必须把访问共享资源的代码段包装进一个合适的同步控制块。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: