Vincent

Vicent's blog
随笔 - 74, 文章 - 0, 评论 - 5, 引用 - 0
数据加载中……

轻松使用线程: 同步不是敌人

与许多其它的编程语言不同,Java语言规范包括对线程和并发的明确支持。语言本身支持并发,这使得指定和管理共享数据的约束以及跨线程操作的计时变得更简单,但是这没有使得并发编程的复杂性更易于理解。这个三部分的系列文章的目的在于帮助程序员理解用Java 语言进行多线程编程的一些主要问题,特别是线程安全对 Java程序性能的影响。

请点击文章顶部或底部的 讨论进入由 Brian Goetz 主持的 “Java线程:技巧、窍门和技术”讨论论坛,与本文作者和其他读者交流您对本文或整个多线程的想法。注意该论坛讨论的是使用多线程时遇到的所有问题,而并不限于本文的内容。

大多数编程语言的语言规范都不会谈到线程和并发的问题;因为一直以来,这些问题都是留给平台或操作系统去详细说明的。但是,Java 语言规范(JLS)却明确包括一个线程模型,并提供了一些语言元素供开发人员使用以保证他们程序的线程安全。

对线程的明确支持有利也有弊。它使得我们在写程序时更容易利用线程的功能和便利,但同时也意味着我们不得不注意所写类的线程安全,因为任何类都很有可能被用在一个多线程的环境内。

许多用户第一次发现他们不得不去理解线程的概念的时候,并不是因为他们在写创建和管理线程的程序,而是因为他们正在用一个本身是多线程的工具或框架。任何用过 Swing GUI 框架或写过小服务程序或 JSP 页的开发人员(不管有没有意识到)都曾经被线程的复杂性困扰过。

Java 设计师是想创建一种语言,使之能够很好地运行在现代的硬件,包括多处理器系统上。要达到这一目的,管理线程间协调的工作主要推给了软件开发人员;程序员必须指定线程间共享数据的位置。在 Java 程序中,用来管理线程间协调工作的主要工具是 synchronized 关键字。在缺少同步的情况下,JVM 可以很自由地对不同线程内执行的操作进行计时和排序。在大部分情况下,这正是我们想要的,因为这样可以提高性能,但它也给程序员带来了额外的负担,他们不得不自己识别什么时候这种性能的提高会危及程序的正确性。

synchronized 真正意味着什么?

大部分 Java 程序员对同步的块或方法的理解是完全根据使用互斥(互斥信号量)或定义一个临界段(一个必须原子性地执行的代码块)。虽然 synchronized 的语义中确实包括互斥和原子性,但在管程进入之前和在管程退出之后发生的事情要复杂得多。

synchronized 的语义确实保证了一次只有一个线程可以访问被保护的区段,但同时还包括同步线程在主存内互相作用的规则。理解 Java 内存模型(JMM)的一个好方法就是把各个线程想像成运行在相互分离的处理器上,所有的处理器存取同一块主存空间,每个处理器有自己的缓存,但这些缓存可能并不总和主存同步。在缺少同步的情况下,JMM 会允许两个线程在同一个内存地址上看到不同的值。而当用一个管程(锁)进行同步的时候,一旦申请加了锁,JMM 就会马上要求该缓存失效,然后在它被释放前对它进行刷新(把修改过的内存位置写回主存)。不难看出为什么同步会对程序的性能影响这么大;频繁地刷新缓存代价会很大。





回页首


使用一条好的运行路线

如果同步不适当,后果是很严重的:会造成数据混乱和争用情况,导致程序崩溃,产生不正确的结果,或者是不可预计的运行。更糟的是,这些情况可能很少发生且具有偶然性(使得问题很难被监测和重现)。如果测试环境和开发环境有很大的不同,无论是配置的不同,还是负荷的不同,都有可能使得这些问题在测试环境中根本不出现,从而得出错误的结论:我们的程序是正确的,而事实上这些问题只是还没出现而已。

争用情况定义

争用情况是一种特定的情况:两个或更多的线程或进程读或写一些共享数据,而最终结果取决于这些线程是如何被调度计时的。争用情况可能会导致不可预见的结果和隐蔽的程序错误。

另一方面,不当或过度地使用同步会导致其它问题,比如性能很差和死锁。当然,性能差虽然不如数据混乱那么严重,但也是一个严重的问题,因此同样不可忽视。编写优秀的多线程程序需要使用好的运行路线,足够的同步可以使您的数据不发生混乱,但不需要滥用到去承担死锁或不必要地削弱程序性能的风险。





回页首


同步的代价有多大?

由于包括缓存刷新和设置失效的过程,Java 语言中的同步块通常比许多平台提供的临界段设备代价更大,这些临界段通常是用一个原子性的“test and set bit”机器指令实现的。即使一个程序只包括一个在单一处理器上运行的单线程,一个同步的方法调用仍要比非同步的方法调用慢。如果同步时还发生锁定争用,那么性能上付出的代价会大得多,因为会需要几个线程切换和系统调用。

幸运的是,随着每一版的 JVM 的不断改进,既提高了 Java 程序的总体性能,同时也相对减少了同步的代价,并且将来还可能会有进一步的改进。此外,同步的性能代价经常是被夸大的。一个著名的资料来源就曾经引证说一个同步的方法调用比一个非同步的方法调用慢 50 倍。虽然这句话有可能是真的,但也会产生误导,而且已经导致了许多开发人员即使在需要的时候也避免使用同步。

严格依照百分比计算同步的性能损失并没有多大意义,因为一个无争用的同步给一个块或方法带来的是固定的性能损失。而这一固定的延迟带来的性能损失百分比取决于在该同步块内做了多少工作。对一个 方法的同步调用可能要比对一个空方法的非同步调用慢 20 倍,但我们多长时间才调用一次空方法呢?当我们用更有代表性的小方法来衡量同步损失时,百分数很快就下降到可以容忍的范围之内。

表 1 把一些这种数据放在一起来看。它列举了一些不同的实例,不同的平台和不同的 JVM 下一个同步的方法调用相对于一个非同步的方法调用的损失。在每一个实例下,我运行一个简单的程序,测定循环调用一个方法 10,000,000 次所需的运行时间,我调用了同步和非同步两个版本,并比较了结果。表格中的数据是同步版本的运行时间相对于非同步版本的运行时间的比率;它显示了同步的性能损失。每次运行调用的都是清单 1 中的简单方法之一。

表格 1 中显示了同步方法调用相对于非同步方法调用的相对性能;为了用绝对的标准测定性能损失,必须考虑到 JVM 速度提高的因素,这并没有在数据中体现出来。在大多数测试中,每个 JVM 的更高版本都会使 JVM 的总体性能得到很大提高,很有可能 1.4 版的 Java 虚拟机发行的时候,它的性能还会有进一步的提高。

表 1. 无争用同步的性能损失

JDK staticEmpty empty fetch hashmapGet singleton create
Linux / JDK 1.1 9.2 2.4 2.5 n/a 2.0 1.42
Linux / IBM Java SDK 1.1 33.9 18.4 14.1 n/a 6.9 1.2
Linux / JDK 1.2 2.5 2.2 2.2 1.64 2.2 1.4
Linux / JDK 1.3 (no JIT) 2.52 2.58 2.02 1.44 1.4 1.1
Linux / JDK 1.3 -server 28.9 21.0 39.0 1.87 9.0 2.3
Linux / JDK 1.3 -client 21.2 4.2 4.3 1.7 5.2 2.1
Linux / IBM Java SDK 1.3 8.2 33.4 33.4 1.7 20.7 35.3
Linux / gcj 3.0 2.1 3.6 3.3 1.2 2.4 2.1
Solaris / JDK 1.1 38.6 20.1 12.8 n/a 11.8 2.1
Solaris / JDK 1.2 39.2 8.6 5.0 1.4 3.1 3.1
Solaris / JDK 1.3 (no JIT) 2.0 1.8 1.8 1.0 1.2 1.1
Solaris / JDK 1.3 -client 19.8 1.5 1.1 1.3 2.1 1.7
Solaris / JDK 1.3 -server 1.8 2.3 53.0 1.3 4.2 3.2

清单 1. 基准测试中用到的简单方法
												
														 public static void staticEmpty() {  }

  public void empty() {  }

  public Object fetch() { return field; }

  public Object singleton() {
    if (singletonField == null)
      singletonField = new Object();
    return singletonField;
  }

  public Object hashmapGet() {
    return hashMap.get("this");
  }

  public Object create() { 
    return new Object();
  }

												
										

这些小基准测试也阐明了存在动态编译器的情况下解释性能结果所面临的挑战。对于 1.3 JDK 在有和没有 JIT 时,数字上的巨大差异需要给出一些解释。对那些非常简单的方法( emptyfetch ),基准测试的本质(它只是执行一个几乎什么也不做的紧凑的循环)使得 JIT 可以动态地编译整个循环,把运行时间压缩到几乎没有的地步。但在一个实际的程序中,JIT 能否这样做就要取决于很多因素了,所以,无 JIT 的计时数据可能在做公平对比时更有用一些。在任何情况下,对于更充实的方法( createhashmapGet ),JIT 就不能象对更简单些的方法那样使非同步的情况得到巨大的改进。另外,从数据中看不出 JVM 是否能够对测试的重要部分进行优化。同样,在可比较的 IBM 和 Sun JDK 之间的差异反映了 IBM Java SDK 可以更大程度地优化非同步的循环,而不是同步版本代价更高。这在纯计时数据中可以明显地看出(这里不提供)。

从这些数字中我们可以得出以下结论:对非争用同步而言,虽然存在性能损失,但在运行许多不是特别微小的方法时,损失可以降到一个合理的水平;大多数情况下损失大概在 10% 到 200% 之间(这是一个相对较小的数目)。所以,虽然同步每个方法是不明智的(这也会增加死锁的可能性),但我们也不需要这么害怕同步。这里使用的简单测试是说明一个无争用同步的代价要比创建一个对象或查找一个 HashMap 的代价小。

由于早期的书籍和文章暗示了无争用同步要付出巨大的性能代价,许多程序员就竭尽全力避免同步。这种恐惧导致了许多有问题的技术出现,比如说 double-checked locking(DCL)。许多关于 Java 编程的书和文章都推荐 DCL,它看上去真是避免不必要的同步的一种聪明的方法,但实际上它根本没有用,应该避免使用它。DCL 无效的原因很复杂,已超出了本文讨论的范围(要深入了解,请参阅 参考资料里的链接)。





回页首


不要争用

假设同步使用正确,若线程真正参与争用加锁,您也能感受到同步对实际性能的影响。并且无争用同步和争用同步间的性能损失差别很大;一个简单的测试程序指出争用同步比无争用同步慢 50 倍。把这一事实和我们上面抽取的观察数据结合在一起,可以看出使用一个争用同步的代价至少相当于创建 50 个对象。

所以,在调试应用程序中同步的使用时,我们应该努力减少实际争用的数目,而根本不是简单地试图避免使用同步。这个系列的第 2 部分将把重点放在减少争用的技术上,包括减小锁的粒度、减小同步块的大小以及减小线程间共享数据的数量。





回页首


什么时候需要同步?

要使您的程序线程安全,首先必须确定哪些数据将在线程间共享。如果正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。有些程序员可能会惊讶地发现,这些规则在简单地检查一个共享引用是否非空的时候也用得上。

许多人会发现这些定义惊人地严格。有一种普遍的观点是,如果只是要读一个对象的字段,不需要请求加锁,尤其是在 JLS 保证了 32 位读操作的原子性的情况下,它更是如此。但不幸的是,这个观点是错误的。除非所指的字段被声明为 volatile ,否则 JMM 不会要求下面的平台提供处理器间的缓存一致性和顺序连贯性,所以很有可能,在某些平台上,没有同步就会读到陈旧的数据。有关更详细的信息,请参阅 参考资料

在确定了要共享的数据之后,还要确定要如何保护那些数据。在简单情况下,只需把它们声明为 volatile 即可保护数据字段;在其它情况下,必须在读或写共享数据前请求加锁,一个很好的经验是明确指出使用什么锁来保护给定的字段或对象,并在你的代码里把它记录下来。

还有一点值得注意的是,简单地同步存取器方法(或声明下层的字段为 volatile )可能并不足以保护一个共享字段。可以考虑下面的示例:

												
														 ...
  private int foo;
  public synchronized int getFoo() { return foo; } 
  public synchronized void setFoo(int f) { foo = f; }

												
										

如果一个调用者想要增加 foo 属性值,以下完成该功能的代码就不是线程安全的:

												
														 ...
  setFoo(getFoo() + 1);

												
										

如果两个线程试图同时增加 foo 属性值,结果可能是 foo 的值增加了 1 或 2,这由计时决定。调用者将需要同步一个锁,才能防止这种争用情况;一个好方法是在 JavaDoc 类中指定同步哪个锁,这样类的调用者就不需要自己猜了。

以上情况是一个很好的示例,说明我们应该注意多层次粒度的数据完整性;同步存取器方法确保调用者能够存取到一致的和最近版本的属性值,但如果希望属性的将来值与当前值一致,或多个属性间相互一致,我们就必须同步复合操作 ― 可能是在一个粗粒度的锁上。





回页首


如果情况不确定,考虑使用同步包装

有时,在写一个类的时候,我们并不知道它是否要用在一个共享环境里。我们希望我们的类是线程安全的,但我们又不希望给一个总是在单线程环境内使用的类加上同步的负担,而且我们可能也不知道使用这个类时合适的锁粒度是多大。幸运的是,通过提供同步包装,我们可以同时达到以上两个目的。Collections 类就是这种技术的一个很好的示例;它们是非同步的,但在框架中定义的每个接口都有一个同步包装(例如, Collections.synchronizedMap() ),它用一个同步的版本来包装每个方法。





回页首


结论

虽然 JLS 给了我们可以使我们的程序线程安全的工具,但线程安全也不是天上掉下来的馅饼。使用同步会蒙受性能损失,而同步使用不当又会使我们承担数据混乱、结果不一致或死锁的风险。幸运的是,在过去的几年内 JVM 有了很大的改进,大大减少了与正确使用同步相关的性能损失。通过仔细分析在线程间如何共享数据,适当地同步对共享数据的操作,可以使得您的程序既是线程安全的,又不会承受过多的性能负担。

posted @ 2006-08-24 17:44 Binary 阅读(265) | 评论 (0)编辑 收藏

Java 理论与实践: 做个好的(事件)侦听器

观察者模式在 Swing 开发中很常见,在 GUI 应用程序以外的场景中,它对于消除组件的耦合性也非常有用。但是,仍然存在一些侦听器登记和调用方面的常见缺陷。在 Java 理论与实践 的这一期中,Java 专家 Brian Goetz 就如何做一个好的侦听器,以及如何对您的侦听器也友好,提供了一些感觉很好的建议。请在相应的 讨论论坛 上与作者和其他读者分享您对这篇文章的想法。(您也可以单击本文顶部或底部的 讨论 访问论坛。)

Swing 框架以事件侦听器的形式广泛利用了观察者模式(也称为发布-订阅模式)。Swing 组件作为用户交互的目标,在用户与它们交互的时候触发事件;数据模型类在数据发生变化时触发事件。用这种方式使用观察者,可以让控制器与模型分离,让模型与视图分离,从而简化 GUI 应用程序的开发。

“四人帮”的 设计模式 一书(参阅 参考资料)把观察者模式描述为:定义对象之间的“一对多”关系,这样一个对象改变状态时,所有它的依赖项都会被通知,并自动更新。观察者模式支持组件之间的松散耦合;组件可以保持它们的状态同步,却不需要直接知道彼此的标识或内部情况,从而促进了组件的重用。

AWT 和 Swing 组件(例如 JButtonJTable)使用观察者模式消除了 GUI 事件生成与它们在指定应用程序中的语义之间的耦合。类似地,Swing 的模型类,例如 TableModelTreeModel,也使用观察者消除数据模型表示 与视图生成之间的耦合,从而支持相同数据的多个独立的视图。Swing 定义了 EventEventListener 对象层次结构;可以生成事件的组件,例如 JButton(可视组件) 或 TableModel(数据模型),提供了 addXxxListener()removeXxxListener() 方法,用于侦听器的登记和取消登记。这些类负责决定什么时候它们需要触发事件,什么时候确实触发事件,以及什么时候调用所有登记的侦听器。

为了支持侦听器,对象需要维护一个已登记的侦听器列表,提供侦听器登记和取消登记的手段,并在适当的事件发生时调用每个侦听器。使用和支持侦听器很容易(不仅仅在 GUI 应用程序中),但是在登记接口的两边(它们是支持侦听器的组件和登记侦听器的组件)都应当避免一些缺陷。

线程安全问题

通常,调用侦听器的线程与登记侦听器的线程不同。要支持从不同线程登记侦听器,那么不管用什么机制存储和管理活动侦听器列表,这个机制都必须是线程安全的。Sun 的文档中的许多示例使用 Vector 保存侦听器列表,它解决了部分问题,但是没有解决全部问题。在事件触发时,触发它的组件会考虑迭代侦听器列表,并调用每个侦听器,这就带来了并发修改的风险,比如在侦听器列表迭代期间,某个线程偶然想添加或删除一个侦听器。

管理侦听器列表

假设您使用 Vector<Listener> 保存侦听器列表。虽然 Vector 类是线程安全的(意味着不需要进行额外的同步就可调用它的方法,没有破坏 Vector 数据结构的风险),但是集合的迭代中包含“检测然后执行”序列,如果在迭代期间集合被修改,就有了失败的风险。假设迭代开始时列表中有三个侦听器。在迭代 Vector 时,重复调用 size()get() 方法,直到所有元素都检索完,如清单 1 所示:


清单 1. Vector 的不安全迭代
												
														Vector<Listener> v;
for (int i=0; i<v.size(); i++)
  v.get(i).eventHappened(event);

												
										

