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

Java多线程安全之对象的发布和溢出、线程封闭详解

2017-08-29 10:42 671 查看

对象的发布与逸出

“发布(Publish)“一个对象是指使对象能够在当前作用域之外的代码中使用。可以通过 公有静态变量,非私有方法,构造方法内隐含引用 三种方式。如果对象构造完成之前就发布该对象,就会破坏线程安全性。当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)。下面我们首先来看看一个对象是如何逸出的。

发布对象最简单的方法便是将对象的引用保存到一个共有的静态变量中,以便任何类和线程都能看见对象,如下面代码。

public static Set<string> mySet;

 

    public void initialize() {

        mySet = new HashSet<string>();

    }</string></string>

当发布某个对象时,可能会间接地发布其他对象。如果将一个 String 对象添加到集合 mySet 中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个 String 对象的引用。同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。如下面代码 UnsafeStates 发布了本应为私有的状态数组。

class UnsafeState {

    private String[] states = new String[] { "AK", "AL" };

 

    public String[] getStates() {

        return states;

    }

}

当发布某个对象时,可能会间接地发布其他对象。如果将一个 String 对象添加到集合 mySet 中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个 String 对象的引用。同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。如下面代码 UnsafeStates 发布了本应为私有的状态数组。

class UnsafeState {

    private String[] states = new String[] { "AK", "AL" };

 

    public String[] getStates() {

        return states;

    }

}

如果按照上诉方法来发布 states,就会出问题,因为任何调用者都能修改这个数组的内容。数组 states 已经溢出了它所在的作用域了,因为这个本应是私有的变量已经被发布了。当私有变量被发布出去之后,这个类就无法知道”外部方法“会进行何种操作。无论其他的线程会对义发布的引用执行何种操作,其实都不重要,因为误用该引用的风险始终存在。当hadoop某个对象逸出后,你必须假设有某个类或者线程可能会误用该对象。这正是需要使用封装的的最主要的原因:封装能使得对正确性分析变得可能,并使减少无意中破坏设计约束条件的行为。

最后一种发布对象或其内部状态的机制就是发布一个内部的类实例,如下面类 ThisEscape 所示。当 ThisEscape 发布 EventListener 时,也隐含的发布了 ThisEscape 实例本身,因为在这个内部类的实例中包含了对 ThisEscape 实例的隐含对象。

public class ThisEscape {

    public ThisEscape(EventSource source) {

        source.registerListener(new EventListener() {

            public void onEvent(Event e) {

                doSomething(e);

            }

        });

    }

}

安全的构造过程

ThisEscape 中给出了逸出的一个特殊示例,即 this 引用在构造函数中逸出。内部 EventListener 实例发布时,在外部封装的 ThisEscape 实例也逸出了。当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态。因此,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果 this 引用在构造过程中逸出,那么这种对象就被认为是不正确构造。

在构造过程中使 this 引用逸出的一个常见错误是,在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论是显示创建(通过将它传给构造函数)还是隐式创建(由于 Thread 或 Runnable 是该对象的一个内部类), this 引用都会被新创建的线程共享。在对象尚未被创建完成之前,新的线程就可以看见它。在构造函数中创建线程并没有错误,但最好不要立即启动它,而是通过一个 start 或 initialize 方法来启动。在构造函数中调用一个可改写的实例方法时,同样会导致 this 引用在构造过程中逸出。

如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法(Factory Method),从而避免不正确的构造过程,如下面的 SafeListener 。

public class SafeListener{

    private final EventListener listener;

    private SafeListener(){

        listener = new EventListener(){

            public void onEvent(Event e){

                doSomething(e);

            }

        };

    }

    public static SafeListener newInstance(EventSource source){

        SafeListener safe = new SafeListener();

        source.registerListener(safe.listener);

        return safe;

    }

}

线程封闭

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭(Thread Confinement),它是实现线程安全型的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。

线程封闭的一种常见的应用是 JDBC 的 Connection 对象。JDBC 规范并不要求 Connection 对象必须是线程安全的。在典型的服务器应用程序中,线程从连接池中获得一个 Connection 对象,并且用该对象来处理请求,使用完之后再将对象返还给连接池。由于大多数请求(例如 Servlet 请求或 EJB 调用等)都是由单个线程采用同步的方式来处理,并且在 Connection 对象返回之前,连接池都不会将它分配给其它线程,因此,这种连接管理模式在处理请求时隐含的将 Connection 对象封闭在线程中。

