首页 | 新闻 | 新品 | 文库 | 方案 | 视频 | 下载 | 商城 | 开发板 | 数据中心 | 座谈新版 | 培训 | 工具 | 博客 | 论坛 | 百科 | GEC | 活动 | 主题月 | 电子展
返回列表 回复 发帖

Java 理论和实践 安全构造技术(1)

Java 理论和实践 安全构造技术(1)

测试和调试多线程程序是极其困难的,因为并发性方面的危险常常不是以一致的方式显现出来,甚至有时未必会显现这种危险性。就线程问题的本质而言,大多数这些问题是无法预料的,甚至在某些平台上(如单处理器系统),或者低于一定的负载,问题可能根本就不出现。由于测试多线程程序的正确性是如此困难,以及查找错误是如此费时,因此从一开始开发应用程序就要在心中牢记线程的安全性,这一点就显得尤为重要。在本文中,我们将研究一个特殊的线程安全方面的问题 ― 在构造过程中,允许         this 引用逃脱(我们称之为        逃脱的引用问题) ― 该问题引起了一些未曾期望的结果。然后,为了编写出线程安全的构造函数,我们给出一些准则。      
遵循“安全构造”技术分析程序来找出线程安全的违例是非常困难的,这需要专门的经验。幸运的是(也许会感到吃惊),从一开始创建线程安全的类并不是那样的困难,尽管这需要一种其它专门的技巧:规程。大多数并发性错误是来自程序员以方便、改善性能或只是一时的懒惰为名企图违规而造成的。如许多其它并发性问题一样,在编写构造函数时,遵循一些简单的规则就可以避免这个逃脱的引用问题。
危险的争用状态大多数并发性危险归根结底是由某类        数据争用引起的。在多个线程或进程正在读取和写入一个共享数据项时,会发生数据争用或进入        争用状态,最终结果取决于这些线程的调度次序。清单1 给出了一个简单的数据争用的示例,其中程序可以打印 0 或者 1,这取决于对线程的调度。      
清单 1. 简单的数据争用
1
2
3
4
5
6
7
8
9
10
11
12
13
public class DataRace {
  static int a = 0;
  public static void main() {
    new MyThread().start();
    a = 1;
  }
  public static class MyThread extends Thread {
    public void run() {
    public void run() {
      System.out.println(a);
    }
  }
}




可以立即调度第二个线程,打印         a 的初始值 0。另一种情形,第二个线程可能        立即运行,则导致打印值1。这个程序的输出取决于您正在使用的 JDK、底层操作系统的调度程序或者随机计时构件。重复运行该程序,会得到不同的结果。      
可见性危险在清单 1 中,除了这个明显的争用 ― 第二个线程是在第一个线程将         a 置为 1之前还是之后开始执行 ― 之外,实际上还有另一种数据争用。第二种争用是一种可见性方面的争用:两个线程没有使用同步,而同步能保证线程之间数据更改的可见性。因为没有同步,如果在第一个线程对         a 赋值完成之后,运行第二个线程,则第二个线程可能或        可能立即看见第一个线程所做的更改。第二个线程可能看到         a 仍然为 0,即使第一个线程已经将值 1 赋给了a。这种第二类的数据争用(在没有正确同步的情况下,两个线程正在访问同一变量)是一种复杂的问题,但幸运的是,每当读取一个其它线程可能已写过的变量,或者写一个接下来可能会被其它线程读取的变量时,使用同步就可以避免这类数据争用。在这里,我们不想进一步探讨这类数据争用,关于这类复杂问题,您可以参阅侧栏 ,也可以参阅 以获取更多有关这类复杂问题的详细信息。      
用 Java Memory Model 同步在 Java 编程中的           synchronized 关键字是强制执行          互斥:它确保在给定的时间内只有一个线程执行给定的代码块。但在具有弱内存(weak memory)模型(即,没有必要提供高速缓存一致性的平台)的多处理器系统上,同步 ― 或者说没有同步 ― 还有其它一些微妙的结果。同步确保其它线程以一种可预知的方式          看见某个线程所做的更改。在某些体系结构上,由于没有同步机制,不同线程实际执行的内存操作次序与它们所看见的执行次序不一致。这种现象令人感到迷惑,但很正常 ― 这关键是为了寻求在这些平台上的高性能。如果只要您遵循这样的规则 ― 每当您读取一个可能已被其它线程写过的变量时,或者写一个接下来可能会被其它线程读取的变量时,就执行同步 ― 那么您不会有任何问题。请参阅 以了解更多信息。        

在构造期间,不要公布“this”引用一种可以将数据争用引入类中的错误是,在构造函数完成之前,使         this 引用暴露给另一个线程。有时这个引用是显式的,(譬如,直接将         this 存储在静态字段或集合),但还有一些时候它可以是隐式的(譬如,当将一个引用公布给构造函数中的非静态内部类的实例时)。构造函数不是一般的方法― 它们有特殊的用于初始化安全的语义。在构造函数完成之后,可以认为对象是处于一种可预测和一致的状态,将引用公布给一个还未完成构造的对象是危险的。清单2 显示了将这类争用条件引入构造函数的示例。这个示例看上去可能没有危害性,但它可以引发严重的并发性问题。      
清单 2. 可能发生的数据争用
1
2
3
4
5
6
7
8
9
10
11
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 类似乎没有危害性。构造函数最后完成的工作是注册侦听器,这会将引用公布给新对象,这时其它线程可能会看到这个引用。但即使不考虑所有 Java 内存模型(JMM)问题(譬如,多个线程所看见同一数据的不一致性以及内存访问重排序的不同),该代码仍然有将还未完成构造的         EventListener 对象暴露给其它线程的危险。考虑清单 3 中这种情况,当创建         EventListener 的一个子类时,会发生什么:      
清单 3. 创建 EventListener 的一个子类时,问题产生了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class RecordingEventListener extends EventListener {
  private final ArrayList list;
  public RecordingEventListener(EventSource eventSource) {
    super(eventSource);
    list = Collections.synchronizedList(new ArrayList());
  }
  public onEvent(Event e) {
    list.add(e);
    super.onEvent(e);
  }
  public Event[] getEvents() {
    return (Event[]) list.toArray(new Event[0]);
  }
}




由于 Java 语言规范要求对         super() 的调用应该是子类构造函数中的第一条语句,所以在完成子类字段的初始化之前,还未构造完的事件侦听器已经对事件源进行了注册。现在,         list 字段存在数据争用。如果事件侦听器决定从注册调用内发送一个事件,或者我们很不幸,在这不恰当的时刻,一个事件到来了,则会调用         RecordingEventListener.onEvent() ,而这时         list 仍然是         null 值,结果会抛出         NullPointerException 异常。就象         onEvent() 这样的类方法一样,在编码时,应该避免使用还未初始化完的 final 字段。      
中的问题在于,         EventListener 在构造完成之前会向正在构造的对象公布一个引用。虽然看上去        几乎已经完整地构造了对象,所以将         this 传递给事件源好象是安全的,但这种表像是具有欺骗性的。公布来自构造函数内的         this 引用(如清单 2 所示)就象放置了一颗随时会爆炸的定时炸弹。
返回列表