但是,如果恰好就在最后一次调用 Vector.size() 之后,有人从列表中删除了一个侦听器,会发生什么呢?现在,Vector.get() 将返回 null (这是对的,因为从上次检测 vector 的状态以来,它的状态已经变了),而在试图调用 eventHappened() 时,会抛出 NullPointerException。这是“检测然后执行”序列的一个示例 —— 检测是否存在更多元素,如果存在,就取得下一元素 —— 但是在存在并发修改的情况下,检测之后状态可能已经变化。图 1 演示了这个问题:

图 1. 并发迭代和修改,造成意料之外的失败

并发迭代和修改,造成意料之外的失败

这个问题的一个解决方案是在迭代期间持有对 Vector 的锁;另一个方案是克隆 Vector 或调用它的 toArray() 方法,在每次发生事件时检索它的内容。所有这两个方法都有性能上的问题:第一个的风险是在迭代期间,会把其他想访问侦听器列表的线程锁在外面;第二个则要创建临时对象,而且每次事件发生时都要拷贝列表。

如果用迭代器(Iterator)去遍历侦听器列表,也会有同样的问题,只是表现略有不同; iterator() 实现不抛出 NullPointerException,它在探测到迭代开始之后集合发生修改时,会抛出 ConcurrentModificationException。同样,也可以通过在迭代期间锁定集合防止这个问题。

java.util.concurrent 中的 CopyOnWriteArrayList 类,能够帮助防止这个问题。它实现了 List,而且是线程安全的,但是它的迭代器不会抛出 ConcurrentModificationException,遍历期间也不要求额外的锁定。这种特性组合是通过在每次列表修改时,在内部重新分配并拷贝列表内容而实现的,这样,遍历内容的线程不需要处理变化 —— 从它们的角度来说,列表的内容在遍历期间保持不变。虽然这听起来可能没效率,但是请记住,在多数观察者情况下,每个组件只有少量侦听器,遍历的数量远远超过插入和删除的数量。所以更快的迭代可以补偿较慢的变化过程,并提供更好的并发性,因为多个线程可以同时迭代列表。

初始化的安全风险

从侦听器的构造函数中登记它很诱惑人,但是这是一个应当避免的诱惑。它仅会造成“失效侦听器(lapsed listener)的问题(我稍后讨论它),而且还会造成多个线程安全问题。清单 2 显示了一个看起来没什么害处的同时构造和登记侦听器的企图。问题是:它造成到对象的“this”引用在对象完全构造完成之前转义。虽然看起来没什么害处,因为登记是构造函数做的最后一件事,但是看到的东西是有欺骗性的:


清单 2. 事件侦听器允许“this”引用转义,造成问题
												
														public class EventListener { 

  public EventListener(EventSource eventSource) {
    // do our initialization
    ...

    // register ourselves with the event source
    eventSource.registerListener(this);
  }

  public onEvent(Event e) { 
    // handle the event
  }
}

												
										

在继承事件侦听器的时候,会出现这种方法的一个风险:这时,子类构造函数做的任何工作都是在 EventListener 构造函数运行之后进行的,也就是在 EventListener 发布之后,所以会造成争用情况。在某些不幸的时候,清单 3 中的 onEvent 方法会在列表字段还没初始化之前就被调用,从而在取消 final 字段的引用时,会生成非常让人困惑的 NullPointerException 异常:


清单 3. 继承清单 2 的 EventListener 类造成的问题
												
														public class RecordingEventListener extends EventListener {
  private final ArrayList<Event> list;

  public RecordingEventListener(EventSource eventSource) {
    super(eventSource);
    list = Collections.synchronizedList(new ArrayList<Event>());
  }

  public onEvent(Event e) { 
    list.add(e);
    super.onEvent(e);
  }
}

												
										

即使侦听器类是 final 的,不能派生子类,也不应当允许“this”引用在构造函数中转义 —— 这样做会危害 Java 内存模型的某些安全保证。如果“this”这个词不会出现在程序中,就可让“this”引用转义;发布一个非静态内部类实例可以达到相同的效果,因为内部类持有对它包围的对象的“this”引用的引用。偶然地允许“this”引用转义的最常见原因,就是登记侦听器,如清单 4 所示。事件侦听器不应当在构造函数中登记!


清单 4. 通过发布内部类实例,显式地允许“this”引用转义
												
														public class EventListener2 {
  public EventListener2(EventSource eventSource) {

    eventSource.registerListener(
      new EventListener() {
        public void onEvent(Event e) { 
          eventReceived(e);
        }
      });
  }

  public void eventReceived(Event e) {
  }
}

												
										

侦听器线程安全

使用侦听器造成的第三个线程安全问题来自这个事实:侦听器可能想访问应用程序数据,而调用侦听器的线程通常不直接在应用程序的控制之下。如果在 JButton 或其他 Swing 组件上登记侦听器,那么会从 EDT 调用该侦听器。侦听器的代码可以从 EDT 安全地调用 Swing 组件上的方法,但是如果对象本身不是线程安全的,那么从侦听器访问应用程序对象会给应用程序增加新的线程安全需求。

Swing 组件生成的事件是用户交互的结果,但是 Swing 模型类是在 fireXxxEvent() 方法被调用的时候生成事件。这些方法又会在调用它们的线程中调用侦听器。因为 Swing 模型类不是线程安全的,而且假设被限制在 EDT 内,所以对 fireXxxEvent() 的任何调用也都应当从 EDT 执行。如果想从另外的线程触发事件,那么应当用 Swing 的 invokeLater() 功能让方法转而在 EDT 内调用。一般来说,要注意调用事件侦听器的线程,还要保证它们涉及的任何对象或者是线程安全的,或者在访问它们的地方,受到适当的同步(或者是 Swing 模型类的线程约束)的保护。





回页首


失效侦听器

不管什么时候使用观察者模式,都耦合着两个独立组件 —— 观察者和被观察者,它们通常有不同的生命周期。登记侦听器的后果之一就是:它在被观察对象和侦听器之间建立起很强的引用关系,这种关系防止侦听器(以及它引用的对象)被垃圾收集,直到侦听器取消登记为止。在许多情况下,侦听器的生命周期至少要和被观察的组件一样长 —— 许多侦听器会在整个应用程序期间都存在。但是在某些情况下,应当短期存在的侦听器最后变成了永久的,它们这种无意识的拖延的证据就是应用程序性能变慢、高于必需的内存使用。

“失效侦听器”的问题可以由设计级别上的不小心造成:没有恰当地考虑包含的对象的寿命,或者由于松懈的编码。侦听器登记和取消登记应当结对进行。但是即使这么做,也必须保证是在正确的时间执行取消登记。清单 5 显示了会造成失效侦听器的编码习惯的示例。它在组件上登记侦听器,执行某些动作,然后取消登记侦听器:


清单 5. 有造成失效侦听器风险的代码
												
														  public void processFile(String filename) throws IOException {
    cancelButton.registerListener(this);
    // open file, read it, process it
    // might throw IOException
    cancelButton.unregisterListener(this);
  }

												
										

清单 5 的问题是:如果文件处理代码抛出了 IOException —— 这是很有可能的 —— 那么侦听器就永远不会取消登记,这就意味着它永远不会被垃圾收集。取消登记的操作应当在 finally 块中进行,这样,processFile() 方法的所有出口都会执行它。

有时推荐的一个处理失效侦听器的方法是使用弱引用。虽然这种方法可行,但是实现起来很麻烦。要让它工作,需要找到另外一个对象,它的生命周期恰好是侦听器的生命周期,并安排它持有对侦听器的强引用,这可不是件容易的事。

另外一项可以用来找到隐藏失效侦听器的技术是:防止指定侦听器对象在指定事件源上登记两次。这种情况通常是 bug 的迹象 —— 侦听器登记了,但是没有取消登记,然后再次登记。不用检测问题,就能缓解这个问题的影响的一种方式是:使用 Set 代替 List 来存储侦听器;或者也可以检测 List,在登记侦听器之前检查是否已经登记了,如果已经登记,就抛出异常(或记录错误),这样就可以搜集编码错误的证据,并采取行动。





回页首


其他侦听器问题

在编写侦听器时,应当一直注意它们将要执行的环境。不仅要注意线程安全问题,还需要记住:侦听器也可以用其他方式为它的调用者把事情搞糟。侦听器 不该 做的一件事是:阻塞相当长一段时间(长得可以感觉得到);调用它的执行上下文很可能希望迅速返回控制。如果侦听器要执行一个可能比较费时的操作,例如处理大型文本,或者要做的工作可能阻塞,例如执行 socket IO,那么侦听器应当把这些操作安排在另一个线程中进行,这样它就可以迅速返回它的调用者。

对于不小心的事件源,侦听器会造成麻烦的另一个方式是:抛出未检测的异常。虽然大多数时候,我们不会故意抛出未检测异常,但是确实有些时候会发生这种情况。如果使用清单 1 的方式调用侦听器,列表中的第二个侦听器就会抛出未检测异常,那么不仅后续的侦听器得不到调用(可能造成应用程序处在不一致的状态),而且有可能把执行它的线程破坏掉,从而造成局部应用程序失败。

在调用未知代码(侦听器就是这样的代码)时,谨慎的方式是在 try-catch 块中执行它,这样,行为有误的侦听器不会造成更多不必要的破坏。对于抛出未检测异常的侦听器,您可能想自动对它取消登记,毕竟,抛出未检测异常就证明侦听器坏掉了。(您可能还想记录这个错误或者提醒用户注意,好让用户能够知道为什么程序停止像期望的那样继续工作。)清单 6 显示了这种方式的一个示例,它在迭代循环内部嵌套了 try-catch 块:


清单 6. 健壮的侦听器调用
												
														List<Listener> list;
for (Iterator<Listener> i=list.iterator; i.hasNext(); ) {
    Listener l = i.next();
    try {
        l.eventHappened(event);
    }
    catch (RuntimeException e) {
        log("Unexpected exception in listener", e);
        i.remove();
    }
}

												
										





回页首


结束语

观察者模式对于创建松散耦合的组件、鼓励组件重用非常有用,但是它有一些风险,侦听器的编写者和组件的编写者都应当注意。在登记侦听器时,应当一直注意侦听器的生命周期。如果侦听器的寿命应当比应用程序的短,那么请确保取消它的登记,这样它就可以被垃圾收集。在编写侦听器和组件时,请注意它包含的线程安全性问题。侦听器涉及的任何对象,都应当是线程安全的,或者是受线程约束的对象(例如 Swing 模型),侦听器应当确定自己正在正确的线程中执行。

posted @ 2006-08-24 17:43 Binary 阅读(216) | 评论 (0)编辑 收藏

Java 理论与实践: 用弱引用堵住内存泄漏

虽然用 Java™ 语言编写的程序在理论上是不会出现“内存泄漏”的,但是有时对象在不再作为程序的逻辑状态的一部分之后仍然不被垃圾收集。本月,负责保障应用程序健康的工程师 Brian Goetz 探讨了无意识的对象保留的常见原因,并展示了如何用弱引用堵住泄漏。

要让垃圾收集(GC)回收程序不再使用的对象,对象的逻辑 生命周期(应用程序使用它的时间)和对该对象拥有的引用的实际 生命周期必须是相同的。在大多数时候,好的软件工程技术保证这是自动实现的,不用我们对对象生命周期问题花费过多心思。但是偶尔我们会创建一个引用,它在内存中包含对象的时间比我们预期的要长得多,这种情况称为无意识的对象保留(unintentional object retention)

全局 Map 造成的内存泄漏

无意识对象保留最常见的原因是使用 Map 将元数据与临时对象(transient object)相关联。假定一个对象具有中等生命周期,比分配它的那个方法调用的生命周期长,但是比应用程序的生命周期短,如客户机的套接字连接。需要将一些元数据与这个套接字关联,如生成连接的用户的标识。在创建 Socket 时是不知道这些信息的,并且不能将数据添加到 Socket 对象上,因为不能控制 Socket 类或者它的子类。这时,典型的方法就是在一个全局 Map 中存储这些信息,如清单 1 中的 SocketManager 类所示:


清单 1. 使用一个全局 Map 将元数据关联到一个对象
												
														public class SocketManager {
    private Map<Socket,User> m = new HashMap<Socket,User>();
    
    public void setUser(Socket s, User u) {
        m.put(s, u);
    }
    public User getUser(Socket s) {
        return m.get(s);
    }
    public void removeUser(Socket s) {
        m.remove(s);
    }
}

SocketManager socketManager;
...
socketManager.setUser(socket, user);

												
										

这种方法的问题是元数据的生命周期需要与套接字的生命周期挂钩,但是除非准确地知道什么时候程序不再需要这个套接字,并记住从 Map 中删除相应的映射,否则,SocketUser 对象将会永远留在 Map 中,远远超过响应了请求和关闭套接字的时间。这会阻止 SocketUser 对象被垃圾收集,即使应用程序不会再使用它们。这些对象留下来不受控制,很容易造成程序在长时间运行后内存爆满。除了最简单的情况,在几乎所有情况下找出什么时候 Socket 不再被程序使用是一件很烦人和容易出错的任务,需要人工对内存进行管理。





回页首


找出内存泄漏

程序有内存泄漏的第一个迹象通常是它抛出一个 OutOfMemoryError,或者因为频繁的垃圾收集而表现出糟糕的性能。幸运的是,垃圾收集可以提供能够用来诊断内存泄漏的大量信息。如果以 -verbose:gc 或者 -Xloggc 选项调用 JVM,那么每次 GC 运行时在控制台上或者日志文件中会打印出一个诊断信息,包括它所花费的时间、当前堆使用情况以及恢复了多少内存。记录 GC 使用情况并不具有干扰性,因此如果需要分析内存问题或者调优垃圾收集器,在生产环境中默认启用 GC 日志是值得的。

有工具可以利用 GC 日志输出并以图形方式将它显示出来,JTune 就是这样的一种工具(请参阅 参考资料)。观察 GC 之后堆大小的图,可以看到程序内存使用的趋势。对于大多数程序来说,可以将内存使用分为两部分:baseline 使用和 current load 使用。对于服务器应用程序,baseline 使用就是应用程序在没有任何负荷、但是已经准备好接受请求时的内存使用,current load 使用是在处理请求过程中使用的、但是在请求处理完成后会释放的内存。只要负荷大体上是恒定的,应用程序通常会很快达到一个稳定的内存使用水平。如果在应用程序已经完成了其初始化并且负荷没有增加的情况下,内存使用持续增加,那么程序就可能在处理前面的请求时保留了生成的对象。

清单 2 展示了一个有内存泄漏的程序。MapLeaker 在线程池中处理任务,并在一个 Map 中记录每一项任务的状态。不幸的是,在任务完成后它不会删除那一项,因此状态项和任务对象(以及它们的内部状态)会不断地积累。


清单 2. 具有基于 Map 的内存泄漏的程序
												
														public class MapLeaker {
    public ExecutorService exec = Executors.newFixedThreadPool(5);
    public Map<Task, TaskStatus> taskStatus 
        = Collections.synchronizedMap(new HashMap<Task, TaskStatus>());
    private Random random = new Random();

    private enum TaskStatus { NOT_STARTED, STARTED, FINISHED };

    private class Task implements Runnable {
        private int[] numbers = new int[random.nextInt(200)];

        public void run() {
            int[] temp = new int[random.nextInt(10000)];
            taskStatus.put(this, TaskStatus.STARTED);
            doSomeWork();
            taskStatus.put(this, TaskStatus.FINISHED);
        }
    }

    public Task newTask() {
        Task t = new Task();
        taskStatus.put(t, TaskStatus.NOT_STARTED);
        exec.execute(t);
        return t;
    }
}

												
										

图 1 显示 MapLeaker GC 之后应用程序堆大小随着时间的变化图。上升趋势是存在内存泄漏的警示信号。(在真实的应用程序中,坡度不会这么大,但是在收集了足够长时间的 GC 数据后,上升趋势通常会表现得很明显。)


图 1. 持续上升的内存使用趋势

确信有了内存泄漏后,下一步就是找出哪种对象造成了这个问题。所有内存分析器都可以生成按照对象类进行分解的堆快照。有一些很好的商业堆分析工具,但是找出内存泄漏不一定要花钱买这些工具 —— 内置的 hprof 工具也可完成这项工作。要使用 hprof 并让它跟踪内存使用,需要以 -Xrunhprof:heap=sites 选项调用 JVM。

清单 3 显示分解了应用程序内存使用的 hprof 输出的相关部分。(hprof 工具在应用程序退出时,或者用 kill -3 或在 Windows 中按 Ctrl+Break 时生成使用分解。)注意两次快照相比,Map.EntryTaskint[] 对象有了显著增加。

请参阅 清单 3

清单 4 展示了 hprof 输出的另一部分,给出了 Map.Entry 对象的分配点的调用堆栈信息。这个输出告诉我们哪些调用链生成了 Map.Entry 对象,并带有一些程序分析,找出内存泄漏来源一般来说是相当容易的。


清单 4. HPROF 输出,显示 Map.Entry 对象的分配点
												
														TRACE 300446:
	java.util.HashMap$Entry.<init>(<Unknown Source>:Unknown line)
	java.util.HashMap.addEntry(<Unknown Source>:Unknown line)
	java.util.HashMap.put(<Unknown Source>:Unknown line)
	java.util.Collections$SynchronizedMap.put(<Unknown Source>:Unknown line)
	com.quiotix.dummy.MapLeaker.newTask(MapLeaker.java:48)
	com.quiotix.dummy.MapLeaker.main(MapLeaker.java:64)

												
										





回页首


弱引用来救援了

SocketManager 的问题是 Socket-User 映射的生命周期应当与 Socket 的生命周期相匹配,但是语言没有提供任何容易的方法实施这项规则。这使得程序不得不使用人工内存管理的老技术。幸运的是,从 JDK 1.2 开始,垃圾收集器提供了一种声明这种对象生命周期依赖性的方法,这样垃圾收集器就可以帮助我们防止这种内存泄漏 —— 利用弱引用

弱引用是对一个对象(称为 referent)的引用的持有者。使用弱引用后,可以维持对 referent 的引用,而不会阻止它被垃圾收集。当垃圾收集器跟踪堆的时候,如果对一个对象的引用只有弱引用,那么这个 referent 就会成为垃圾收集的候选对象,就像没有任何剩余的引用一样,而且所有剩余的弱引用都被清除。(只有弱引用的对象称为弱可及(weakly reachable)。)