Java 语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和 ThreadLocal 类,即便如此,程序员仍然需要确保封闭在线程中的对象不会从线程中逸出。

Ad-hoc 线程封闭

Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。事实上,对线程封闭对象(例如,GUI应用程序中的可视化组件或数据模型等)的引用通常保存在公有变量中。

当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。在某些情况下,单线程子系统提供的简便性要胜过Ad-hoc线程封闭技术的脆弱性。

在volatile变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共享的volatile变量上执行“读取-修改-写入”的操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且volatile变量的可见性保证还确保了其他线程能看到最新的值。

由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该使用更强的线程封闭技术(例如,栈封闭或ThreadLocal类)。

栈封闭

栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。正如封装能使代码更容易维持不变性条件那样,同步变量也能使对象更易于封闭在线程中。

对于基本类型的局部变量,例如下面 loadTheArk 方法的 numPairs ,无论如何都不会破坏栈封闭性。由于任何方法都不发获得对基本类型的引用,因此 Java 语言的这种语义确保了基本类型的局部变量始终封闭在线程内。

public int loadTheArk(Collection candidates) {

    SortedSet animals;

    int numPairs = 0;

    Animal candidate = null;

 

    // animals被封闭在方法中,不要使它们逸出!

    animals = new TreeSet(new SpeciesGenderComparator());

    animals.addAll(candidates);

    for (Animal a : animals) {

        if (candidate == null || !candidate.isPotentialMate(a))

            candidate = a;

        else {

            ark.load(new AnimalPair(candidate, a));

            ++numPairs;

            candidate = null;

        }

    }

    return numPairs;

}

在维持对象引用的栈封闭性时,程序员需要多做一些工作以确保被引用的对象不会逸出。在loadTheArk中实例化一个TreeSet对象,并将指向该对象的一个引用保存到animals中。此时,只有一个引用指向集合animals,这个引用被封闭在局部变量中,因此也被封闭在执行线程中。然而,如果发布了对集合animals(或者该对象中的任何内部数据)的引用,那么封闭性将被破坏,并导致对象animals的逸出。

如果在线程内部(Within-Thread)上下文中使用非线程安全的对象,那么该对象仍然是线程安全的。然而,要小心的是,只有编写代码的开发人员才知道哪些对象需要被封闭到执行线程中,以及被封闭的对象是否是线程安全的。如果没有明确地说明这些需求,那么后续的维护人员很容易错误地使对象逸出。

ThreadLocal 类

维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。由于JDBC的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接。

当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。例如,在Java 5.0之前,Integer.toString()方法使用ThreadLocal对象来保存一个12字节大小的缓冲区,用于对结果进行格式化,而不是使用共享的静态缓冲区(这需要使用锁机制)或者在每次调用时都分配一个新的缓冲区。

当某个线程初次调用ThreadLocal.get方法时,就会调用initialValue来获取初始值。从概念上看,你可以将ThreadLocal视为包含了Map< Thread,T>对象,其中保存了特定于该线程的值,但ThreadLocal的实现并非如此。这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。

假设你需要将一个单线程应用程序移植到多线程环境中,通过将共享的全局变量转换为ThreadLocal对象(如果全局变量的语义允许),可以维持线程安全性。然而,如果将应用程序范围内的缓存转换为线程局部的缓存,就不会有太大作用。

在实现应用程序框架时大量使用了ThreadLocal。例如,在EJB调用期间,J2EE容器需要将一个事务上下文(Transaction Context)与某个执行中的线程关联起来。通过将事务上下文保存在静态的ThreadLocal对象中,可以很容易地实现这个功能:当框架代码需要判断当前运行的是哪一个事务时,只需从这个ThreadLocal对象中读取事务上下文。这种机制很方便,因为它避免了在调用每个方法时都要传递执行上下文信息,然而这也将使用该机制的代码与框架耦合在一起。

开发人员经常滥用ThreadLocal,例如将所有全局变量都作为ThreadLocal对象,或者作为一种“隐藏”方法参数的手段。ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: