Java多线程编程实战指南:设计模式篇(第2版)
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

4.4 Guarded Suspension模式的评价与实现考量

Guarded Suspension模式使应用程序避免了样板式代码。Guarded Suspension模式的Blocker参与者所实现的线程挂起与唤醒功能固然可以由应用代码直接使用wait/notify或者java.util.concurrent.locks.Condition来实现,但是这里面涉及几个比较容易犯错的重要技术细节(在下面的内容中会提到)。这些细节如果散落在应用代码中,则会增加出错的概率。另外,应用直接编写代码来正确实现这些技术细节往往会导致许多样板式代码。这不仅增加了代码编写的工作量,也增加了出错的概率。相反,Guarded Suspension模式的Blocker参与者封装了这些易错的技术细节,从而减少了应用代码的编写工作量和出错的概率。

使关注点分离(Separation of Concern)。Guarded Suspension模式中的各个参与者分别只关注本模式所要解决的问题中的一个方面,各个参与者的职责是高度内聚(Cohesive)的。这使得Guarded Suspension模式便于理解和应用,其实现代码也便于阅读和维护。应用开发人员只需要根据应用的需要实现GuardedObject、ConcretePredicate和ConcreteGuardedAction这几个必须由应用实现的参与者,而其他参与者的实现都是可复用的。

可能增加Java虚拟机垃圾回收的负担。为了使GuardedAction实例的call方法能够访问受保护方法guardedMethod的参数,我们需要利用闭包(Closure)。因此,GuardedAction实例可能是在受保护方法中创建的。这意味着,在每次调用受保护方法的时候,都会有一个新的GuardedAction实例被创建。而这会增加Java虚拟机内存池Eden区域内存的占用,从而可能增加Java虚拟机垃圾回收的负担。如果应用所在Java虚拟机的内存池Eden区域的空间比较小,则需要特别注意创建GuardedAction实例可能导致的垃圾回收负担。

可能增加上下文切换(Context Switch)。严格来说,这点与Guarded Suspension模式本身无关。只不过,不管如何实现Guarded Suspension模式,只要其中涉及线程的暂挂和唤醒,就会引起上下文切换。如果频繁出现受保护方法被调用时保护条件不成立,那么受保护方法的执行线程就会频繁地被暂挂和唤醒,从而导致频繁的上下文切换。过于频繁的上下文切换会过多地消耗系统的CPU时间,从而降低系统的处理能力。

下面将介绍Blocker实现类中封装的几个易错的重要技术细节。

4.4.1 内存可见性和锁泄漏(Lock Leak)

保护条件中涉及的变量牵涉读线程和写线程进行共享访问。受保护方法的执行线程是读线程,它读取这些变量以判断保护条件是否成立。而写线程是受保护对象实例的stateChanged方法的执行线程,它会更改这些变量的值。因此,对保护条件涉及的变量的访问应该使用锁进行保护,以保证对于写线程对这些变量所做的更改,读线程能够“看到”相应的值。从清单4-5中的代码可以看出,这点已经被封装在Blocker实例的几个方法中了。应用代码只需要在创建Blocker实例时在其构造器中指定恰当的锁实例即可。

ConditionVarBlocker类(代码见清单4-5)为了保证保护条件中涉及的变量的内存可见性而引入了ReentrantLock锁。使用该锁时需要注意临界区中的代码无论是执行正常还是出现异常,进入临界区前获得的锁实例都应该被释放。否则,就会出现锁泄漏现象:锁对象被某个线程获得,但永远不会被该线程释放,导致其他线程无法获得该锁。为了避免锁泄漏,使用ReentrantLock的临界区代码总是需要按照如下格式来编写:

4.4.2 线程被过早地唤醒

ConditionVarBlocker类的callWithGuard方法对Condition实例的await方法的调用是放在一个while循环中的,而不是放在if语句中的。这是因为Condition实例的await方法使当前线程暂挂后,其他线程虽然可以通过调用Condition实例的signal或者signalAll方法来唤醒被暂挂的线程,但是并不能保证此时保护条件就是成立的。因此,这个时候callWithGuard方法应该继续检测保护条件,直到保护条件成立。

ConditionVarBlocker类是基于Condition接口实现的。由callWithGuard方法的签名可知,一个Condition实例可以对应多个受保护方法。假设某Condition的实例aCondition对应多个保护条件predicateA和predicateB。当某个线程threadA发现predicateA成立时,它调用了aCondition的notifyAll方法,那么此时所有被aCondition暂挂的线程都会被唤醒。这些被唤醒的线程中的某个线程threadB需要等待的保护条件是predicateB,显然这时predicateB不一定成立。也就是,这时线程threadB被“过早”地唤醒了[5]

导致线程被“过早”地唤醒的因素还有所谓的欺骗性唤醒(Spurious Wakeup),即在没有其他任何线程调用Condition实例的notify/notifyAll方法的情况下,Condition实例的await方法就返回了。

因此,为了应对被暂挂线程可能被“过早”地唤醒的情形,将callWithGuard方法对保护条件的检测和对Condition实例的await方法的调用总是放在一个while循环而非if语句中。代码样板如下:

在应用Guarded Suspension模式的时候还需要注意嵌套监视器锁死问题(Nested Monitor Lockout),具体介绍可见4.4.3节。

4.4.3 嵌套监视器锁死

ConditionVarBlocker实例的callWithGuard方法和signalAfter/signal/broadcastAfter/broadcast方法本身涉及由java.util.concurrent.locks.Lock实例实现的同步块。如果这些方法又在另一个同步块代码中被调用,那么这就形成了嵌套同步。而嵌套同步可能产生锁死的问题(类似死锁)。下面看一个嵌套同步导致锁死(即嵌套监视器锁死)的示例代码(见清单4-6)。

清单4-6 嵌套监视器锁死示例代码

如清单4-6所示的程序,如果我们将其xGuardedMethod方法和xStateChanged方法的synchronized关键字去掉,那么运行该程序可以得到类似如下的输出:

但是,如果保留xGuardedMethod方法和xStateChanged方法的synchronized关键字,那么上述程序运行的时候,程序的输出始终只有上面显示的输出的第1行。这说明xGuardedMethod方法一直没有返回。

xGuardedMethod方法最终会调用java.util.concurrent.locks.Condition实例的await方法。Condition实例的await方法在其被调用之后且返回之前会释放与其所属的Condition实例所关联的锁,但是xGuardedMethod方法本身所获得的锁(即guardedMethod方法所属的NestedMonitorLockoutExample实例)并没有被释放。而Condition实例的await方法要返回需要其他线程调用Condition实例的signal/signalAll方法。但是,调用xStateChanged方法所需获得的锁此时还是被xGuardedMethod方法的执行线程所保留。这就意味着其他线程无法通过调用xStateChanged方法使得xGuardedMethod方法所调用的Condition实例的await方法返回,而这点又导致无法获得xStateChanged方法调用时所需的锁。这就形成了锁死,如图4-4所示。

图4-4 嵌套监视器锁死的线程示例

从如图4-4所示的调用栈(Call Stack)可知,线程Thread-0和线程Timer-0一直都处于这样一个状态:前者拥有一个锁,而后者等待获取前者拥有的锁,但是前者又等待后者唤醒自己,之后才会释放其拥有的锁。

如果我们能够避免不必要的嵌套同步,那么嵌套监视器锁死的问题也就可以避免。ConditionVarBlocker的构造器支持传入一个java.util.concurrent.locks.Lock实例,这正是出于避免不必要的嵌套同步的考虑。该构造器使得在ConditionVarBlocker调用Condition实例的相关方法时可以使用指定的Lock实例,而不是使用ConditionVarBlocker实例自己创建的Lock实例。