WeakReference 的 referent 是在构造时设置的,在没有被清除之前,可以用 get() 获取它的值。如果弱引用被清除了(不管是 referent 已经被垃圾收集了,还是有人调用了 WeakReference.clear()),get() 会返回 null。相应地,在使用其结果之前,应当总是检查 get() 是否返回一个非 null 值,因为 referent 最终总是会被垃圾收集的。

用一个普通的(强)引用拷贝一个对象引用时,限制 referent 的生命周期至少与被拷贝的引用的生命周期一样长。如果不小心,那么它可能就与程序的生命周期一样 —— 如果将一个对象放入一个全局集合中的话。另一方面,在创建对一个对象的弱引用时,完全没有扩展 referent 的生命周期,只是在对象仍然存活的时候,保持另一种到达它的方法。

弱引用对于构造弱集合最有用,如那些在应用程序的其余部分使用对象期间存储关于这些对象的元数据的集合 —— 这就是 SocketManager 类所要做的工作。因为这是弱引用最常见的用法,WeakHashMap 也被添加到 JDK 1.2 的类库中,它对键(而不是对值)使用弱引用。如果在一个普通 HashMap 中用一个对象作为键,那么这个对象在映射从 Map 中删除之前不能被回收,WeakHashMap 使您可以用一个对象作为 Map 键,同时不会阻止这个对象被垃圾收集。清单 5 给出了 WeakHashMapget() 方法的一种可能实现,它展示了弱引用的使用:


清单 5. WeakReference.get() 的一种可能实现
												
														public class WeakHashMap<K,V> implements Map<K,V> {

    private static class Entry<K,V> extends WeakReference<K> 
      implements Map.Entry<K,V> {
        private V value;
        private final int hash;
        private Entry<K,V> next;
        ...
    }

    public V get(Object key) {
        int hash = getHash(key);
        Entry<K,V> e = getChain(hash);
        while (e != null) {
            K eKey= e.get();
            if (e.hash == hash && (key == eKey || key.equals(eKey)))
                return e.value;
            e = e.next;
        }
        return null;
    }

												
										

调用 WeakReference.get() 时,它返回一个对 referent 的强引用(如果它仍然存活的话),因此不需要担心映射在 while 循环体中消失,因为强引用会防止它被垃圾收集。WeakHashMap 的实现展示了弱引用的一种常见用法 —— 一些内部对象扩展 WeakReference。其原因在下面一节讨论引用队列时会得到解释。

在向 WeakHashMap 中添加映射时,请记住映射可能会在以后“脱离”,因为键被垃圾收集了。在这种情况下,get() 返回 null,这使得测试 get() 的返回值是否为 null 变得比平时更重要了。

用 WeakHashMap 堵住泄漏

SocketManager 中防止泄漏很容易,只要用 WeakHashMap 代替 HashMap 就行了,如清单 6 所示。(如果 SocketManager 需要线程安全,那么可以用 Collections.synchronizedMap() 包装 WeakHashMap)。当映射的生命周期必须与键的生命周期联系在一起时,可以使用这种方法。不过,应当小心不滥用这种技术,大多数时候还是应当使用普通的 HashMap 作为 Map 的实现。


清单 6. 用 WeakHashMap 修复 SocketManager
												
														public class SocketManager {
    private Map<Socket,User> m = new WeakHashMap<Socket,User>();
    
    public void setUser(Socket s, User u) {
        m.put(s, u);
    }
    public User getUser(Socket s) {
        return m.get(s);
    }
}

												
										

引用队列

WeakHashMap 用弱引用承载映射键,这使得应用程序不再使用键对象时它们可以被垃圾收集,get() 实现可以根据 WeakReference.get() 是否返回 null 来区分死的映射和活的映射。但是这只是防止 Map 的内存消耗在应用程序的生命周期中不断增加所需要做的工作的一半,还需要做一些工作以便在键对象被收集后从 Map 中删除死项。否则,Map 会充满对应于死键的项。虽然这对于应用程序是不可见的,但是它仍然会造成应用程序耗尽内存,因为即使键被收集了,Map.Entry 和值对象也不会被收集。

可以通过周期性地扫描 Map,对每一个弱引用调用 get(),并在 get() 返回 null 时删除那个映射而消除死映射。但是如果 Map 有许多活的项,那么这种方法的效率很低。如果有一种方法可以在弱引用的 referent 被垃圾收集时发出通知就好了,这就是引用队列 的作用。

引用队列是垃圾收集器向应用程序返回关于对象生命周期的信息的主要方法。弱引用有两个构造函数:一个只取 referent 作为参数,另一个还取引用队列作为参数。如果用关联的引用队列创建弱引用,在 referent 成为 GC 候选对象时,这个引用对象(不是 referent)就在引用清除后加入 到引用队列中。之后,应用程序从引用队列提取引用并了解到它的 referent 已被收集,因此可以进行相应的清理活动,如去掉已不在弱集合中的对象的项。(引用队列提供了与 BlockingQueue 同样的出列模式 —— polled、timed blocking 和 untimed blocking。)

WeakHashMap 有一个名为 expungeStaleEntries() 的私有方法,大多数 Map 操作中会调用它,它去掉引用队列中所有失效的引用,并删除关联的映射。清单 7 展示了 expungeStaleEntries() 的一种可能实现。用于存储键-值映射的 Entry 类型扩展了 WeakReference,因此当 expungeStaleEntries() 要求下一个失效的弱引用时,它得到一个 Entry。用引用队列代替定期扫描内容的方法来清理 Map 更有效,因为清理过程不会触及活的项,只有在有实际加入队列的引用时它才工作。


清单 7. WeakHashMap.expungeStaleEntries() 的可能实现
												
														    private void expungeStaleEntries() {
	Entry<K,V> e;
        while ( (e = (Entry<K,V>) queue.poll()) != null) {
            int hash = e.hash;

            Entry<K,V> prev = getChain(hash);
            Entry<K,V> cur = prev;
            while (cur != null) {
                Entry<K,V> next = cur.next;
                if (cur == e) {
                    if (prev == e)
                        setChain(hash, next);
                    else
                        prev.next = next;
                    break;
                }
                prev = cur;
                cur = next;
            }
        }
    }

												
										





回页首


结束语

弱引用和弱集合是对堆进行管理的强大工具,使得应用程序可以使用更复杂的可及性方案,而不只是由普通(强)引用所提供的“要么全部要么没有”可及性。下个月,我们将分析与弱引用有关的软引用,将分析在使用弱引用和软引用时,垃圾收集器的行为。

posted @ 2006-08-24 17:42 Binary 阅读(227) | 评论 (0)编辑 收藏

Java 理论与实践: Web 层的状态复制

大多数具有一定重要性的 Web 应用程序都要求维护某种会话状态,如用户购物车的内容。如何在群集服务器应用程序中管理和复制状态对应用程序的可伸缩性有显著影响。许多 J2SE 和 J2EE 应用程序将状态存储在由 Servlet API 提供的 HttpSession 中。本月,专栏作家 Brian Goetz 分析了状态复制的一些选项以及如何最有效地使用 HttpSession 以提供好的伸缩性和性能。在本文论坛中与本文作者和其他读者分享您的观点。(可以单击文章顶部或者底部的 讨论访问论坛。)

不管正在构建的是 J2EE 还是 J2SE 服务器应用程序,都有可能以某种方式使用 Java Servlet —— 可能是直接地通过像 JSP 技术、Velocity 或者 WebMacro 这样的表示层,也可能通过一个基于 servlet 的 Web 服务实现,如 Axis 或者 Glue。Servlet API 提供的一个最重要的功能是会话管理 —— 通过 HttpSession 接口进行用户状态的认证、失效和维护。

会话状态

几乎每一个 Web 应用程序都有一些会话状态,这些状态有可能像记住您是否已登录这么简单,也可能是您的会话的更详细的历史,如购物车的内容、以前查询结果的缓存或者 20 页动态问卷表的完整响应历史。因为 HTTP 协议本身是无状态的,所以需要将会话状态存储在某处并与浏览会话以某种方式相关联,使得下次请求同一 Web 应用程序的页面时可以容易地获取。幸运的是,J2EE 提供了几种管理会话状态的方法 —— 状态可以存储在数据层,用 Servlet API 的 HttpSession 接口存储在 Web 层,用有状态会话 bean 存储在 Enterprise JavaBeans(EJB)层,甚至用 cookie 或者隐藏表单字段将状态存储在客户层。不幸的是,会话状态管理不当会带来严重的性能问题。

如果应用程序能够在 HttpSession 中存储用户状态,这种方法通常比其他方法更好。在客户端用 HTTP cookie 或者隐藏表单字段存储会话状态有很大的安全风险 —— 它将应用程序的一部分内部内容暴露给了非受信任的客户层。(一个早期的电子商务网站将购物车内容(包括价格)存储在隐藏表单字段中,从而可以很容易被非法利用,让任何了解 HTML 和 HTTP 的用户可以以 0.01 美元购买任何商品。噢)此外,使用 cookie 或者隐藏表单字段很混乱,容易出错,并且脆弱(如果用户禁止在浏览器中使用 cookie,那么基于 cookie 的方法就完全不能工作)。

在 J2EE 应用程序中存储服务器端状态的其他方法是使用有状态会话 bean,或者在数据库中存储会话状态。虽然有状态会话 bean 在会话状态管理方面有更大的灵活性,但是在可能的情况下,将会话状态存储在 Web 层仍然有好处。如果业务对象是无状态的,那么通常可以仅仅添加更多 Web 服务器来扩展应用程序,而不用添加更多 Web 服务器和更多 EJB 容器, 这样的成本一般要低一些并且容易完成。使用 HttpSession 存储会话状态的另一个好处是 Servlet API 提供了一种会话失效时通知的容易方法。在数据库中存储会话状态的成本可能难以承受。

servlet 规范没有要求 servlet 容器进行某种类型的会话复制或者持久性,但是它建议将状态复制作为 servlet 首要 存在理由(raison d'etre) 的重要部分,并且它对作为进行会话复制的容器提出了一些要求。会话复制可以提供大量好处 —— 负载平衡、伸缩性、容错和高可用性。相应地,大多数 servlet 容器支持某种形式的 HttpSession 复制,但是复制的机制、配置和时间是由实现决定的。





回页首


HttpSession API

简单地说, HttpSession 接口支持几种方法,servlet、JSP 页或者其他表示层组件可以用这些方法来跨多个 HTTP 请求维护会话信息。会话绑定到特定的用户,但是在 Web 应用程序的所有 servlet 中共享 —— 不特定于某一个 servlet。一种考虑会话的有用方法是,会话像一个在会话期间存储对象的 Map —— 可以用 setAttribute 按名字存储会话属性,并用 getAttribute 提取它们。 HttpSession 接口还包含会话生存周期方法,如 invalidate() (它通知容器应丢弃会话)。清单 1 显示 HttpSession 接口最常用的元素:


清单 1. HttpSession API
												
														public interface HttpSession {
    Object getAttribute(String s);
    Enumeration getAttributeNames();
    void setAttribute(String s, Object o);
    void removeAttribute(String s);

    boolean isNew();
    void invalidate();
    void setMaxInactiveInterval(int i);
    int getMaxInactiveInterval();
    ...
}

												
										

理论上,可以跨群集一致性地完全复制会话状态,这样群集中的所有节点都可以服务任何请求,一个简单的负载平衡器可以以轮询方式传送请求,避开有故障的主机。不过,这种紧密的复制有很高的性能成本,并且难于实现,当群集接近某一规模时,还会有伸缩性的问题。

一种更常用的方式是将负载平衡与会话相似性(affinity) 结合起来 —— 负载平衡器可以将会话与连接相关联,并将会话中以后的请求发送给同一服务器。有很多硬件和软件负载平衡器支持这个功能,并且这意味着只有主连接主机和会话需要故障转移到另一台服务器时才访问复制的会话信息。





回页首


复制方式

复制提供了一些可能的好处,包括可用性、容错和伸缩性。此外,有大量会话复制的方法可用:方法的选择取决于应用程序群集的规模、复制的目标和 servlet 容器支持的复制设施。复制有性能成本,包括 CPU 周期(存储在会话中的序列化对象)、网络带宽(广播更新),以及基于磁盘的方案中写入到磁盘或者数据库的成本。

几乎所有 servlet 容器都通过存储在 HttpSession 中的序列化对象进行 HttpSession 复制,所以如果是创建一个分布式应用程序,应当确保只将可序列化对象放到会话中。(一些容器对像 EJB 引用、事务上下文、还有其他非可序列化的 J2EE 对象类型有特殊的处理。)

基于 JDBC 的复制

一种会话复制的方法是序列化会话内容并将它写入数据库。这种方法相当直观,其优点是不仅会话可以故障转移到其他主机,而且即使整个群集失效,会话数据也可以保存下来。基于数据库的复制的缺点是性能成本 —— 数据库事务是昂贵的。虽然它可以在 Web 层很好地伸缩,但是它可能在数据层产生伸缩问题 —— 如果群集增长大到一定程度,扩展数据层以容纳会话数据会很困难或者成本无法接受。

基于文件的复制

基于文件的复制类似于使用数据库存储序列化的会话,只不过是使用共享文件服务器而不是数据库来存储会话数据。这种方式的成本一般比使用数据库的成本(硬件成本、软件许可证和计算开销)低,其代价则是可靠性(数据库可提供比文件系统更强的持久化保证)。

基于内存的复制

另一种复制方式是与群集中的一个或者多个其他服务器共享序列化的会话数据副本。复制所有会话到所有主机中提供了最大的可用性,并且负载平衡最容易,但是因为复制消息所消耗的每个节点的内存和网络带宽,最终会限制群集的规模。一些应用服务器支持与“伙伴(buddy)”节点的基于内存的复制,其中每一个会话存在于主服务器上和一台(或更多)备份服务器上。这种方案比将所有会话复制到所有服务器的伸缩性更好,但是当需要将会话故障转移到另一台服务器上时会使负载平衡任务复杂化,因为它必须找出另外哪一台(几台)服务器有这个会话。

时间考虑

除了决定如何存储复制会话数据,还有什么时候复制数据的问题。最可靠但也最昂贵的方法是每次数据改变时复制它(如每次 servlet 调用结束)。不那么昂贵、但是在故障时会有丢失一些数据的风险的方法是在每超过 N 秒时复制数据。

与时间问题有关的问题是,是复制整个会话还是只试尝复制会话中改变了的属性(它包含的数据会少得多)。这些都需要在可靠性和性能之间进行取舍。Servlet 开发人员应当认识到在故障转移时,会话状态可能变得“过时”(是几次请求前的复制),并应当准备处理不是最新的会话内容。(例如,如果一个interview 的第 3 步产生一个会话属性,而用户在第 4 步时,请求被故障转移到一个具有两次请求之前的会话状态复制的系统上,那么第 4 步的 servlet 代码应预备在会话中找不到这个属性,并采取相应的行动 —— 如重定向,而不是认定它会在那里、并在找不到它时抛出一个 NullPointerException 。)





回页首


容器支持

Servlet 容器的 HttpSession 复制选项以及如何配置这些选项是各不相同的。IBM WebSphere ®提供的复制选项是最多的,它提供了在内存中复制或者基于数据库的复制、在 servlet 末尾或者基于时间的复制时间、传播全部会话快照(JBoss 3.2 或以后版本)或者只传播改变了的属性等选择。基于内存的复制基于 JMS 发布-订阅,它可以复制到所有克隆、一个“伙伴”复制品或者一个专门的复制服务器。

WebLogic 还提供了一组选择,包括内存中(使用一个伙伴复制品)、基于文件的或者基于数据库的。JBoss 与 Tomcat 或者 Jetty servlet 容器一同使用时,进行基于内存的复制,可以选择 servlet 末尾或者基于时间的复制时间,而快照选项(在 JBoss 3.2 或以后版本)是只复制改变了的属性。Tomcat 5.0 为所有群集节点提供了基于内存的复制。此外,通过像 WADI 这样的项目,可以用 servlet 过滤机制将会话复制添加到像 Tomcat 或者 Jetty 这样的 servlet 容器中。





回页首


改进分布式 Web 应用程序的性能

不管决定使用什么机制进行会话复制,可以用几种方式改进 Web 应用程序的性能和伸缩性。首先记住,为了获得会话复制的好处,需要在部署描述符中将 Web 应用程序标记为 distributable,并保证在会话中的所有内容都是可序列化的。

保持会话最小

因为复制会话有随着会话中的对象图(object graph) 的变大而增加成本,所以应当尽可能地在会话中少放置数据。这样做会减少复制的序列化的开销、网络带宽要求和磁盘要求。特别地,将共享对象存储在会话中一般不是好主意,因为它们需要复制到它们所属的 每一个会话中。

不要绕过 setAttribute

在改变会话的属性时,要知道即使 servlet 容器只是试图做最小的更新(只传播改变了的属性),如果没有调用 setAttribute ,容器也可能没有注意到已经改变的属性。(想像在会话中有一个 Vector ,表示购物车中的商品 —— 如果调用 getAttribute() 获取 Vector 、然后向它添加一些内容,并且不再次调用 setAttribute ,容器可能不会意识到 Vector 已经改变了。)

使用细化的会话属性

对于支持最小更新的容器,可以通过将多个细化的对象而不是一个大块头放到会话中而降低会话复制的成本。这样,对快速改变的数据的改变也不会迫使容器去序列化并传播慢速改变的数据。

完成后使之失效

如果知道用户完成了会话的使用(如,用户选择注销登录),确保调用 HttpSession.invalidate() 。否则,会话将持久化直到它失效,这会消耗内存,并且可能是长时间的(取决于会话超时时间)。许多 servlet 容器对可以跨所有会话使用的内存的数量有一个限制,达到这个限制时,会序列化最先使用的会话并将它写到磁盘上。如果知道用户使用完了会话,可以使容器不再处理它并使它作废。

保持会话干净

如果在会话中有大的项,并且只在会话的一部分中使用,那么当不再需要时应删除它们。删除它们会减少会话复制的成本。(这种做法类似于使用显式 nulling 以帮助垃圾收集器,老读者知道我一般不建议这样做,但是在这种情况下,因为有复制,在会话中保持垃圾的成本要高得多,因此值得以这种方式帮助容器。)





回页首


结束语

通过 HttpSession 复制,Servlet 容器可以在构建复制的、高可用性的 Web 应用程序方面给您减轻很多负担。不过,对于复制有一些配置选项,每个容器都不一样,复制策略的选择对于应用程序的容错、性能和伸缩性有影响。复制策略的选择不应当是事后的 —— 您应当在构建 Web 应用程序时就考虑它。并且,一定不要忘记进行负载测试以确定应用程序的伸缩性 —— 在客户替您做之前。

posted @ 2006-08-24 17:41 Binary 阅读(206) | 评论 (0)编辑 收藏

Java 理论与实践: 关于异常的争论

关于在 Java 语言中使用异常的大多数建议都认为,在确信异常可以被捕获的任何情况下,应该优先使用检查型异常。语言设计(编译器强制您在方法签名中列出可能被抛出的所有检查型异常)以及早期关于样式和用法的著作都支持该建议。最近,几位著名的作者已经开始认为非检查型异常在优秀的 Java 类设计中有着比以前所认为的更为重要的地位。在本文中,Brian Goetz 考察了关于使用非检查型异常的优缺点。请在附带的讨论论坛中与作者和其他读者一起分享您有关本文的心得体会(您也可以点击文章顶部或底部的 讨论来访问该论坛。)

与 C++ 类似,Java 语言也提供异常的抛出和捕获。但是,与 C++ 不一样的是,Java 语言支持检查型和非检查型异常。Java 类必须在方法签名中声明它们所抛出的任何检查型异常,并且对于任何方法,如果它调用的方法抛出一个类型为 E 的检查型异常,那么它必须捕获 E 或者也声明为抛出 E(或者 E 的一个父类)。通过这种方式,该语言强制我们文档化控制可能退出一个方法的所有预期方式。

对于因为编程错误而导致的异常,或者是不能期望程序捕获的异常(解除引用一个空指针,数组越界,除零,等等),为了使开发人员免于处理这些异常,一些异常被命名为非检查型异常(即那些继承自 RuntimeException 的异常)并且不需要进行声明。

传统的观点

在下面的来自 Sun 的“The Java Tutorial”的摘录中,总结了关于将一个异常声明为检查型还是非检查型的传统观点(更多的信息请参阅 参考资料):

因为 Java 语言并不要求方法捕获或者指定运行时异常,因此编写只抛出运行时异常的代码或者使得他们的所有异常子类都继承自 RuntimeException ,对于程序员来说是有吸引力的。这些编程捷径都允许程序员编写 Java 代码而不会受到来自编译器的所有挑剔性错误的干扰,并且不用去指定或者捕获任何异常。尽管对于程序员来说这似乎比较方便,但是它回避了 Java 的捕获或者指定要求的意图,并且对于那些使用您提供的类的程序员可能会导致问题。

检查型异常代表关于一个合法指定的请求的操作的有用信息,调用者可能已经对该操作没有控制,并且调用者需要得到有关的通知 —— 例如,文件系统已满,或者远端已经关闭连接,或者访问权限不允许该动作。

如果您仅仅是因为不想指定异常而抛出一个 RuntimeException ,或者创建 RuntimeException 的一个子类,那么您换取到了什么呢?您只是获得了抛出一个异常而不用您指定这样做的能力。换句话说,这是一种用于避免文档化方法所能抛出的异常的方式。在什么时候这是有益的?也就是说,在什么时候避免注明一个方法的行为是有益的?答案是“几乎从不。”

换句话说,Sun 告诉我们检查型异常应该是准则。该教程通过多种方式继续说明,通常应该抛出异常,而不是 RuntimeException —— 除非您是 JVM。

Effective Java: Programming Language Guide一书中(请参阅 参考资料),Josh Bloch 提供了下列关于检查型和非检查型异常的知识点,这些与 “The Java Tutorial” 中的建议相一致(但是并不完全严格一致):

  • 第 39 条:只为异常条件使用异常。也就是说,不要为控制流使用异常,比如,在调用 Iterator.next() 时而不是在第一次检查 Iterator.hasNext() 时捕获 NoSuchElementException

  • 第 40 条:为可恢复的条件使用检查型异常,为编程错误使用运行时异常。这里,Bloch 回应传统的 Sun 观点 —— 运行时异常应该只是用于指示编程错误,例如违反前置条件。

  • 第 41 条:避免不必要的使用检查型异常。换句话说,对于调用者不可能从其中恢复的情形,或者惟一可以预见的响应将是程序退出,则不要使用检查型异常。

  • 第 43 条:抛出与抽象相适应的异常。换句话说,一个方法所抛出的异常应该在一个抽象层次上定义,该抽象层次与该方法做什么相一致,而不一定与方法的底层实现细节相一致。例如,一个从文件、数据库或者 JNDI 装载资源的方法在不能找到资源时,应该抛出某种 ResourceNotFound 异常(通常使用异常链来保存隐含的原因),而不是更底层的 IOExceptionSQLException 或者 NamingException




回页首


重新考察非检查型异常的正统观点

最近,几位受尊敬的专家,包括 Bruce Eckel 和 Rod Johnson,已经公开声明尽管他们最初完全同意检查型异常的正统观点,但是他们已经认定排他性使用检查型异常的想法并没有最初看起来那样好,并且对于许多大型项目,检查型异常已经成为一个重要的问题来源。Eckel 提出了一个更为极端的观点,建议所有的异常应该是非检查型的;Johnson 的观点要保守一些,但是仍然暗示传统的优先选择检查型异常是过分的。(值得一提的是,C# 的设计师在语言设计中选择忽略检查型异常,使得所有异常都是非检查型的,因而几乎可以肯定他们具有丰富的 Java 技术使用经验。但是,后来他们的确为检查型异常的实现留出了空间。)





回页首


对于检查型异常的一些批评

Eckel 和 Johnson 都指出了一个关于检查型异常的相似的问题清单;一些是检查型异常的内在属性,一些是检查型异常在 Java 语言中的特定实现的属性,还有一些只是简单的观察,主要是关于检查型异常的广泛的错误使用是如何变为一个严重的问题,从而导致该机制可能需要被重新考虑。

检查型异常不适当地暴露实现细节

您已经有多少次看见(或者编写)一个抛出 SQLException 或者 IOException 的方法,即使它看起来与数据库或者文件毫无关系呢?对于开发人员来说,在一个方法的最初实现中总结出可能抛出的所有异常并且将它们增加到方法的 throws 子句(许多 IDE 甚至帮助您执行该任务)是十分常见的。这种直接方法的一个问题是它违反了 Bloch 的 第 43 条 —— 被抛出的异常所位于的抽象层次与抛出它们的方法不一致。

一个用于装载用户概要的方法,在找不到用户时应该抛出 NoSuchUserException ,而不是 SQLException —— 调用者可以很好地预料到用户可能找不到,但是不知道如何处理 SQLException 。异常链可以用于抛出一个更为合适的异常而不用丢弃关于底层失败的细节(例如栈跟踪),允许抽象层将位于它们之上的分层同位于它们之下的分层的细节隔离开来,同时保留对于调试可能有用的信息。

据说,诸如 JDBC 包的设计采取这样一种方式,使得它难以避免该问题。在 JDBC 接口中的每个方法都抛出 SQLException ,但是在访问一个数据库的过程中可能会经历多种不同类型的问题,并且不同的方法可能易受不同错误模式的影响。一个 SQLException 可能指示一个系统级问题(不能连接到数据库)、逻辑问题(在结果集中没有更多的行)或者特定数据的问题(您刚才试图插入行的主键已经存在或者违反实体完整性约束)。如果没有犯不可原谅的尝试分析消息正文的过失,调用者是不可能区分这些不同类型的 SQLException 的。( SQLException 的确支持用于获取数据库特定错误代码和 SQL 状态变量的方法,但是在实践中这些很少用于区分不同的数据库错误条件。)

不稳定的方法签名

不稳定的方法签名问题是与前面的问题相关的 —— 如果您只是通过一个方法传递异常,那么您不得不在每次改变方法的实现时改变它的方法签名,以及改变调用该方法的所有代码。一旦类已经被部署到产品中,管理这些脆弱的方法签名就变成一个昂贵的任务。然而,该问题本质上是没有遵循 Bloch 提出的第 43 条的另一个症状。方法在遇到失败时应该抛出一个异常,但是该异常应该反映该方法做什么,而不是它如何做。

有时,当程序员对因为实现的改变而导致从方法签名中增加或者删除异常感到厌烦时,他们不是通过使用一个抽象来定义特定层次可能抛出的异常类型,而只是将他们的所有方法都声明为抛出 Exception 。换句话说,他们已经认定异常只是导致烦恼,并且基本上将它们关闭掉了。毋庸多言,该方法对于绝大多数可任意使用的代码来说通常不是一个好的错误处理策略。

难以理解的代码

因为许多方法都抛出一定数目的不同异常,错误处理的代码相对于实际的功能代码的比率可能会偏高,使得难以找到一个方法中实际完成功能的代码。异常是通过集中错误处理来设想减小代码的,但是一个具有三行代码和六个 catch 块(其中每个块只是记录异常或者包装并重新抛出异常)的方法看起来比较膨胀并且会使得本来简单的代码变得模糊。

异常淹没

我们都看到过这样的代码,其中捕获了一个异常,但是在 catch 块中没有代码。尽管这种编程实践很明显是不好的,但是很容易看出它是如何发生的 —— 在原型化期间,某人通过 try...catch 块包装代码,而后来忘记返回并填充 catch 块。尽管这个错误很常见,但是这也是更好的工具可以帮助我们的地方之一 —— 对于异常淹没的地方,通过编辑器、编译器或者静态检查工具可以容易地检测并发出警告。

极度通用的 try...catch 块是另一种形式的异常淹没,并且更加难以检测,因为这是 Java 类库中的异常类层次的结构而导致的(可疑)。让我们假定一个方法抛出四个不同类型的异常,并且调用者遇到其中任何一个异常都将捕获、记录它们,并且返回。实现该策略的一种方式是使用一个带有四个 catch 子句的 try...catch 块,其中每个异常类型一个。为了避免代码难以理解的问题,一些开发人员将重构该代码,如清单 1 所示:


清单 1. 意外地淹没 RuntimeException
												
														try { 
  doSomething();
}
catch (Exception e) { 
  log(e);
}

												
										

尽管该代码与四个 catch 块相比更为紧凑,但是它具有一个问题 —— 它还捕获可能由 doSomething 抛出的任何 RuntimeException 并且阻止它们进行扩散。

过多的异常包装

如果异常是在一个底层的设施中生成的,并且通过许多代码层向上扩散,在最终被处理之前它可能被捕获、包装和重新抛出若干次。当异常最终被记录的时候,栈跟踪可能有许多页,因为栈跟踪可能被复制多次,其中每个包装层一次。(在 JDK 1.4 以及后来的版本中,异常链的实现在某种程度上缓解了该问题。)





回页首


替换的方法

Bruce Eckel, Thinking in Java(请参阅 参考资料)的作者,声称在使用 Java 语言多年后,他已经得出这样的结论,认为检查型异常是一个错误 —— 一个应该被声明为失败的试验。Eckel 提倡将所有的异常都作为非检查型的,并且提供清单 2 中的类作为将检查型异常转变为非检查型异常的一个方法,同时保留当异常从栈向上扩散时捕获特定类型的异常的能力(关于如何使用该方法的解释,请参阅他在 参考资料小节中的文章):


清单 2. Eckel 的异常适配器类
												
														class ExceptionAdapter extends RuntimeException {
  private final String stackTrace;
  public Exception originalException;
  public ExceptionAdapter(Exception e) {
    super(e.toString());
    originalException = e;
    StringWriter sw = new StringWriter();
    e.printStackTrace(new PrintWriter(sw));
    stackTrace = sw.toString();
  }
  public void printStackTrace() { 
    printStackTrace(System.err);
  }
  public void printStackTrace(java.io.PrintStream s) { 
    synchronized(s) {
      s.print(getClass().getName() + ": ");
      s.print(stackTrace);
    }
  }
  public void printStackTrace(java.io.PrintWriter s) { 
    synchronized(s) {
      s.print(getClass().getName() + ": ");
      s.print(stackTrace);
    }
  }
  public void rethrow() { throw originalException; }
} 

												
										

如果查看 Eckel 的 Web 站点上的讨论,您将会发现回应者是严重分裂的。一些人认为他的提议是荒谬的;一些人认为这是一个重要的思想。(我的观点是,尽管恰当地使用异常确实是很难的,并且对异常用不好的例子大量存在,但是大多数赞同他的人是因为错误的原因才这样做的,这与一个政客位于一个可以随便获取巧克力的平台上参选将会获得十岁孩子的大量选票的情况具有相似之处。)

Rod Johnson 是 J2EE Design and Development(请参阅 参考资料) 的作者,这是我所读过的关于 Java 开发,J2EE 等方面的最好的书籍之一。他采取一个不太激进的方法。他列举了异常的多个类别,并且为每个类别确定一个策略。一些异常本质上是次要的返回代码(它通常指示违反业务规则),而一些异常则是“发生某种可怕错误”(例如数据库连接失败)的变种。Johnson 提倡对于第一种类别的异常(可选的返回代码)使用检查型异常,而对于后者使用运行时异常。在“发生某种可怕错误”的类别中,其动机是简单地认识到没有调用者能够有效地处理该异常,因此它也可能以各种方式沿着栈向上扩散而对于中间代码的影响保持最小(并且最小化异常淹没的可能性)。

Johnson 还列举了一个中间情形,对此他提出一个问题,“只是少数调用者希望处理问题吗?”对于这些情形,他也建议使用非检查型异常。作为该类别的一个例子,他列举了 JDO 异常 —— 大多数情况下,JDO 异常表示的情况是调用者不希望处理的,但是在某些情况下,捕获和处理特定类型的异常是有用的。他建议在这里使用非检查型异常,而不是让其余的使用 JDO 的类通过捕获和重新抛出这些异常的形式来弥补这个可能性。

使用非检查型异常

关于是否使用非检查型异常的决定是复杂的,并且很显然没有明显的答案。Sun 的建议是对于任何情况使用它们,而 C# 方法(也就是 Eckel 和其他人所赞同的)是对于任何情况都不使用它们。其他人说,“还存在一个中间情形。”

通过在 C++ 中使用异常,其中所有的异常都是非检查型的,我已经发现非检查型异常的最大风险之一就是它并没有按照检查型异常采用的方式那样自我文档化。除非 API 的创建者明确地文档化将要抛出的异常,否则调用者没有办法知道在他们的代码中将要捕获的异常是什么。不幸的是,我的经验是大多数 C++ API 的文档化非常差,并且即使文档化很好的 API 也缺乏关于从一个给定方法可能抛出的异常的足够信息。我看不出有任何理由可以说该问题对于 Java 类库不是同样的常见,因为 Jav 类库严重依赖于非检查型异常。依赖于您自己的或者您的合作伙伴的编程技巧是非常困难的;如果不得不依赖于某个人的文档化技巧,那么对于他的代码您可能得使用调用栈中的十六个帧来作为您的主要的错误处理机制,这将会是令人恐慌的。

文档化问题进一步强调为什么懒惰是导致选择使用非检查型异常的一个不好的原因,因为对于文档化增加给包的负担,使用非检查型异常应该比使用检查型异常甚至更高(当文档化您所抛出的非检查型异常比检查型异常变得更为重要的时候)。





回页首


文档化,文档化,文档化

如果决定使用非检查型异常,您需要彻底地文档化这个选择,包括在 Javadoc 中文档化一个方法可能抛出的所有非检查型异常。Johnson 建议在每个包的基础上选择检查型和非检查型异常。使用非检查型异常时还要记住,即使您并不捕获任何异常,也可能需要使用 try...finally 块,从而可以执行清除动作例如关闭数据库连接。对于检查型异常,我们有 try...catch 用来提示增加一个 finally 子句。对于非检查型异常,我们则没有这个支撑可以依靠。

posted @ 2006-08-24 17:39 Binary 阅读(179) | 评论 (0)编辑 收藏

Java 理论和实践: 理解 JTS ― 平衡安全性和性能

为 EJB 组件定义事务划分和隔离属性(attribute)的职责由应用程序装配人员来承担。如果这些属性设置不当,会对应用程序的性能、可伸缩性或容错能力造成严重的后果。不幸的是,并没有一种必须遵守的规则用于正确设置这些属性,但有一些指导可以帮助我们在并发危险和性能危险之间找到一种平衡。

我们在第 1 部分中讨论过,事务主要是一种异常处理机制。事务在程序中的用途与合法合同在日常业务中的用途相似:如果出了什么问题它们可以帮助恢复。但由于大多数时间内都没实际 发生什么错误,我们就希望能够尽量减少它们的开销以及对其余时间的占用。我们在应用程序中如何使用事务会对应用程序的性能和可伸缩性产生很大的影响。

事务划分

J2EE 容器提供了两种机制用来定义事务的起点和终点:bean 管理的事务和容器管理的事务。在 bean 管理的事务中,用 UserTransaction.begin()UserTransaction.commit() 在 bean 方法中显式开始和结束一个事务。另一方面,容器管理的事务提供了更多的灵活性。通过在装配描述符中为每个 EJB 方法定义事务性属性,您可以指定每个方法的事务性需求并让容器确定何时开始和结束一个事务。无论在哪种情况下,构建事务的基本指导方针都是一样的。

进来,出去

事务划分的第一条规则是“尽量短小”。事务提供并发控制;这通常意味着资源管理器将代表您获得您在事务期间访问的数据项的锁,并且它必须一直持有这些锁,直到事务结束。(请回忆一下本系列第 1 部分所讨论的 ACID特性,其中“ACID”的“I”代表“隔离”(Isolation)。也就是说,一个事务的结果影响不到与该事务并发执行的其它事务。)当您拥有锁时,任何需要访问您锁定的数据项的其它事务将不得不一直等待,直到您释放锁。如果您的事务很长,那些其它的所有事务都将被锁定,您的应用程序吞吐量将大幅度下降。

规则 1:使事务尽可能短小。

通过使事务尽量短小,您可以把阻碍其它事务的时间缩到最短,从而提高应用程序的可伸缩性。保持事务尽可能短小的最好方法当然是不在事务中间做任何不必要耗费时间的事,特别是不要在事务中间等待用户输入。

开始一个事务,从数据库检索一些数据,显示数据,然后在仍处于事务中时请用户做出一个选择可能比较诱人。千万别这么做!即使用户注意力集中,也要花费数秒来响应 ― 而在数据库中拥有锁数秒的时间已经是很长的了。如果用户决定离开计算机,或许是去吃午餐或者甚至回家一天,会发生什么情况?应用程序将只好无奈停机。在事务期间执行 I/O 是导致灾难的秘诀。

规则 2:在事务期间不要等待用户输入。

将相关的操作归在一起

由于每个事务都有不小的开销,您可能认为最好是在单个事务中执行尽可能多的操作以使每个操作的开销达到最小。但规则 1 告诉我们长事务对可伸缩性不利。那么如何实现最小化每个操作的开销和可伸缩性之间的平衡呢?

我们把规则 1 设置为逻辑上的极端 ― 每个事务一个操作 ― 这样不仅会导致额外开销,还会危及应用程序状态的一致性。假定事务性资源管理器维护应用程序状态的一致性(请回忆一下第 1 部分,其中“ACID”的“C”代表“一致性”(Consistency)),但它们依赖应用程序来定义一致性的意思。实际上,我们在描述事务时使用的一致性的定义有点圆滑:应用程序说一致性是什么意思它就是什么意思。应用程序把几组应用程序状态的变化组织到几个事务中,结果应用程序的状态就成了 定义上的(by definition)一致。然后资源管理器确保如果它必须从故障恢复的话,就把应用程序状态恢复到最近的一致状态。

在第 1 部分中,我们给出了一个在银行应用程序中将资金从一个帐户转移到另一个帐户的示例。清单 1 展示了这个示例可能的 SQL 实现,它包含 5 个 SQL 操作(一个选择,两个更新和两个插入操作):


清单 1. 资金转移的样本 SQL 代码
												
														SELECT accountBalance INTO aBalance 
    FROM Accounts WHERE accountId=aId;
IF (aBalance >= transferAmount) THEN 
    UPDATE Accounts 
        SET accountBalance = accountBalance - transferAmount
        WHERE accountId = aId;
    UPDATE Accounts 
        SET accountBalance = accountBalance + transferAmount
        WHERE accountId = bId;
    INSERT INTO AccountJournal (accountId, amount)
        VALUES (aId, -transferAmount);
    INSERT INTO AccountJournal (accountId, amount)
        VALUES (bId, transferAmount);
ELSE
    FAIL "Insufficient funds in account";
END IF

												
										

如果我们把这个操作作为五个单独的事务来执行会发生什么情况?这样不仅会使执行速度变慢(由于事务开销),还会失去一致性。例如,如果一个人从帐户 A 取了钱,作为执行第一次 SELECT(检查余额)和随后的记入借方 UPDATE 之间的一个单独事务的一部分,会发生什么情况?这样会违反我们认为这段代码会强制遵守的业务规则 ― 帐户余额应该是非负的。如果在第一次 UPDATE 和第二次 UPDATE 之间系统失败会发生什么情况?现在,当系统恢复时,钱已经离开了帐户 A 但还没有记入帐户 B 的贷方,并且也无记录说明原因。这样,哪个帐户的所有者都不会开心。

清单 1 中的五个 SQL 操作是单个相关操作 ― 将资金从一个帐户转移到另一个帐户 ― 的一部分。因此,我们希望要么全部执行它们,要么一个也不执行,建议在单个事务中全部执行它们。

规则 3:将相关操作归到单个事务中。

理想化的平衡

规则 1 说事务应尽可能短小。清单 1 中的示例表明有时候我们必须把一些操作归到一个事务中来维护一致性。当然,它要依赖应用程序来确定“相关操作”是由什么组成的。我们可以把规则 1 和 3 结合在一起,提供一个描述事务范围的一般指导,我们规定它为规则 4:

规则 4:把相关操作归到单个事务中,但把不相关的操作放到单独的事务中。





回页首


容器管理的事务

在使用容器管理的事务时,不是显式声明事务的起点和终点,而是为每个 EJB 方法定义事务性需求。bean 的 assembly-descriptorcontainer-transaction 部分的 trans-attribute 元素中定义了事务模式。(清单 2 中显示了一个 assembly-descriptor 示例。)方法的事务模式以及状态 ― 调用方法是否早已在事务中被征用 ― 决定了当 EJB 方法被调用时容器应该进行下面几个操作中的哪一个:

  • 征用现有事务中的方法。
  • 创建一个新事务,并征用该事务中的方法。
  • 不征用任何事务中的方法。
  • 抛出一个异常。

清单 2. 样本 EJB 装配描述符
												
														<assembly-descriptor>
  ...
  <container-transaction>
    <method>
      <ejb-name>MyBean</ejb-name>
      <method-name>*</method-name>
    </method>
    <trans-attribute>Required</trans-attribute>
  </container-transaction>
  <container-transaction>
    <method>
      <ejb-name>MyBean</ejb-name>
      <method-name>logError</method-name>
    </method>
    <trans-attribute>RequiresNew</trans-attribute>
  </container-transaction>
  ...
</assembly-descriptor>

												
										

J2EE 规范定义了六种事务模式: RequiredRequiresNewMandatorySupportsNotSupportedNever 。表 1 概述了每种模式的行为 ― 在现有事务中被调用和不在事务内调用时的行为 ― 并描述了每种模式受哪些类型的 EJB 组件支持。(一些容器可能允许您在选择事务模式时有更多的灵活性,但这种使用要依赖特定于容器的功能,因此不适合跨容器的情况)。

表 1. 事务模式

事务模式 Bean 类型 在事务 T 内被调用时的行为 在事务外被调用时的行为
Required 会话、实体、消息驱动 在 T 中征用 新建事务
RequiresNew 会话、实体 新建事务 新建事务
Supports 会话、消息驱动 在 T 中征用 不带事务运行
Mandatory 会话、实体 在 T 中征用 出错
NotSupported 会话、消息驱动 不带事务运行 不带事务运行
Never 会话、消息驱动 出错 不带事务运行

在只使用容器管理的事务的应用程序中,只有组件调用事务模式为 RequiredRequiresNew 的 EJB 方法时才启动事务。如果容器创建一个事务作为调用事务性方法的结果,当该方法完成时将关闭该事务。如果方法正常返回,容器将提交事务(除非应用程序已经要求回滚事务)。如果方法通过抛出一个异常退出,容器将回滚事务并传播该异常。如果在现有事务 T 中调用了一个方法,并且事务模式指定应该不带事务运行该方法或者在新事务中运行该方法,那么事务 T 将被暂挂,一直到方法完成,然后先前的事务 T 被恢复。

选择一种事务模式

那么我们应该为自己的 bean 方法选择哪种模式呢?对于会话 bean 和消息驱动 bean,您通常想使用 Required 来确保每个调用都被作为事务的一部分执行,但仍将允许方法作为一个更大的事务的组件。请小心使用 RequiresNew ;只有在确定自己的方法的行为应该与调用您的方法的行为分开提交时,才应该使用这种模式。 RequiresNew 一般情况下只和与系统中其它对象关系很少或没什么关系的对象(比如日志对象)一起使用。(把 RequiresNew 与日志对象一起使用比较有意义,因为您可能希望在不管外围事务是否提交的情况下提交日志消息。)

RequiresNew 使用不当会导致与上面的描述相似的情况,其中,清单 1 中的代码在五个分开的事务而不是一个事务中执行,这样会使应用程序处于不一致状态。

对于 CMP(容器管理的持久性,container-managed persistence)实体 bean,通常是希望使用 RequiredMandatory 也是一个合理的选项,特别是在最初开发时;这将会警告您实体 bean 方法在事务外被调用这种情况,这时可能会指出一个部署错误。您几乎从不希望把 RequiresNew 和 CMP 实体 bean 一起使用。 NotSupportedNever 旨在用于非事务性资源,比如 Java 事务 API(Java Transaction API,JTA)事务中无法征用的外部非事务性系统或事务性系统的适配器。

如果 EJB 应用程序设计得当,应用上面的事务模式指导往往会自然地产生规则 4 建议的事务划分。原因是 J2EE 体系架构鼓励把应用程序分解为最小的方便处理的块,并且每个块都作为一个单独的请求被处理( 不管是以 HTTP 请求的形式还是作为在 JMS 队列中排队的消息的结果)。





回页首


重温隔离

在第 1 部分中,我们定义了 隔离(isolation)的意思是:一个事务的影响对与该事务并发执行的其它事务是不可见的;从事务的角度来看,好象事务是连续执行而非并行执行。尽管事务性资源管理器经常可以同时处理许多事务并提供隔离的假象,但有时隔离限制实际上要求把新事务延迟到现有事务完成后才开始。由于完成一个事务至少包括一个同步磁盘 I/O(写到事务日志),这就会把每秒的事务数限制到接近每秒的写磁盘次数,这对可伸缩性不利。

实际上,通常是充分放松隔离需求以允许更多的事务并发执行并使系统响应能够得到改善,使可伸缩性变得更强。几乎所有的数据库都支持标准隔离级别:读未提交的(Read Uncommitted)、读已提交的(Read Committed)、可重复的读(Repeatable Read) 和可串行化的(Serializable)。

不幸的是,为容器管理的事务管理隔离目前是在 J2EE 规范的范围之外。但是,许多 J2EE 容器,比如 IBM WebSphere 和 BEA WebLogic,将提供特定于容器的扩展,这些扩展允许您以每方法(per-method)为基础设置事务隔离级别,设置方法与在装配描述符中设置事务模式的方法相同。对于 bean 管理的事务,您可以通过 JDBC 或者其它资源管理器连接设置隔离级别。

为阐明隔离级别之间的差异,我们首先把几个并发危险分类 ― 这几种危险是当没有适当地隔离时一个事务可能会干涉另一个事务的情况。下列的所有这些危险都与这种情况( 第二个事务已经启动后第一个事务变得对第二个事务 可见)的结果有关:

  • 脏读(Dirty Read):当一个事务的中间(未提交的)结果对另一个事务可见时就会发生这种情况。
  • 不可重复的读(Unrepeatable Read):当一个事务读取一个数据项,然后重新读取这个数据项并看到不同的值时就是发生了这种情况。
  • 虚读(Phantom Read):当一个事务执行返回多个行的查询,稍后再次执行同一个查询并看到第一次执行该查询没出现的额外行时就是发生了这种情况。

四个标准隔离级别与这三个隔离危险相关,如表 2 所示。最低的隔离级别“读未提交的”并不能保护事务不被其它事务更改,但它的速度最快,因为它不需要争夺读锁。最高的隔离级别“可串行化的”与上面给出的隔离的定义相当;每个事务好象都与其它事务的影响完全隔离。

表 2. 事务隔离级别

隔离级别 脏读 不可重复的读 虚读
读未提交的
读已提交的
可重复的读
可串行化的

对于大多数数据库,缺省的隔离级别为“读已提交的”,这是个很好的缺省选择,因为它阻止事务在事务中的任何给定的点看到应用程序数据的不一致视图。“读已提交的”是一个很不错的隔离级别,用于大多数典型的短事务,比如获取报表数据或获取要显示给用户的数据的时候(多半是作为 Web 请求的结果),也用于将新数据插入到数据库的情况。

当您需要所有事务间有较高级别的一致性时,使用较高的隔离级别“可重复的读”和“可串行化的”比较合适,比如在清单 1 示例中,您希望从检查余额以确保有足够的资金到您实际取钱期间账户余额一直保持不变;这就要求至少要用“可重复的读”隔离级别。在数据一致性绝对重要的情况下,比如审核记帐数据库以确保一个帐户的所有借方金额和贷方金额的总数等于它目前的余额时,可能还需要防止创建新行。这种情况下就需要使用“可串行化的”隔离级别。

最低的隔离级别“读未提交的”很少使用。它适用于您只需要获得近似值,否则查询将导致您不希望的性能开销这种情况。当您想要估计一个变化很快的数量,如定单数或者今天所下定单的总金额(以美元为单位)时一般使用““读未提交的”。

因为隔离和可伸缩性之间实际是一种此消彼长的关系,所以您在为事务选择隔离级别时应该小心行事。选择太低的级别对数据比较危险。选择太高的级别可能对性能不利,尽管负载比较轻时可能不会这样。一般来说,数据一致性问题比性能问题更严重。如果拿不准,应该以小心为主,选择一个较高的隔离级别。这就引出了规则 5:

规则 5:使用保证数据安全的最低隔离级别,但如果拿不准,请使用“可串行化的”。

即使您打算刚开始时以小心为主并希望结果性能可以接受 ―(被称为“拒绝和祈祷(denial and prayer)”的性能管理技术 ― 很可能是最常用的性能策略,尽管大多数开发者都不承认这一点),在开发组件时考虑隔离需求也是有利的。您应该努力编写能够容忍级别较低但实用的隔离级别的事务,这样,当稍后性能成为问题时,自己就不会陷入困境。因为您需要知道方法正在做什么以及这个方法中隐藏了什么一致性假设来正确设置隔离级别,那么在开发期间仔细说明并发需求和假设,以便在装配应用程序时帮助作出正确的决定也不失为一个好主意。





回页首


结束语

本文中提供的许多指导可能看起来有点互相矛盾,因为象事务划分和隔离这种问题本来就是此消彼长的。我们正在努力平衡安全性(如果我们不关心安全性,那就压根不必用事务了)和我们用来提供安全限度的工具的性能开销。正确的平衡要依赖许多因素,包括与系统故障或当机时间相关的代价或损害以及组织的风险承受能力。

posted @ 2006-08-24 17:38 Binary 阅读(191) | 评论 (0)编辑 收藏

Java 理论与实践: 在没有数据库的情况下进行数据库查询

手里有锤子的时候,看什么东西都像钉子(就像古谚语所说的那样)。但是如果没有锤子时该怎样办呢?有时,您可以去借一把锤子。然后,拿着这把借来的锤子敲打虚拟的钉子,最后归还锤子,没人知道这些。在本月的 Java 理论与实践 系列中,Brian Goetz 将演示如何将 SQL 或者 XQuery 这样的数据操纵之锤应用于非持久存储的数据。请在本文附带的 讨论论坛 中与作者和其他读者分享您对本文的看法。(也可以单击本文顶部或底部的 讨论 来访问该论坛。)

我最近仔细考察了一个项目,该项目涉及相当多的 Web 快速搜索。当爬虫程序爬过不同的 Web 站点时,它将建立一个数据库,该数据库中包括它所爬过的站点和网页、每一页所包含的链接、每一页的分析结果等数据。最终结果是一组报告,详细说明经过了哪些站点和页面、哪些是一直链接的、哪些链接已经断开、哪些页面有错误、计算出的页面规格,等等。开始的时候,没人确切知道需要什么样的报告,或者应当采用什么样的格式 —— 只知道有一些内容要报告。这表明报告开发阶段会是一个反复的阶段,要经过多次反馈、修改,并且可能尝试使用不同的结构。惟一确定的报告要求是,报告应当以 XML 形式展示,也可能以 HTML 形式展示。因此,开发和修改报告的过程必须是轻量级的,因为报告要求是“动态发现”的,而不是预先指定的。

不需要数据库

对这个问题的“最显而易见的”解决方法是将所有东西都放入 SQL 数据库中 —— 页面、链接、度量标准、HTTP 结果代码、计时结果和其他元数据。这个问题可以借助关系表示来很好地解决,特别是因为这种方法不需要存储已访问页面的内容,只需要存储它们的结构和元数据。

到目前为止,这个项目看起来像是一个典型的数据库应用程序,并且它并不缺少可供选择的持久性策略。但是,或许可以避免使用数据库持久存储数据的复杂性 —— 这个快速搜索工具(crawler)只访问数万个页面。这个数字不是很大,因此可以将整个数据库放在内存中,当需要持久存储数据时,可以通过序列化来实现它。(是的,加载和保存操作要花费较长的时间,但是这些操作并不经常执行。)懒惰反而带来了一个好处 —— 不需要处理持久性极大地缩短了开发应用程序的时间,因而显著地减少了开发工作量。构建和操纵内存中的数据结构要比每次添加、提取或者分析数据时都使用数据库容易得多。不管选择了哪种持久存储模型,都会限制任何触及到数据的代码的构造。

内存中的数据结构是一种树型结构,如清单 1 所示,它的根是快速搜索过的各个网站的主页,因此 Visitor 模式是搜索这些主页或者从中提取数据的理想模式。(构建一个防止陷入链接循环 —— A 链接到 B、B 链接到 C、C 链接到 A —— 的基本 Visitor 类并不是很难。)


清单 1. Web 爬行器的一个简化方案
												
																		
public class Site {
    Page homepage;
    Collection<Page> pages;
    Collection<Link> links;
}

public class Page {
    String url;
    Site site;
    PageMetrics metrics;
}

public class Link {
    Page linkFrom;
    Page linkTo;
    String anchorText;
}

												
										

这个快速搜索工具的应用程序中有十多个 Visitor,它们所做的事情类似于选择页面做进一步分析、选择不带链接的页面、列出“被链接最多”的页面,等等。因为所有这些操作都很简单,所以 Visitor 模式(如清单 2 所示)可以工作得很好,由于数据结构可以放到内存中,因此就算进行彻底搜索,花费也不是很大:


清单 2. 用于 Web 快速搜索工具数据库的 Visitor 模式
												
																		
public interface Visitor {
    public void visitSite(Site site);
    public void visitLink(Link link);
}

												
										

噢,忘记报告了

如果不运行报告的话,Visitor 策略在访问数据方面会做得非常好。使用数据库进行持久存储的一个好处是:在生成报告时,SQL 的能力就会大放光彩 —— 几乎可以让数据库做任何事情。甚至用 SQL 生成报告原型也很容易 —— 运行原型报告,如果结果不是所需要的结果,那么可以修改 SQL 查询或者编写新的查询,然后再试一试。如果改变的只是 SQL 查询的话,那么这个编辑-编译-运行周期可能很快。如果 SQL 不是存储在程序中,那么您甚至可以跳过这个周期的编译部分,这样可以快速生成报告的原型。确定所需要的报告后,将它们构建到应用程序中就很容易了。

因此,虽然对于添加新结果、寻找特定的结果和进行特殊传输来说,内存中的数据结构都表现得很不错,但是对于报告来说,这些变成了不利条件。对于所有其自身结构与数据库结构不同的报告,Visitor 都必须创建一个全新的数据结构,以包含报告数据。因此,每一种报告类型都需要有自己的、特定于报告的中间数据结构来存放结果,还需要一个用来填充中间数据结构的访问者,以及用来将中间数据结构转换成最终报告的后处理(post-processing)代码。似乎需要做很多工作,尤其在大多数原型报告将被抛弃时。例如,假定您想要列出所有从其他网站链接到某个给定网站的页面的报告、所有外部页面的列表报告,以及站点上链接该页面的那些页面的列表,然后,根据链接的数量对报告进行归类,链接最多的页面显示在最前面。这个计划基本上将数据结构从里到外翻了个个儿。为了用 Visitor 实现这种数据转换,需要获得从某个给定网站可以到达的外部页面链接的列表,并根据被链接的页面对它们进行分类,如清单 3 所示:


清单 3. Visitor 列出被链接最多的页面,以及链接到它们的页面
												
																		
public class InvertLinksVisitor {
    public Map<Page, Set<Page>> map = ...;
    
    public void visitLink(Link link) {
        if (link.linkFrom.site.equals(targetSite) 
            && !link.linkTo.site.equals(targetSite)) {
            if (!map.containsKey(link.linkTo))
                map.put(link.linkTo, new HashSet<Page>());
            map.get(link.linkTo).add(link.linkFrom);
        }
    }
}

												
										

清单 3 中的 Visitor 生成一个映射,将每一个外部页面与链接它的一组内部页面相关联。为了准备该报告,还必须根据关联页面的大小对这些条目进行分类,然后创建报告。虽然没有任何困难步骤,但是每一个报告需要的特定于报告的代码数量却很多,因此快速报告原型就成为一个重要的目标(因为没有提出报告要求),试验新报告的开销比理想情况更高。许多报告需要多次传递数据,以便对数据进行选择、汇总和分类。





回页首


我的数据模型王国

这时,缺少一个正式的数据模型开始成为一项不利因素,该数据模型可以用于描述收集的数据,并且可以用它更容易地表示选择和聚合查询。也许懒惰不像开始希望的那样有效。但是,虽然这个应用程序缺少正式数据模型,但也许我们可以将数据存储到内存中的数据库,并凭借该数据库进行查询,通过这种方式借用一个数据模型。有两种可能会立即出现在您的脑海中:开源的内存中的 SQL 数据库 HSQLDB 和 XQuery。我不需要数据库提供的持久性,但是我确实需要查询语言。

HSQLDB 是一个用 Java 语言编写的可嵌入的数据库引擎。它既包含适用于内存中表的表类型,又包含适用于基于磁盘的表的表类型,设计该引擎为了将表完全嵌入到应用程序中,消除与大多数真实数据库相关的管理开销。要将数据装载到 HSQLDB,只需编写一个 Visitor 即可,该 Visitor 将遍历内存中的数据结构,并为每一个将要存储的实体生成相应的 INSERT 语句。然后可以对这个内存中的数据库表执行 SQL 查询,以生成报告,并在完成这些操作后抛弃这个“数据库”。

噢,忘记了关系数据库有多烦人

HSQLDB 方法是一个可行方法,但您很快就发现,我必须为对象关系的不匹配而两次(而不是一次)受罚 —— 一次是在将树型结构数据库转换为关系数据模型时,一次是在将平面关系查询结果转换成结构化的 XML 或者 HTML 结果集时。此外,将 JDBC ResultSet 后处理为 DOM 表示形式的 XML 或者 HTML 文档也不是一项很容易的任务,需要为每一个报告提供一些定制的编码。因此虽然内存中的 SQL 数据库 的确 可以简化查询,但是从数据库中存入和取出数据所需要的额外代码会抵消所有节省的代码。





回页首


让 XQuery 来拯救您

另一个容易得到的数据查询方法是 XQuery。XQuery 的优点是,它是为生成 XML 或者 HTML 文档作为查询结果而设计的,因此不需要对查询结果进行后处理。这种想法很有吸引力 —— 每个报告只有一层编码,而不是两层或者更多层。因此第一项任务是构建一个表示整个数据集的 XML 文档。设计一个简单的 XML 数据模型和编写遍历数据结构,并将每一个元素附加到一个 DOM 文档中的 Visitor 很简单。(不需要写出这个文档。可以将它保持在内存中,用于查询,然后在完成查询时丢弃它。当底层数据改变时,可以重新生成它。)之后,所有要做的就是编写 XQuery 查询,该查询将选择并聚集用于报告的数据,并按最终需要的格式(XML 或 HTML)对它们进行格式化。查询可以存储在单独的文件中,以便进行快速原型制造,因此,可支持多种报告格式。使用 Saxon 评估查询的代码如清单 4 中所示:


清单 4. 执行 XQuery 查询并将结果序列化为 XML 或 HTML 文档的代码
												
																		
  String query = readFile(queryFile + ".xq");
  Configuration c = new Configuration();
  StaticQueryContext qp = new StaticQueryContext(c);
  XQueryExpression xe = qp.compileQuery(query);
  DynamicQueryContext dqc = new DynamicQueryContext(c);
  dqc.setContextNode(new DocumentWrapper(document, z.getName(), c));
  List result = xe.evaluate(dqc);

  FileOutputStream os = new FileOutputStream(fileName);
  XMLSerializer serializer = new XMLSerializer (os, format);
  serializer.asDOMSerializer();

  for(Iterator i = result.iterator(); i.hasNext(); ) {
      Object o = i.next();
      if (o instanceof Element)
          serializer.serialize((Element) o);
      else if (o instanceof Attr) {
          Element e = document.createElement("scalar");
          e.setTextContent(((Attr) o).getNodeValue());
          serializer.serialize(e);
      }
      else {
          Element e = document.createElement("scalar");
          e.setTextContent(o.toString());
          serializer.serialize(e);
      }
  }
  os.close(); 

												
										

表示数据库的 XML 文档的结构与内存中的数据结构稍有不同,每一个 <site> 元素都有嵌套的 <page> 元素,每一个 <page> 元素都有嵌套的 <link> 元素,而每一个 <link> 元素都有 <link-to> 和 <link-from> 元素。实践证明,这种表示方法对于大多数报告都很方便。

清单 5 显示了一个示例 XQuery 报告,这个报告处理链接的选择、分类和表示。它有几个地方优于 Visitor 方法 —— 不仅代码少(因为查询语言支持选择、聚积和分类),而且所有报告的代码 —— 选择、聚积、分类和表示 —— 都在一个位置上。


清单 5.生成链接次数最多的页面的完整报告的 XQuery 代码
												
																		
<html>
<head><title>被链接最多的页面</title></head>
<body>
<ul>
{
  let $links := //link[link-to/@siteUrl ne $targetSite
                       and link-from/@siteUrl eq $targetSite]
  for $page in distinct-values($links/link-to/@url)
  let $linkingPages := $links[link-to/@url eq $page]/link-from/@url
  order by count($linkingPages)
  return 
    <li>Page {$page}, {count($linkingPages)} links 
    <ul> {
      for $p in $linkingPages return <li>Linked from {$p/@url}</li>
    }
    </ul></li>
}
</ul> </body> </html>

												
										





回页首


结束语

从开发成本角度看,XQuery 方法已证实可以节约大量成本。树型结构对于构建和搜索数据很理想,但对于报告,就不是很理想了。XML 方法很适合于报告(因为可以利用 XQuery 的能力),但是对于整个应用程序的实现,该方法还有很多不便,并会降低性能。因为数据集的大小是可管理的 —— 只有几十兆字节,所以可以将数据从一种格式转换为从开发的角度看最方便的另一种格式。更大的数据集,比如不能完全存储到内存中的数据集,会要求整个应用程序都围绕着一个数据库构建。虽然有许多处理数据持久性的好工具,但是它们需要的工作都比简单操纵内存中数据结构要多得多。如果数据集的大小合适,那么就可以同时利用这两种方法的长处。

posted @ 2006-08-24 17:36 Binary 阅读(302) | 评论 (0)编辑 收藏

Java 理论与实践: 动态编译与性能测量

为动态编译的语言(例如 Java)编写和解释性能评测,要比为静态编译的语言(例如 C 或 C++)编写困难得多。在这期的 Java 理论与实践 中,Brian Goetz 介绍了动态编译使性能测试复杂的诸多原因中的一些。请在本文附带的讨论组上与作者和其他读者分享您对本文的看法。 (您也可以选择本文顶部或底部的 讨论 访问论坛。)

这个月,我着手撰写一篇文章,分析一个写得很糟糕的微评测。毕竟,我们的程序员一直受性能困扰,我们也都想了解我们编写、使用或批评的代码的性能特征。当我偶然间写到性能这个主题时,我经常得到这样的电子邮件:“我写的这个程序显示,动态 frosternation 要比静态 blestification 快,与您上一篇的观点相反!”许多随这类电子邮件而来的所谓“评测“程序,或者它们运行的方式,明显表现出他们对于 JVM 执行字节码的实际方式缺乏基本认识。所以,在我着手撰写这样一篇文章(将在未来的专栏中发表)之前,我们先来看看 JVM 幕后的东西。理解动态编译和优化,是理解如何区分微评测好坏的关键(不幸的是,好的微评测很少)。

动态编译简史

Java 应用程序的编译过程与静态编译语言(例如 C 或 C++)不同。静态编译器直接把源代码转换成可以直接在目标平台上执行的机器代码,不同的硬件平台要求不同的编译器。 Java 编译器把 Java 源代码转换成可移植的 JVM 字节码,所谓字节码指的是 JVM 的“虚拟机器指令”。与静态编译器不同,javac 几乎不做什么优化 —— 在静态编译语言中应当由编译器进行的优化工作,在 Java 中是在程序执行的时候,由运行时执行。

第一代 JVM 完全是解释的。JVM 解释字节码,而不是把字节码编译成机器码并直接执行机器码。当然,这种技术不会提供最好的性能,因为系统在执行解释器上花费的时间,比在需要运行的程序上花费的时间还要多。

即时编译

对于证实概念的实现来说,解释是合适的,但是早期的 JVM 由于太慢,迅速获得了一个坏名声。下一代 JVM 使用即时 (JIT) 编译器来提高执行速度。按照严格的定义,基于 JIT 的虚拟机在执行之前,把所有字节码转换成机器码,但是以惰性方式来做这项工作:JIT 只有在确定某个代码路径将要执行的时候,才编译这个代码路径(因此有了名称“ 即时 编译”)。这个技术使程序能启动得更快,因为在开始执行之前,不需要冗长的编译阶段。

JIT 技术看起来很有前途,但是它有一些不足。JIT 消除了解释的负担(以额外的启动成本为代价),但是由于若干原因,代码的优化等级仍然是一般般。为了避免 Java 应用程序严重的启动延迟,JIT 编译器必须非常迅速,这意味着它无法把大量时间花在优化上。所以,早期的 JIT 编译器在进行内联假设(inlining assumption)方面比较保守,因为它们不知道后面可能要装入哪个类。

虽然从技术上讲,基于 JIT 的虚拟机在执行字节码之前,要先编译字节码,但是 JIT 这个术语通常被用来表示任何把字节码转换成机器码的动态编译过程 —— 即使那些能够解释字节码的过程也算。

HotSpot 动态编译

HotSpot 执行过程组合了编译、性能分析以及动态编译。它没有把所有要执行的字节码转换成机器码,而是先以解释器的方式运行,只编译“热门”代码 —— 执行得最频繁的代码。当 HotSpot 执行时,会搜集性能分析数据,用来决定哪个代码段执行得足够频繁,值得编译。只编译执行最频繁的代码有几项性能优势:没有把时间浪费在编译那些不经常执行的代码上;这样,编译器就可以花更多时间来优化热门代码路径,因为它知道在这上面花的时间物有所值。而且,通过延迟编译,编译器可以访问性能分析数据,并用这些数据来改进优化决策,例如是否需要内联某个方法调用。

为了让事情变得更复杂,HotSpot 提供了两个编译器:客户机编译器和服务器编译器。默认采用客户机编译器;在启动 JVM 时,您可以指定 -server 开关,选择服务器编译器。服务器编译器针对最大峰值操作速度进行了优化,适用于需要长期运行的服务器应用程序。客户机编译器的优化目标,是减少应用程序的启动时间和内存消耗,优化的复杂程度远远低于服务器编译器,因此需要的编译时间也更少。

HotSpot 服务器编译器能够执行各种样的类。它能够执行许多静态编译器中常见的标准优化,例如代码提升( hoisting)、公共的子表达式清除、循环展开(unrolling)、范围检测清除、死代码清除、数据流分析,还有各种在静态编译语言中不实用的优化技术,例如虚方法调用的聚合内联。

持续重新编译

HotSpot 技术另一个有趣的方面是:编译不是一个全有或者全无(all-or-nothing)的命题。在解释代码路径一定次数之后,会把它重新编译成机器码。但是 JVM 会继续进行性能分析,而且如果认为代码路径特别热门,或者未来的性能分析数据认为存在额外的优化可能,那么还有可能用更高一级的优化重新编译代码。JVM 在一个应用程序的执行过程中,可能会把相同的字节码重新编译许多次。为了深入了解编译器做了什么,请用 -XX:+PrintCompilation 标志调用 JVM,这个标志会使编译器(客户机或服务器)每次运行的时候打印一条短消息。

栈上(On-stack)替换

HotSpot 开始的版本编译的时候每次编译一个方法。如果某个方法的累计执行次数超过指定的循环迭代次数(在 HotSpot 的第一版中,是 10,000 次),那么这个方法就被当作热门方法,计算的方式是:为每个方法关联一个计数器,每次执行一个后向分支时,就会递增计数器一次。但是,在方法编译之后,方法调用并没有切换到编译的版本,需要退出并重新进入方法,后续调用才会使用编译的版本。结果就是,在某些情况下,可能永远不会用到编译的版本,例如对于计算密集型程序,在这类程序中所有的计算都是在方法的一次调用中完成的。重量级方法可能被编译,但是编译的代码永远用不到。

HotSpot 最近的版本采用了称为 栈上(on-stack)替换 (OSR) 的技术,支持在循环过程中间,从解释执行切换到编译的代码(或者从编译代码的一个版本切换到另一个版本)。





回页首


那么,这与评测有什么关系?

我向您许诺了一篇关于评测和性能测量的文章,但是迄今为止,您得到的只是历史的教训和 Sun 的 HotSpot 白皮书的老调重谈。绕这么大的圈子的原因是,如果不理解动态编译的过程,就不可能正确地编写或解释 Java 类的性能测试。(即使深入理解动态编译和 JVM 优化,也仍然是非常困难的。)

为 Java 代码编写微评测远比为 C 代码编写难得多

判断方法 A 是否比方法 B 更快的传统方法,是编写小的评测程序,通常叫做 微评测。这个趋势非常有意义。科学的方法不能缺少独立的调查。魔鬼总在细节之中。为动态编译的语言编写并解释评测,远比为静态编译的语言难得多。为了了解某个结构的性能,编写一个使用该结构的程序一点也没有错,但是在许多情况下,用 Java 编写的微评测告诉您的,往往与您所认为的不一样。

使用 C 程序时,您甚至不用运行它,就能了解许多程序可能的性能特征。只要看看编译出的机器码就可以了。编译器生成的指令就是将要执行的机器码,一般情况下,可以很合理地理解它们的时间特征。(有许多有毛病的例子,因为总是遗漏分支预测或缓存,所以性能差的程度远远超过查看机器码所能够想像的程度,但是大多数情况下,您都可以通过查看机器码了解 C 程序的性能的很多方面。)

如果编译器认为某段代码不恰当,准备把它优化掉(通常的情况是,评测到它实际上不做任何事情),那么您在生成的机器码中可以看到这个优化 —— 代码不在那儿了。通常,对于 C 代码,您不必执行很长时间,就可以对它的性能做出合理的推断。

而在另一方面,HotSpot JIT 在程序运行时会持续地把 Java 字节码重新编译成机器码,而重新编译触发的次数无法预期,触发重新编译的依据是性能分析数据积累到一定数量、装入新类,或者执行到的代码路径的类已经装入,但是还没有执行过。持续的重新编译情况下的时间测量会非常混乱、让人误解,而且要想获得有用的性能数据,通常必须让 Java 代码运行相当长的时间(我曾经看到过一些怪事,在程序启动运行之后要加速几个小时甚至数天),才能获得有用的性能数据。





回页首


清除死代码

编写好评测的一个挑战就是,优化编译器要擅长找出死代码 —— 对于程序执行的输出没有作用的代码。但是评测程序一般不产生任何输出,这就意味着有一些,或者全部代码都有可能被优化掉,而毫无知觉,这时您实际测量的执行要少于您设想的数量。具体来说,许多微评测在用 -server 方式运行时,要比用 -client 方式运行时好得多,这不是因为服务器编译器更快(虽然服务器编译器一般更快),而是因为服务器编译器更擅长优化掉死代码。不幸的是,能够让您的评测工作非常短(可能会把评测完全优化掉)的死代码优化,在处理实际做些工作的代码时,做得就不会那么好了。

奇怪的结果

清单 1 的评测包含一个什么也不做的代码块,它是从一个测试并发线程性能的评测中摘出来的,但是它实际测量的根本不是要评测的东西。(这个示例是从 JavaOne 2003 的演示 “The Black Art of Benchmarking” 中借用的。请参阅 参考资料。)


清单 1. 被意料之外的死代码弄乱的评测
												
																		
        
public class StupidThreadTest {
    public static void doSomeStuff() {
        double uselessSum = 0;
        for (int i=0; i<1000; i++) {
            for (int j=0;j<1000; j++) {
                uselessSum += (double) i + (double) j;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        doSomeStuff();
        
        int nThreads = Integer.parseInt(args[0]);
        Thread[] threads = new Thread[nThreads];
        for (int i=0; i<nThreads; i++)
            threads[i] = new Thread(new Runnable() {
                public void run() { doSomeStuff(); }
            });
        long start = System.currentTimeMillis();
        for (int i = 0; i < threads.length; i++)
            threads[i].start();
        for (int i = 0; i < threads.length; i++)
            threads[i].join();
        long end = System.currentTimeMillis();
        System.out.println("Time: " + (end-start) + "ms");
    }
}

      
												
										

表面上看, doSomeStuff() 方法可以给线程分点事做,所以我们能够从 StupidThreadBenchmark 的运行时间推导出多线程调度开支的一些情况。但是,因为 uselessSum 从没被用过,所以编译器能够判断出 doSomeStuff 中的全部代码是死的,然后把它们全部优化掉。一旦循环中的代码消失,循环也就消失了,只留下一个空空如也的 doSomeStuff。表 1 显示了使用客户机和服务器方式执行 StupidThreadBenchmark 的性能。两个 JVM 运行大量线程的时候,都表现出差不多是线性的运行时间,这个结果很容易被误解为服务器 JVM 比客户机 JVM 快 40 倍。而实际上,是服务器编译器做了更多优化,发现整个 doSomeStuff 是死代码。虽然确实有许多程序在服务器 JVM 上会提速,但是您在这里看到的提速仅仅代表一个写得糟糕的评测,而不能成为服务器 JVM 性能的证明。但是如果您没有细看,就很容易会把两者混淆。


表 1. 在客户机和服务器 JVM 中 StupidThreadBenchmark 的性能
线程数量 客户机 JVM 运行时间 服务器 JVM 运行时间
10 43 2
100 435 10
1000 4142 80
10000 42402 1060

对于评测静态编译语言来说,处理过于积极的死代码清除也是一个问题。但是,在静态编译语言中,能够更容易地发现编译器清除了大块评测。您可以查看生成的机器码,查看是否漏了某块程序。而对于动态编译语言,这些信息不太容易访问得到。





回页首


预热

如果您想测量 X 的性能,一般情况下您是想测量它编译后的性能,而不是它的解释性能(您想知道 X 在赛场上能跑多快)。要做到这样,需要“预热” JVM —— 即让目标操作执行足够的时间,这样编译器在为执行计时之前,就有足够的运行解释的代码,并用编译的代码替换解释代码。

使用早期 JIT 和没有栈上替换的动态编译器,有一个容易的公式可以测量方法编译后的性能:运行多次调用,启动计时器,然后执行若干次方法。如果预热调用超过方法被编译的阈值,那么实际计时的调用就有可能全部是编译代码执行的时间,所有的编译开支应当在开始计时之前发生。

而使用今天的动态编译器,事情更困难。编译器运行的次数很难预测,JVM 按照自己的想法从解释代码切换到编译代码,而且在运行期间,相同的代码路径可能编译、重新编译不止一次。如果您不处理这些事件的计时问题,那么它们会严重歪曲您的计时结果。

图 1 显示了由于预计不到的动态编译而造成的可能的计时歪曲。假设您正在通过循环计时 200,000 次迭代,编译代码比解释代码快 10 倍。如果编译只在 200,000 次迭代时才发生,那么您测量的只是解释代码的性能(时间线(a))。如果编译在 100,000 次迭代时发生,那么您总共的运行时间是运行 200,000 次解释迭代的时间,加上编译时间(编译时间非您所愿),加上执行 100,000 次编译迭代的时间(时间线(b))。如果编译在 20,000 次迭代时发生,那么总时间会是 20,000 次解释迭代,加上编译时间,再加上 180,000 次编译迭代(时间线(c))。因为您不知道编译器什么时候执行,也不知道要执行多长时间,所以您可以看到,您的测量可能受到严重的歪曲。根据编译时间和编译代码比解释代码快的程度,即使对迭代数量只做很小的变化,也可能造成测量的“性能”有极大差异。


图 1. 因为动态编译计时造成的性能测量歪曲
时间线图

那么,到底多少预热才足够呢?您不知道。您能做到的最好的,就是用 -XX:+PrintCompilation 开关来运行评测,观察什么造成编译器工作,然后改变评测程序的结构,以确保编译在您启动计时之前发生,在计时循环过程中不会再发生编译。

不要忘记垃圾收集

那么,您已经看到,如果您想得到正确的计时结果,就必须要让被测代码比您想像的多运行几次,以便让 JVM 预热。另一方面,如果测试代码要进行对象分配工作(差不多所有的代码都要这样),那么垃圾收集器也肯定会运行。这是会严重歪曲计时结果的另一个因素 —— 即使对迭代数量只做很小的变化,也意味着没有垃圾收集和有垃圾收集之间的区别,就会偏离“每迭代时间”的测量。

如果用 -verbose:gc 开关运行评测,您可以看到在垃圾收集上耗费了多少时间,并相应地调整您的计时数据。更好一些的话,您可以长时间运行您的程序,这可以保证触发许多垃圾收集,从而更精确地分摊垃圾收集的成本。





回页首


动态反优化(deoptimization)

许多标准的优化只能在“基本块”内执行,所以内联方法调用对于达到好的优化通常很重要。通过内联方法调用,不仅方法调用的开支被清除,而且给优化器提供了更大的优化块可以优化,会带来相当大的死代码优化机会。

清单 2 显示了一个通过内联实现的这类优化的示例。 outer() 方法用参数 null 调用 inner(),结果是 inner() 什么也不做。但是通过把 inner() 的调用内联,编译器可以发现 inner()else 分支是死的,因此能够把测试和 else 分支优化掉,在某种程度上,它甚至能把整个对 inner() 的调用全优化掉。如果 inner() 没有被内联,那么这个优化是不可能发生的。


清单 2. 内联如何带来更好的死代码优化
												
																		
        
public class Inline {
  public final void inner(String s) {
    if (s == null)
      return;
    else {
      // do something really complicated
    }
  }

  public void outer() {
    String s=null; 
    inner(s);
  }
}

      
												
										

但是不方便的是,虚方法对内联造成了障碍,而虚函数调用在 Java 中要比在 C++ 中普遍。假设编译器正试图优化以下代码中对 doSomething() 的调用:

												
														  Foo foo = getFoo();
  foo.doSomething(); 

												
										

从这个代码片断中,编译器没有必要分清要执行哪个版本的 doSomething() —— 是在类 Foo 中实现的版本,还是在 Foo 的子类中实现的版本?只在少数情况下答案才明显 —— 例如 Foofinal 的,或者 doSomething()Foo 中被定义为 final 方法 —— 但是在多数情况下,编译器不得不猜测。对于每次只编译一个类的静态编译器,我们很幸运。但是动态编译器可以使用全局信息进行更好的决策。假设有一个还没有装入的类,它扩展了应用程序中的 Foo。现在的情景更像是 doSomething()Foo 中的 final 方法 —— 编译器可以把虚方法调用转换成一个直接分配(已经是个改进了),而且,还可以内联 doSomething()。(把虚方法调用转换成直接方法调用,叫做 单形(monomorphic)调用变换。)

请稍等 —— 类可以动态装入。如果编译器进行了这样的优化,然后装入了一个扩展了 Foo 的类,会发生什么?更糟的是,如果这是在工厂方法 getFoo() 内进行的会怎么样? getFoo() 会返回新的 Foo 子类的实例?那么,生成的代码不就无效了么?对,是无效了。但是 JVM 能指出这个错误,并根据目前无效的假设,取消生成的代码,并恢复解释(或者重新编译不正确的代码路径)。

结果就是,编译器要进行主动的内联决策,才能得到更高的性能,然后当这些决策依据的假设不再有效时,就会收回这些决策。实际上,这个优化如此有效,以致于给那些不被覆盖的方法添加 final 关键字(一种性能技巧,在以前的文章中建议过)对于提高实际性能没有太大作用。

奇怪的结果

清单 3 中包含一个代码模式,其中组合了不恰当的预热、单形调用变换以及反优化,因此生成的结果毫无意义,而且容易被误解:


清单 3. 测试程序的结果被单形调用变换和后续的反优化歪曲
												
																		
        
public class StupidMathTest {
    public interface Operator {
        public double operate(double d);
    }

    public static class SimpleAdder implements Operator {
        public double operate(double d) {
            return d + 1.0;
        }
    }

    public static class DoubleAdder implements Operator {
        public double operate(double d) {
            return d + 0.5 + 0.5;
        }
    }

    public static class RoundaboutAdder implements Operator {
        public double operate(double d) {
            return d + 2.0 - 1.0;
        }
    }

    public static void runABunch(Operator op) {
        long start = System.currentTimeMillis();
        double d = 0.0;
        for (int i = 0; i < 5000000; i++)
            d = op.operate(d);
        long end = System.currentTimeMillis();
        System.out.println("Time: " + (end-start) + "   ignore:" + d);
    }

    public static void main(String[] args) {
        Operator ra = new RoundaboutAdder();
        runABunch(ra); // misguided warmup attempt
        runABunch(ra);
        Operator sa = new SimpleAdder();
        Operator da = new DoubleAdder();
        runABunch(sa);
        runABunch(da);
    }
}

      
												
										

StupidMathTest 首先试图做些预热(没有成功),然后测量 SimpleAdderDoubleAdderRoundaboutAdder 的运行时间,结果如表 2 所示。看起来好像先加 1,再加 2 ,然后再减 1 最快。加两次 0.5 比加 1 还快。这有可能么?(答案是:不可能。)


表 2. StupidMathTest 毫无意义且令人误解的结果
方法 运行时间
SimpleAdder 88ms
DoubleAdder 76ms
RoundaboutAdder 14ms

这里发生什么呢?在预热循环之后, RoundaboutAdderrunABunch() 确实已经被编译了,而且编译器 OperatorRoundaboutAdder 上进行了单形调用转换,第一轮运行得非常快。而在第二轮( SimpleAdder)中,编译器不得不反优化,又退回虚函数分配之中,所以第二轮的执行表现得更慢,因为不能把虚函数调用优化掉,把时间花在了重新编译上。在第三轮( DoubleAdder)中,重新编译比第二轮少,所以运行得就更快。(在现实中,编译器会在 RoundaboutAdderDoubleAdder 上进行常数替换(constant folding),生成与 SimpleAdder 几乎相同的代码。所以如果在运行时间上有差异,那么不是因为算术代码)。哪个代码首先执行,哪个代码就会最快。

那么,从这个“评测”中,我们能得出什么结论呢?实际上,除了评测动态编译语言要比您可能想到的要微妙得多之外,什么也没得到。





回页首


结束语

这个示例中的结果错得如此明显,所以很清楚,肯定发生了什么,但是更小的结果能够很容易地歪曲您的性能测试程序的结果,却不会触发您的“这里肯定有什么东西有问题”的警惕。虽然本文列出的这些内容是微评测歪曲的一般来源,但是还有许多其他来源。本文的中心思想是:您正在测量的,通常不是您以为您正在测量的。实际上,您通常所测量的,不是您以为您正在测量的。对于那些没有包含什么实际的程序负荷,测试时间不够长的性能测试的结果,一定要非常当心。

posted @ 2006-08-24 17:36 Binary 阅读(222) | 评论 (0)编辑 收藏

Java 理论与实践: 用动态代理进行修饰

动态代理工具java.lang.reflect 包的一部分,在 JDK 1.3 版本中添加到 JDK,它允许程序创建 代理对象,代理对象能实现一个或多个已知接口,并用反射代替内置的虚方法分派,编程地分派对接口方法的调用。这个过程允许实现“截取”方法调用,重新路由它们或者动态地添加功能。本期文章中,Brian Goetz 介绍了几个用于动态代理的应用程序。请在本文伴随的 讨论论坛 上与作者和其他读者分享您对这篇文章的想法。(也可以单击文章顶部或底部的 讨论 访问讨论论坛。)

动态代理为实现许多常见设计模式(包括 Facade、Bridge、Interceptor、Decorator、Proxy(包括远程和虚拟代理)和 Adapter 模式)提供了替代的动态机制。虽然这些模式不使用动态代理,只用普通的类就能够实现,但是在许多情况下,动态代理方式更方便、更紧凑,可以清除许多手写或生成的类。

Proxy 模式

Proxy 模式中要创建“stub”或“surrogate”对象,它们的目的是接受请求并把请求转发到实际执行工作的其他对象。远程方法调用(RMI)利用 Proxy 模式,使得在其他 JVM 中执行的对象就像本地对象一样;企业 JavaBeans (EJB)利用 Proxy 模式添加远程调用、安全性和事务分界;而 JAX-RPC Web 服务则用 Proxy 模式让远程服务表现得像本地对象一样。在每一种情况中,潜在的远程对象的行为是由接口定义的,而接口本质上接受多种实现。调用者(在大多数情况下)不能区分出它们只是持有一个对 stub 而不是实际对象的引用,因为二者实现了相同的接口;stub 的工作是查找实际的对象、封送参数、把参数发送给实际对象、解除封送返回值、把返回值返回给调用者。代理可以用来提供远程控制(就像在 RMI、EJB 和 JAX-RPC 中那样),用安全性策略包装对象(EJB)、为昂贵的对象(EJB 实体 Bean)提供惰性装入,或者添加检测工具(例如日志记录)。

在 5.0 以前的 JDK 中,RMI stub(以及它对等的 skeleton)是在编译时由 RMI 编译器(rmic)生成的类,RMI 编译器是 JDK 工具集的一部分。对于每个远程接口,都会生成一个 stub(代理)类,它代表远程对象,还生成一个 skeleton 对象,它在远程 JVM 中做与 stub 相反的工作 —— 解除封送参数并调用实际的对象。类似地,用于 Web 服务的 JAX-RPC 工具也为远程 Web 服务生成代理类,从而使远程 Web 服务看起来就像本地对象一样。

不管 stub 类是以源代码还是以字节码生成的,代码生成仍然会向编译过程添加一些额外步骤,而且因为命名相似的类的泛滥,会带来意义模糊的可能性。另一方面,动态代理机制支持在编译时没有生成 stub 类的情况下,在运行时创建代理对象。在 JDK 5.0 及以后版本中,RMI 工具使用动态代理代替了生成的 stub,结果 RMI 变得更容易使用。许多 J2EE 容器也使用动态代理来实现 EJB。EJB 技术严重地依靠使用拦截(interception)来实现安全性和事务分界;动态代理为接口上调用的所有方法提供了集中的控制流程路径。





回页首


动态代理机制

动态代理机制的核心是 InvocationHandler 接口,如清单 1 所示。调用句柄的工作是代表动态代理实际执行所请求的方法调用。传递给调用句柄一个 Method 对象(从 java.lang.reflect 包),参数列表则传递给方法;在最简单的情况下,可能仅仅是调用反射性的方法 Method.invoke() 并返回结果。


清单 1. InvocationHandler 接口
												
																		
public interface InvocationHandler {
    Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}

												
										

每个代理都有一个与之关联的调用句柄,只要代理的方法被调用时就会调用该句柄。根据通用的设计原则:接口定义类型、类定义实现,代理对象可以实现一个或多个接口,但是不能实现类。因为代理类没有可以访问的名称,它们不能有构造函数,所以它们必须由工厂创建。清单 2 显示了动态代理的最简单的可能实现,它实现 Set 接口并把所有 Set 方法(以及所有 Object 方法)分派给封装的 Set 实例。


清单 2. 包装 Set 的简单的动态代理
												
																		
public class SetProxyFactory {

    public static Set getSetProxy(final Set s) {
        return (Set) Proxy.newProxyInstance
          (s.getClass().getClassLoader(),
                new Class[] { Set.class },
                new InvocationHandler() {
                    public Object invoke(Object proxy, Method method, 
                      Object[] args) throws Throwable {
                        return method.invoke(s, args);
                    }
                });
    }
}

												
										

SetProxyFactory 类包含一个静态工厂方法 getSetProxy(),它返回一个实现了 Set 的动态代理。代理对象实际实现 Set —— 调用者无法区分(除非通过反射)返回的对象是动态代理。SetProxyFactory 返回的代理只做一件事,把方法分派给传递给工厂方法的 Set 实例。虽然反射代码通常比较难读,但是这里的内容很少,跟上控制流程并不难 —— 只要某个方法在 Set 代理上被调用,它就被分派给调用句柄,调用句柄只是反射地调用底层包装的对象上的目标方法。当然,绝对什么都不做的代理可能有点傻,是不是呢?

什么都不做的适配器

对于像 SetProxyFactory 这样什么都不做的包装器来说,实际有个很好的应用 —— 可以用它安全地把对象引用的范围缩小到特定接口(或接口集)上,方式是,调用者不能提升引用的类型,使得可以更安全地把对象引用传递给不受信任的代码(例如插件或回调)。清单 3 包含一组类定义,实现了典型的回调场景。从中会看到动态代理可以更方便地替代通常用手工(或用 IDE 提供的代码生成向导)实现的 Adapter 模式。


清单 3. 典型的回调场景
												
																		
public interface ServiceCallback {
    public void doCallback();
}

public interface Service {
    public void serviceMethod(ServiceCallback callback);
}

public class ServiceConsumer implements ServiceCallback {
    private Service service;

    ...
    public void someMethod() {
        ...
        service.serviceMethod(this);
    }
}

												
										

ServiceConsumer 类实现了 ServiceCallback(这通常是支持回调的一个方便途径)并把 this 引用传递给 serviceMethod() 作为回调引用。这种方法的问题是没有机制可以阻止 Service 实现把 ServiceCallback 提升为 ServiceConsumer,并调用 ServiceConsumer 不希望 Service 调用的方法。有时对这个风险并不关心 —— 但有时却关心。如果关心,那么可以把回调对象作为内部类,或者编写一个什么都不做的适配器类(请参阅清单 4 中的 ServiceCallbackAdapter)并用 ServiceCallbackAdapter 包装 ServiceConsumerServiceCallbackAdapter 防止 ServiceServiceCallback 提升为 ServiceConsumer


清单 4. 用于安全地把对象限制在一个接口上以便不被恶意代码不能的适配器类
												
																		
public class ServiceCallbackAdapter implements ServiceCallback {
    private final ServiceCallback cb;

    public ServiceCallbackAdapter(ServiceCallback cb) {
        this.cb = cb;
    }

    public void doCallback() {
        cb.doCallback();
    }
}

												
										

编写 ServiceCallbackAdapter 这样的适配器类简单却乏味。必须为包装的接口中的每个方法编写重定向类。在 ServiceCallback 的示例中,只有一个需要实现的方法,但是某些接口,例如 Collections 或 JDBC 接口,则包含许多方法。现代的 IDE 提供了“Delegate Methods”向导,降低了编写适配器类的工作量,但是仍然必须为每个想要包装的接口编写一个适配器类,而且对于只包含生成的代码的类,也有一些让人不满意的地方。看起来应当有一种方式可以更紧凑地表示“什么也不做的限制适配器模式”。

通用适配器类

清单 2 中的 SetProxyFactory 类当然比用于 Set 的等价的适配器类更紧凑,但是它仍然只适用于一个接口:Set。但是通过使用泛型,可以容易地创建通用的代理工厂,由它为任何接口做同样的工作,如清单 5 所示。它几乎与 SetProxyFactory 相同,但是可以适用于任何接口。现在再也不用编写限制适配器类了!如果想创建代理对象安全地把对象限制在接口 T,只要调用 getProxy(T.class,object) 就可以了,不需要一堆适配器类的额外累赘。


清单 5. 通用的限制适配器工厂类
												
																		
public class GenericProxyFactory {

    public static<T> T getProxy(Class<T> intf, 
      final T obj) {
        return (T) 
          Proxy.newProxyInstance(obj.getClass().getClassLoader(),
                new Class[] { intf },
                new InvocationHandler() {
                    public Object invoke(Object proxy, Method method, 
                      Object[] args) throws Throwable {
                        return method.invoke(obj, args);
                    }
                });
    }
}

												
										





回页首


动态代理作为 Decorator

当然,动态代理工具能做的,远不仅仅是把对象类型限制在特定接口上。从 清单 2清单 5 中简单的限制适配器到 Decorator 模式,是一个小的飞跃,在 Decorator 模式中,代理用额外的功能(例如安全检测或日志记录)包装调用。清单 6 显示了一个日志 InvocationHandler,它在调用目标对象上的方法之外,还写入一条日志信息,显示被调用的方法、传递的参数,以及返回值。除了反射性的 invoke() 调用之外,这里的全部代码只是生成调试信息的一部分 —— 还不是太多。代理工厂方法的代码几乎与 GenericProxyFactory 相同,区别在于它使用的是 LoggingInvocationHandler 而不是匿名的调用句柄。


清单 6. 基于代理的 Decorator,为每个方法调用生成调试日志
												
																		
    private static class LoggingInvocationHandler<T> 
      implements InvocationHandler {
        final T underlying;

        public LoggingHandler(T underlying) {
            this.underlying = underlying;
        }

        public Object invoke(Object proxy, Method method, 
          Object[] args) throws Throwable {
            StringBuffer sb = new StringBuffer();
            sb.append(method.getName()); sb.append("(");
            for (int i=0; args != null && i<args.length; i++) {
                if (i != 0)
                    sb.append(", ");
                sb.append(args[i]);
            }
            sb.append(")");
            Object ret = method.invoke(underlying, args);
            if (ret != null) {
                sb.append(" -> "); sb.append(ret);
            }
            System.out.println(sb);
            return ret;
        }
    }

												
										

如果用日志代理包装 HashSet,并执行下面这个简单的测试程序:

												
														    Set s = newLoggingProxy(Set.class, new HashSet());
    s.add("three");
    if (!s.contains("four"))
        s.add("four");
    System.out.println(s);

												
										

会得到以下输出:

												
														  add(three) -> true
  contains(four) -> false
  add(four) -> true
  toString() -> [four, three]
  [four, three]

												
										

这种方式是给对象添加调试包装器的一种好的而且容易的方式。它当然比生成代理类并手工创建大量 println() 语句容易得多(也更通用)。我进一步改进了这一方法;不必无条件地生成调试输出,相反,代理可以查询动态配置存储(从配置文件初始化,可以由 JMX MBean 动态修改),确定是否需要生成调试语句,甚至可能在逐个类或逐个实例的基础上进行。

在这一点上,我认为读者中的 AOP 爱好者们几乎要跳出来说“这正是 AOP 擅长的啊!”是的,但是解决问题的方法不止一种 —— 仅仅因为某项技术能解决某个问题,并不意味着它就是最好的解决方案。在任何情况下,动态代理方式都有完全在“纯 Java”范围内工作的优势,不是每个公司都用(或应当用) AOP 的。

动态代理作为适配器

代理也可以用作真正的适配器,提供了对象的一个视图,导出与底层对象实现的接口不同的接口。调用句柄不需要把每个方法调用都分派给相同的底层对象;它可以检查名称,并把不同的方法分派给不同的对象。例如,假设有一组表示持久实体(PersonCompanyPurchaseOrder) 的 JavaBean 接口,指定了属性的 getter 和 setter,而且正在编写一个持久层,把数据库记录映射到实现这些接口的对象上。现在不用为每个接口编写或生成类,可以只用一个 JavaBean 风格的通用代理类,把属性保存在 Map 中。

清单 7 显示的动态代理检查被调用方法的名称,并通过查询或修改属性图直接实现 getter 和 setter 方法。现在,这一个代理类就能实现多个 JavaBean 风格接口的对象。


清单 7. 用于把 getter 和 setter 分派给 Map 的动态代理类
												
																		
public class JavaBeanProxyFactory {
    private static class JavaBeanProxy implements InvocationHandler {
        Map<String, Object> properties = new HashMap<String, 
          Object>();

        public JavaBeanProxy(Map<String, Object> properties) {
            this.properties.putAll(properties);
        }

        public Object invoke(Object proxy, Method method, 
          Object[] args) 
          throws Throwable {
            String meth = method.getName();
            if (meth.startsWith("get")) {
                String prop = meth.substring(3);
                Object o = properties.get(prop);
                if (o != null && !method.getReturnType().isInstance(o))
                    throw new ClassCastException(o.getClass().getName() + 
                      " is not a " + method.getReturnType().getName());
                return o;
            }
            else if (meth.startsWith("set")) {
                // Dispatch setters similarly
            }
            else if (meth.startsWith("is")) {
                // Alternate version of get for boolean properties
            }
            else {
                // Can dispatch non get/set/is methods as desired
            }
        }
    }

    public static<T> T getProxy(Class<T> intf,
      Map<String, Object> values) {
        return (T) Proxy.newProxyInstance
          (JavaBeanProxyFactory.class.getClassLoader(),
                new Class[] { intf }, new JavaBeanProxy(values));
    }
}

												
										

虽然因为反射在 Object 上工作会有潜在的类型安全性上的损失,但是,JavaBeanProxyFactory 中的 getter 处理会进行一些必要的额外的类型检测,就像我在这里用 isInstance() 对 getter 进行的检测一样。





回页首


性能成本

正如已经看到的,动态代理拥有简化大量代码的潜力 —— 不仅能替代许多生成的代码,而且一个代理类还能代替多个手写的类或生成的代码。什么是成本呢? 因为反射地分派方法而不是采用内置的虚方法分派,可能有一些性能上的成本。在早期的 JDK 中,反射的性能很差(就像早期 JDK 中几乎其他每件事的性能一样),但是在近 10 年,反射已经变得快多了。

不必进入基准测试构造的主题,我编写了一个简单的、不太科学的测试程序,它循环地把数据填充到 Set,随机地对 Set进行插入、查询和删除元素。我用三个 Set 实现运行它:一个未经修饰的 HashSet,一个手写的、只是把所有方法转发到底层的 HashSetSet 适配器,还有一个基于代理的、也只是把所有方法转发到底层 HashSetSet 适配器。每次循环迭代都生成若干随机数,并执行一个或多个 Set 操作。手写的适配器比起原始的 HashSet 只产生很少百分比的性能负荷(大概是因为 JVM 级有效的内联缓冲和硬件级的分支预测);代理适配器则明显比原始 HashSet 慢,但是开销要少于两个量级。

我从这个试验得出的结论是:对于大多数情况,代理方式即使对轻量级方法也执行得足够好,而随着被代理的操作变得越来越重量级(例如远程方法调用,或者使用序列化、执行 IO 或者从数据库检索数据的方法),代理开销就会有效地接近于 0。当然也存在一些代理方式的性能开销无法接受的情况,但是这些通常只是少数情况。

posted @ 2006-08-24 17:35 Binary 阅读(193) | 评论 (0)编辑 收藏

Java 理论与实践: 伪 typedef 反模式

将泛型添加到 Java™ 语言中增加了类型系统的复杂性,提高了许多变量和方法声明的冗长程度。因为没有提供 “typedef” 工具来定义类型的简短名称,所以有些开发人员转而把扩展当作 “穷人的 typedef”,但是收到的决不是好的结果。在这个月的 Java 理论与实践 中,Java 专家 Brian Goetz 解释了这个 “反模式” 的限制。

对于 Java 5.0 中新增的泛型工具,一个常见的抱怨就是,它使代码变得太冗长。原来用一行就够的变量声明不再存在了,与声明参数化类型有关的重复非常讨厌,特别是还没有良好地支持自动补足的 IDE。例如,如果想声明一个 Map,它的键是 Socket,值是 Future<String>,那么老方法就是:

												
														Map socketOwner = new HashMap();

												
										

比新方法紧凑得多:
Map<Socket, Future<String>> socketOwner 
  = new HashMap<Socket, Future<String>>();  

当然,新方法内置了更多类型信息,减少了编程错误,提高了程序的可读性,但是确实带来了更多声明变量和方法签名方面的前期工作。类型参数在声明和初始化中的重复看起来尤其没有必要;SocketFuture<String> 需要输入两次,这迫使我们违犯了 “DRY” 原则(不要重复自己)。

合成类似于 typedef 的东西

添加泛型给类型系统增加了一些复杂性。在 Java 5.0 之前,“type” 和 “class” 几乎是同义的,而参数化类型,特别是那些绑定的通配类型,使子类型和子类的概念有了显著区别。类型 ArrayList<?>ArrayList<? extends Number>ArrayList<Integer> 是不同的类型,虽然它们是由同一个类 ArrayList 实现的。这些类型构成了一个层次结构;ArrayList<?>ArrayList<? extends Number> 的超类型,而 ArrayList<? extends Number>ArrayList<Integer> 的超类型。

对于原来的简单类型系统,像 C 的 typedef 这样的特性没有意义。但是对于更复杂的类型系统,typedef 工具可能会提供一些好处。不知是好还是坏,总之在泛型加入的时候,typedef 没有加入 Java 语言。

有些人用作 “穷人的 typedef” 的一个(坏的)做法是一个小小的扩展:创建一个类,扩展泛型类型,但是不添加功能,例如 SocketUserMap 类型,如清单 1 所示:


清单 1. 伪 typedef 反模式 —— 不要这么做
public class SocketUserMap extends HashMap<Socket<Future<String>> { }
SocketUserMap socketOwner = new SocketUserMap();

我将这个技巧称为伪 typedef 反模式,它实现了将 socketOwner 定义简化为一行的这一(有问题的)目标,但是有些副作用,最终成为重用和维护的障碍。(对于有明确的构造函数而不是无参构造函数的类来说,派生类也需要声明每个构造函数,因为构造函数没有被继承。)





回页首


伪类型的问题

在 C 中,用 typedef 定义一个新类型更像是宏,而不是类型声明。定义等价类型的 typedef,可以与原始类型自由地互换。清单 2 显示了一个定义回调函数的示例,其中在签名中使用了一个 typedef,但是调用者提供给回调的是一个等价类型,而编译器和运行时都可以接受它:


清单 2. C 语言的 typedef 示例
// Define a type called "callback" that is a function pointer
typedef void (*Callback)(int);

void doSomething(Callback callback) { }

// This function conforms to the type defined by Callback
void callbackFunction(int arg) { }

// So a caller can pass the address of callbackFunction to doSomething
void useCallback() {
  doSomething(&callbackFunction); 
}

扩展不是类型定义

用 Java 语言编写的试图使用伪 typedef 的等价程序就会出现麻烦。清单 3 的 StringListUserList 类型都扩展了一个公共超类,但是它们不是等价的类型。这意味着任何想调用 lookupAll 的代码都必须传递一个 StringList,而不能是 List<String>UserList


清单 3. 伪类型如何把客户限定在只能使用伪类型
class StringList extends ArrayList<String> { }
class UserList extends ArrayList<String> { }
...
class SomeClass {
    public void validateUsers(UserList users) { ... }
    public UserList lookupAll(StringList names) { ... }
}

这个限制要比初看上去严格得多。在小程序中,可能不会有太大差异,但是当程序变大的时候,使用伪类型的需求就会不断地造成问题。如果变量类型是 StringList,就不能给它分配普通的 List<String>,因为 List<String>StringList 的超类型,所以不是 StringList。就像不能把 Object 分配给类型为 String 的变量一样,也不能把 List<String> 分配给类型为 StringList 的变量(但是,可以反过来,例如,可以把 StringList 分配给类型为 List<String> 的变量,因为 List<String>StringList 的超类型。)

同样的情况也适用于方法的参数;如果一个方法参数是 StringList 类型,那么就不能把普通的 List<String> 传递给它。这意味着,如果不要求这个方法的每次使用都使用伪类型,那么根本不能用伪类型作为方法参数,而这在实践当中就意味着在库 API 中根本就不能使用伪类型。而且大多数库 API 都源自本来没想成为库代码的那些代码,所以 “这个代码只是给我自己的,没有其他人会用它” 可不是个好借口(只要您的代码有一点儿用处,别人就有可能会使用它;如果您的代码臭得很,那您可能是对的)。

伪类型会传染

这种 “病毒” 性质是让 C 代码的重用有困难的因素之一。差不多每个 C 包都有头文件,定义工具宏和类型,像 int32booleantruefalse,诸如此类。如果想在一个应用程序内使用几个包,而它们对于这些公共条目没有使用相同的定义,那么即使要编译一个只包含所有头文件的空程序,之前也要在 “头文件地狱” 问题上花好长时间。如果编写的 C 应用程序要使用许多来自不同作者的不同的包,那么几乎肯定要涉及一些这类痛苦。另一方面,对于 Java 应用程序来说,在没有这类痛苦的情况下使用许多甚至更多的包,是非常常见的事。如果包要在它们的 API 中使用伪类型,那么我们可能就要重新经历早已留在痛苦回忆中的问题。

作为示例,假设有两个不同的包,每个包都用伪类型反模式定义了 StringList,如清单 4 所示,而且每个包都定义了操作 StringList 的工具方法。两个包都定义了同样的标识符,这一事实已经是不方便的一个小源头了;客户程序必须选择导入一个定义,而另一个定义则要使用完全限定的名称。但是更大的问题是现在这些包的客户无法创建既能传递给 sortList 又能传递给 reverseList 的对象,因为两个不同的 StringList 类型是不同的类型,彼此互不兼容。客户现在必须在使用一个包还是使用另一个包之间进行选择,否则他们就必须做许多工作,在不同类型的 StringList 之间进行转换。对包的作者来说以为方便的东西,成为在所有地方使用这个包的突出障碍,除非在最受限的环境中。


清单 4. 伪类型的使用如何妨碍重用
package a;

class StringList extends ArrayList<String> { }
class ListUtilities {
    public static void sortList(StringList list) { }
}

package b;

class StringList extends ArrayList<String> { }
class SomeOtherUtilityClass {
    public static void reverseList(StringList list) { }
}
 
...

class Client {
    public void someMethod() {
        StringList list = ...;
        // Can't do this
        ListUtilities.sortList(list);
        SomeOtherUtilityClass.reverseList(list);
    }
}

伪类型通常太具体

伪类型反模式的进一步问题是,它会丧失使用接口定义变量类型和方法参数的好处。虽然可以把 StringList 定义成扩展 List<String> 的接口,再定义一个具体类型 StringArrayList 来扩展 ArrayList<String> 并实现 StringList,但多数伪 typedef 反模式的用户通常达不到这种水平,因为这项技术的目的主要是为了简化和缩短类型的名称。但结果是,API 的用处减少了并变得更脆弱,因为它们使用 ArrayList 这样的具体类型,而不是 List 这样的抽象类型。

更安全的技巧

一个更安全的减少声明泛型集合所需打字量的技巧是使用类型推导(type inference)。编译器可以非常聪明地使用程序中内嵌的类型信息来分配类型参数。如果定义了下面这样一个工具方法:

public static <K,V> Map<K,V> newHashMap() {
    return new HashMap<K,V>(); 
}

那么可以安全地用它来避免录入两次参数:
Map<Socket, Future<String>> socketOwner = Util.newHashMap();

这种方法之所以能够奏效,在于编译器可以根据泛型方法 newHashMap() 被调用的位置推导出 KV 的值。



回页首


结束语

伪 typedef 反模式的动机很简单 —— 开发人员想要一种方法可以定义更紧凑的类型标识符,特别是在泛型把类型标识符变得更冗长的时候。问题在于这个做法在使用它的代码和代码的客户之间形成了紧密的耦合,从而妨碍了重用。不喜欢泛型类型标识符的冗长是可以理解的,但这不是解决问题的办法。

posted @ 2006-08-24 17:34 Binary 阅读(146) | 评论 (0)编辑 收藏

仅列出标题
共8页: 上一页 1 2 3 4 5 6 7 8 下一页