`
足至迹留
  • 浏览: 495740 次
  • 性别: Icon_minigender_1
  • 来自: OnePiece
社区版块
存档分类
最新评论

<进阶-2> 对象的共享和发布

阅读更多
[...续上文]
2. 对象的共享和发布
我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是认为关键字synchronized只能用于实现原子性或确定临界区,其实synchronized还有另一个重要方面:内存可见性。这一点前面讨论原子性的时候提到过,synchronized能同时保证原子性和可见性。

2.1 可见性

前面已经讨论过jvm的内存模型与可见性的关系,这里再进一步讨论。可见性是一种复杂的属性,因为可见性中的错误会违背我们的直觉。单线程环境中如果向某个变量先写入值在没有其他写入操作的情况下读取这个变量,那么总能得到相同的值。然而,当读取操作和写操作在不同的线程中执行时,情况却并非如此。通常,我们无法确保执行读取操作的线程能及时看到其他线程写入的值。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
举例说明多个线程在没有同步的情况下共享数据时出现的错误:
public class NoVisibility
{
    private static boolean ready;
    private static int number;

	private static class ReaderThread extends Thread
	{
	    public void run() 
	    {
	        while(!ready)
			{
	        	Thread.yield();
	        	
			}
	        
	        System.out.println(number);
	    }
	}
	
	public static void main(String[] args) throws InterruptedException
	{
		new ReaderThread().start();
		
		number = 42;
		ready = true;
	}
}


(1)NoVisibility可能会持续循环下去,因为读线程可能永远看不到ready值。(2)一种更奇怪的现象是NoVisibility可能会输出0,因为读线程可能看到了写入ready的值却没有看到之后写入number的值,这种现象被称为“重排序(Reordering)”。只要在某个线程中无法检测到重排序情况,那么就无法确保线程中的操作按照程序指定的顺序执行。当主线程首先写入number,然后再没有同步的情况下写入ready,那么读线程看到的顺序可能与写入的顺序完全相反。
在没有同步的情况下,编译器,处理器以及运行时都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断几乎无法得到正确的结论。
有一种简单的方法能避免这些复杂的问题,只要有数据在多个线程中共享,就使用正确的同步。当然,也有其他更好的方法。

2.1.1 失效数据
NoVisibility展示了缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当读线程查看ready变量时,可能会得到一个已经失效的值。更糟糕的是,失效值可能不会同时出现:一个线程获得某个变量的最新值而获得另一个变量的失效值。
失效值可能会导致一些严重的安全问题或活跃性问题。如果对象的引用失效还会有更复杂的错误。如意料之外的异常,被破坏的数据结构,不精确的计算以及无限循环等。

下面示例MutableInteger不是线程安全的,因为get和set都是在没有同步的情况下访问value,如果某个线程调用了set,那么另一个正在调用get的线程可能会看到更新后的value,也可能看不到。
public class MutableInteger
{
    private int value;

    public int get()
    {
        return value;
    }

    public void set(int value)
    {
        this.value = value;
    }
}

如果get和set前面都加上synchronized同步则变成线程安全的类。

2.1.2 非原子的64位操作
当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性。最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(double和long)。
Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,jvm允许将64位的读操作和写操作分解为两个32位的操作,这样就可能会读到某个值的高32位和另一个值的低32位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非使用关键字volatile来声明他们,或用锁来保护。

2.1.3 加锁和可见性
在访问某个共享且可变(又一次看到这句话了)的变量时要求所有线程在同一个锁上同步就是为了确保某个线程写入该变量的值对其他线程时可见的,因为其他线程要等到拥有锁的线程操作完全完成之后才能操作,完全能看到上一步操作的结果。
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。

2.1.4 volatile变量
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。Volatile变量不会被缓存在寄存器或对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
然而,访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
但volatile的使用有限制,前面已经强调过它的使用场景,这里再次强调,当且仅当满足以下所有条件时,才应该使用volatile变量:
1) 对变量的写入操作不依赖变量的当前值,或者能确保只有单个线程更新变量的值。
2) 该变量不会与其他状态变量一起纳入不变性条件中。
3) 在访问变量时不需要加锁。


而且仅当volatile变量能简化代码的实现以及对同步策略的验证时才应该使用它们。加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

推荐阅读:http://www.blogjava.net/qileilove/archive/2013/09/30/404636.html


2.2 发布与逸出
“发布(publish)”一个对象的意思是使对象能够在当前作用域之外的代码中使用。例如,(1)将一个指向该对象的引用保存到其他代码可以访问的地方,(2)或者在某一个非私有的方法中返回该引用,(3)或者将引用传递到其他类的方法中。
在许多情况下,我们要确保对象及其内部状态不被发布。而在某些情况下,我们又需要发布某个对象,但如果在发布时要确保线程安全性则可能需要同步。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。例如,如果在对象构造完成之前就发布该对象,就会破坏线程安全性。当某个不应该发布的对象被发布时,这种情况就被称为逸出(escape)。

下面逐一说明上面提到的发布对象方式。
(1)发布对象的最简单方法是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象,如程序清单3-5所示。在initialize方法中实例化一个新的HashSet对象,并将对象的引用保存到knownSecrets中以发布该对象。

public static Set<Secret> knownSecrets;
public void initialize() {
    knownSecrets = new HashSet<Secret>();
}

当发布某个对象时,可能会间接地发布其他对象。如果将一个Secret对象添加到集合knownSecrets中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个新Secret对象的引用。

(2)同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。下面程序中 的UnsafeStates发布了本应为私有的状态数组。
class UnsafeStates {
    private String[] states = new String[] {
        "AK", "AL" ...
    };
    public String[] getStates() { return states; }
}

如果按照上述方式来发布states,就会出现问题,因为任何调用者都能修改这个数组的内容。在这个示例中,数组states已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了。
当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。
无论其他的线程会对已发布的引用执行何种操作,其实都不重要,因为误用该引用的风险始终存在。当某个对象逸出后,你必须假设有某个类或线程可能会误用该对象。这正是需要使用封装的最主要原因:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。
(3)最后一种发布对象或其内部状态的机制就是发布一个内部的类实例,如下面程序中的ThisEscape所示。当ThisEscape发布 EventListener时,也隐含地发布了ThisEscape实例本身,因为在这个内部类的实例中包含了对ThisEscape实例的隐含引用(内部类可以访问外部类的所有属性)。
隐式地使this引用逸出(不要这么做,下面会有这个示例的安全做法)

public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(
            new EventListener() {
                public void onEvent(Event e) {
                    doSomething(e);
                }
            });
    }
}


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

不要在构造过程中使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;
    }
}


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

在Swing中大量使用了线程封闭技术。Swing的可视化组件和数据模型对象都不是线程安全的,Swing通过将它们封闭到Swing的事件分发 线程中来实现线程安全性。要想正确地使用Swing,那么在除了事件线程之外的其他线程中就不能访问这些对象(为了进一步简化对Swing的使 用,Swing还提供了invokeLater机制,用于将一个Runnable实例调度到事件线程中执行)。Swing应用程序的许多并发错误都是由于 错误地在另一个线程中使用了这些被封闭的对象。
线程封闭技术的另一种常见应用是JDBC(Java Database Connectivity)的Connection对象。JDBC规范并不要求Connection对象必须是线程安全的。在典型的服务器应用程序中,线 程从连接池中获得一个Connection对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。由于大多数请求(例如Servlet请求或 EJB调用等)都是由单个线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式 在处理请求时隐含地将Connection对象封闭在线程中。
在Java语言中并没有强制规定某个变量必须由锁来保护,同样在Java语言中也无法强制将对象封闭在某个线程中。线程封闭是在程序设计中的一个考 虑因素,必须在程序中实现。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类,但即便如此,程序员仍 然需要负责确保封闭在线程中的对象不会从线程中逸出。

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

当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。在某些情况下,单线程子系统提供的简便性要胜过Ad-hoc线程封闭技术的脆弱性。
在volatile变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共 享的volatile变量上执行“读取-修改-写入”的操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且volatile 变量的可见性保证还确保了其他线程能看到最新的值。
由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该使用更强的线程封闭技术(例如,栈封闭或ThreadLocal类)。

2.3.2 栈封闭
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象(其实就是破坏“共享且可变”的共享性)。正如封装能使得代码更容易维持不变性条件那样,同步变量也能使对象更易于封闭在线程中。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭(也被称为线程内部使用或者线程局部使用,不要与核心类库中的ThreadLocal混淆)比Ad-hoc线程封闭更易于维护,也更加健壮。
对于基本类型的局部变量,例如程序清单3-9中loadTheArk方法的numPairs,无论如何都不会破坏栈封闭性。由于任何方法都无法获得对基本类型的引用,因此Java语言的这种语义就确保了基本类型的局部变量始终封闭在线程内。
基本类型的局部变量与引用变量的线程封闭性

public int loadTheArk(Collection<Animal> candidates) {
    SortedSet<Animal> animals;
    int numPairs = 0;
    Animal candidate = null;
    // animals被封闭在方法中,不要使它们逸出!
    animals = new TreeSet<Animal>(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)上下文中使用非线程安全的对象,那么该对象仍然是线程安全的。然而,要小心的是,只有编写代码的开发 人员才知道哪些对象需要被封闭到执行线程中,以及被封闭的对象是否是线程安全的。如果没有明确地说明这些需求,那么后续的维护人员很容易错误地使对象逸 出。

2.3.3 ThreadLocal类
维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了 get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。
ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。由于JDBC的连接对象不一定是线程安全的,因 此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接,如下面程序中的ConnectionHolder所示。
private static ThreadLocal<Connection> connectionHolder
    = new ThreadLocal<Connection>() {
        public Connection initialValue() {
            return DriverManager.getConnection(DB_URL);
        }
    };
public static Connection getConnection() {
    return connectionHolder.get();
}

当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。例如,在 Java 5.0之前,Integer.toString()方法使用ThreadLocal对象来保存一个12字节大小的缓冲区,用于对结果进行格式化,而不是使 用共享的静态缓冲区(这需要使用锁机制)或者在每次调用时都分配一个新的缓冲区。
当某个线程初次调用ThreadLocal.get方法时,就会调用initialValue来获取初始值。从概念上看,你可以将 ThreadLocal<T>视为包含了Map< Thread,T>对象,其中保存了特定于该线程的值,但ThreadLocal的实现并非如此。这些特定于线程的值保存在Thread对象中,当 线程终止(但是如果是在线程池中,线程使用完后不会回收,可能会导致后来的线程使用前面线程的遗留值,所以线程池中慎用ThreadLocal,使用完一定要remove回收)后,这些值会作为垃圾回收。
假设你需要将一个单线程应用程序移植到多线程环境中,通过将共享的全局变量转换为ThreadLocal对象(如果全局变量的语义允许),可以维持线程安全性。然而,如果将应用程序范围内的缓存转换为线程局部的缓存,就不会有太大作用。

在实现应用程序框架时大量使用了ThreadLocal。例如,在EJB调用期间,J2EE容器需要将一个事务上下文(Transaction Context)与某个执行中的线程关联起来。通过将事务上下文保存在静态的ThreadLocal对象中,可以很容易地实现这个功能:当框架代码需要判 断当前运行的是哪一个事务时,只需从这个ThreadLocal对象中读取事务上下文。这种机制很方便,因为它避免了在调用每个方法时都要传递执行上下文 信息,然而这也将使用该机制的代码与框架耦合在一起。
开发人员经常滥用ThreadLocal,例如将所有全局变量都作为ThreadLocal对象,或者作为一种“隐藏”方法参数的手段。ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。
另外关于threadLocal可以参考:
http://wenku.baidu.com/link?url=Iysr94Q_Fh7atzCU_NPJbLWOPqvIlXYuJDhWIpG7XzfJUXy1enzHX-blEq4oj-2OrEDbbCBhGfORtqZ0SeslGXPE9JxckJf_zIcwPJqexva

2.4 不变性
无论是线程封闭还是栈封闭还是使用ThreadLocal,都是使对象不共享。现在讨论不变性。满足同步需求的另一种方法是使用不可变对象(Immutable Object),前面就提到过(共享+可变)的状态才会出现多线程问题,线程封闭章节讨论的是解决共享的问题,本节就讨论可变的问题。

到目前为止,我们介绍了许多与原子性和可见性相关的问题,例如得到失效数据,丢失更新操作或者观察到某个对象处于不一致的状态等等,都与多线程试 图同时访问同一个可变的状态相关。如果对象的状态不会改变,那么这些问题与复杂性也就自然消失了。
如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。

不可变对象一定是线程安全的。
不可变对象很简单。它们只有一种状态,并且该状态由构造函数来控制。在程序设计中,一个最困难的地方就是判断复杂对象的可能状态。然而,判断不可变对象的状态却很简单。
同样,不可变对象也更加安全。如果将一个可变对象传递给不可信的代码,或者将该对象发布到不可信代码可以访问它的地方,那么就很危险——不可信代码 会改变它们的状态,更糟的是,在代码中将保留一个对该对象的引用并稍后在其他线程中修改对象的状态。另一方面,不可变对象不会像这样被恶意代码或者有问题 的代码破坏,因此可以安全地共享和发布这些对象,而无须创建保护性的副本。

虽然在Java语言规范和Java内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将对象中所有的域都声明为final类型,即使对象中所有的域都是final类型的,这个对象也仍然是可变的,因为在final类型的域中可以保存对可变对象的引用。

同时满足以下条件时,对象才是不可变的:
•对象创建以后起状态就不能修改。
•对象的所有域都是final类型。
•对象是正确创建的(在对象的创建期间,this引用没有逸出)。

在不可变对象的内部仍可以使用可变对象来管理它们的状态,如下面程序中的ThreeStooges所示。尽管保存姓名的Set对象是可变 的,但从ThreeStooges的设计中可以看到,在Set对象构造完成后无法对其进行修改。stooges是一个final类型的引用变量,因此所有的对象状态都通过一个final域来访问。最后一个要求是“正确地构造对象”,这个要求很容易满足,因为构造函数能使该引用由除了构造函数及其调用者之外的代码来访问。
public final class ThreeStooges {
    private final Set<String> stooges = new HashSet<String>();//final且private
    public ThreeStooges() {
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");//构造完之后不会再改变,至此,3个条件都满足。
    }
    public boolean isStooge(String name) {
        return stooges.contains(name);
    }
}

由于程序的状态总在不断地变化,你可能会认为需要使用不可变对象的地方不多,但实际情况并非如此。在“不可变的对象”与“不可变的对象引用”之间存在着差异。保存在不可变对象中的程序状态仍然可以更新,即通过将一个保存新状态的实例来“替换”原有的不可变对象。

2.4.1 final域
关键字final可以视为C++中const机制的一种受限版本,用于构造不可变性对象。final类型的域是不能修改的(但如果final域 所引用的对象是可变的,那么这些被引用的对象是可以修改的)。然而,在Java内存模型中,final域还有着特殊的语义final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。这是jvm做的规约,后面我们会再讨论这一点。

即使对象是可变的,通过将对象的某些域声明为final类型,仍然可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象可能的状态集 合。仅包含一个或两个可变状态的“基本不可变”对象仍然比包含多个可变状态的对象简单。通过将域声明为final类型,也相当于告诉维护人员这些域是不会 变化的。
正如“除非需要更高的可见性,否则应将所有的域都声明为私有域”是一个良好的编程习惯,“除非需要某个域是可变的,否则应将其声明为final域”也是一个良好的编程习惯。

通过为final域增加写和读重排序规则,可以为java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指lock和volatile的使用),就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值。但一旦构造函数返回后,其余的操作是否线程安全需要自己保证。
还可以参考:http://www.infoq.com/cn/articles/java-memory-model-6

扩散下思维,final域的初始化问题:final类型的变量必须显示初始化,且初始化的方法必须是在声明时或非静态块或者在构造方法中初始化,而不能通过调用函数赋值。初始化之后不能再赋值。

2.4.2  示例:使用Volatile类型来发布不可变对象
在前面的UnsafeCachingFactorizer类中,我们尝试用两个AtomicReferences变量来保存最新的数值及其因数 分解结果,但这种方式并非是线程安全的,因为我们无法以原子方式来同时读取或更新这两个相关的值。同样,用volatile类型的变量来保存这些值也不是 程安全的。然而,在某些情况下,不可变对象能提供一种弱形式的原子性。
因式分解Servlet将执行两个原子操作:更新缓存的结果,以及通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的因数分解结 果。每当需要对一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据,例如程序清单3-12中的 OneValueCache。
下面程序对数值及其因数分解结果进行缓存的不可变容器类

@Immutable
class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;//两个final,private域
    public OneValueCache(BigInteger i,
                         BigInteger[] factors) {
        lastNumber  = i;
        //构造好就不会再改变。至此,也满足了final域的不可变形。
        lastFactors = Arrays.copyOf(factors, factors.length);
    }
    public BigInteger[] getFactors(BigInteger i) {
        if (lastNumber == null || !lastNumber.equals(i))
            return null;
        else
            return Arrays.copyOf(lastFactors, lastFactors.length);
    }
}

对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。如果是一个可变的对象,那么就必须使用 锁来确保原子性。如果是一个不可变对象,那么当线程获得了对该对象的引用后,就不必担心另一个线程会修改对象的状态。如果要更新这些变量,那么可以创建一 个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致的状态。
上面程序中的VolatileCachedFactorizer使用了OneValueCache来保存缓存的数值及其因数。当一个线程将volatile类型的cache设置为引用一个新的OneValueCache时,其他线程就会立即看到新缓存的数据。
使用指向不可变容器对象的volatile类型引用以缓存最新的结果

@ThreadSafe
public class VolatileCachedFactorizer implements Servlet {
    private volatile OneValueCache cache =
        new OneValueCache(null, null);
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = cache.getFactors(i);
        if (factors == null) {
            factors = factor(i);
            cache = new OneValueCache(i, factors);
        }
        encodeIntoResponse(resp, factors);
    }
}

与cache相关的操作不会相互干扰,因为OneValueCache是不可变的,并且在每条相应的代码路径中只会访问它一次。通过使用包含多个状 态变量的容器对象来维持不变性条件,并使用一个volatile类型的引用来确保可见性,使得Volatile Cached Factorizer在没有显式地使用锁的情况下仍然是线程安全的。虽然是线程安全的,但这个缓存还是有一个缺陷,会存在重复缓存的问题,当两个线程都走到cache.getFactors(i)时都返回null,就会计算两次,创建两个缓存结果,虽然结果仍然是正确的,这个在后面讲到FutureTask的时候会有更好的解决。

2.5 安全发布
到目前为止,我们重点讨论的是如何确保对象不被发布,例如让对象封闭在线程或另一个对象的内部。当然,在某些情况下我们希望在多个线程间共享对 象,此时必须确保安全地进行共享。然而,如果只是像下面程序那样将对象引用保存到公有域中,那么还不足以安全地发布这个对象。

比如你可能会尝试在构造方法中加这样一行:
instances.add(this);
不过其他线程就能够在对象构造完成之前使用instances访问对象。

下面程序在没有足够同步的情况下发布对象(不要这么做)

//不安全的发布
public Holder holder;
public void initialize() {
    holder = new Holder(42);//这一行包含很多的指令才能完成构造好一个对象。
}

你可能会奇怪,这个看似没有问题的示例何以会运行失败。由于存在可见性问题,其他线程看到的Holder对象将处于不一致的状态(这里的关键是因为holder是public的,在构造方法里正在初始化holder还没有完全构造好时,其他线程是可以访问holder的),即便在该对象的构造函数中已经正确地构建了不变性条件。这种不正确的发布导致其他线程看到尚未创建完成的对象。

2.5.1 不正确的发布:正确的对象被破坏
你不能指望一个尚未被完全创建的对象拥有完整性。某个观察该对象的线程将看到对象处于不一致的状态,然后看到对象的状态突然发生变化,即使线程 在对象发布后还没有修改过它。事实上,如果下面程序中的Holder使用上面2.5节开头程序中的不安全发布方式,那么另一个线程在调用 assertSanity时将抛出AssertionError。
下面程序由于未被正确发布,因此这个类可能出现故障

public class Holder {
    private int n;
    public Holder(int n) { this.n = n; }
    public void assertSanity() {
        if (n != n)
            throw new AssertionError("This statement is false.");
    }
}

由于没有使用同步来确保Holder对象对其他线程可见,因此将Holder称为“未被正确发布”。在未被正确发布的对象中存在两个问题。首先,除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟糕的情况是,线程看到Holder 引用的值是最新的,但Holder状态的值却是失效的。情况变得更加不可预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值,这也是assertSainty抛出AssertionError的原因。也就是说Holder没有完全初始化时,只要其他线程能调用到它的成员或方法,访问了内部状态,就有可能出错。

如果没有足够的同步,那么当在多个线程间共享数据时将发生一些非常奇怪的事情。

2.5.2  不可变对象与初始化安全性
由于不可变对象是一种非常重要的对象,因此Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。我们已经知道,即使某个对象 的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态能呈现出一致的视图,就必须使用同步。

另一方面,即使在发布不可变对象的引用时没有使用同步,也仍然可以安全地访问该对象。为了维持这种初始化安全性的保证,必须满足不可变性的所有需 求:状态不可修改,所有域都是final类型,以及正确的构造过程。(如果2.5.1节程序中的Holder对象是不可变的,那么即使Holder没有被正确地发布,在assertSanity中也不会抛出AssertionError。)
任何线程都可以在不需要额外同步的情况下安全地访问不可改变对象,即使在发布这些对象时没有使用同步。
这种保证还将延伸到被正确创建对象中所有final类型的域。在没有额外同步的情况下,也可以安全地访问final类型的域。然而,如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。

2.5.3 安全发布的常用模式
这一节尤其重要,因为我们遇到的大多数问题都是对可变对象的发布,而且要保证线程安全。
可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。现在,我们将重点介绍如何确保使用对象的线程能够看到该对象处于已发布的状态,并稍后介绍如何在对象发布后对其可见性进行修改。
要安全地发布一个对象,对象的应用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式之一来安全地发布:
•在静态初始化函数中初始化一个对象引用。
•将对象的引用保存到volatile类型的域或者AtomicReference对象中。
•将对象的引用保存到某个正确构造对象的final类型域中。
•将对象的引用保存到一个由锁保护的域中。


在线程安全容器内部的同步意味着,在将对象放入到某个容器,例如Vector或synchronizedList时,将满足上述最后一条需求。如果 线程A将对象X放入一个线程安全的容器,随后线程B读取这个对象,那么可以确保B看到A设置的X状态,即便在这段读/写X的应用程序代码中没有包含显式的 同步。尽管Javadoc在这个主题上没有给出很清晰的说明,但线程安全库中的容器类提供了以下的安全发布保证:
•通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。
•通过将某个元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或synchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
•通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。
类库中的其他数据传递机制(例如Future和Exchanger)同样能实现安全发布,在介绍这些机制时将讨论它们的安全发布功能。
通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:
public static Holder holder = new Holder(42);
静态初始化器由JVM在类的初始化阶段执行。由于在JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布。

安全发布并不一定代表所有使用的地方是安全使用。

2.5.4 事实不可变对象
如果对象在发布后不会被修改,那么对于其他在没有额外同步的情况下安全地访问这些对象的线程来说,安全发布是足够的。所有的安全发布机制都能确 保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所有线程也将是可见的,并且如果对象状态不会再改变,那么就足以确保任何访问都是安 全的。

如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象(Effectively Immutable Object)”。这些对象不需要满足2.4节中提出的不可变性的严格定义。在这些对象发布后,程序只需将它们视为不可变对象即可。通过使用事实不可变对 象,不仅可以简化开发过程,而且还能由于减少了同步而提高性能。
在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
例如,Date本身是可变的,但如果将它作为不可变对象来使用,那么在多个线程之间共享Date对象时,就可以省去对锁的使用。假设需要维护一个Map对象,其中保存了每位用户的最近登录时间:

public Map<String, Date> lastLogin =
    Collections.synchronizedMap(new HashMap<String, Date>());
如果Date对象的值在被放入Map后就不会改变,那么synchronizedMap中的同步机制就足以使Date值被安全地发布,并且在访问这些Date值时不需要额外的同步。

2.5.5 可变对象
如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问 时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者由某个锁保护起来。
对象的发布需求取决于它的可变性:
•不可变对象可以通过任意机制来发布。
•事实不可改变必须通过安全方式发布。
•可变对象必须通过安全方式发布,并且必须是线程安全的或者由某个锁保护起来。


2.5.6 安全地共享对象
当获得对象的一个引用时,你需要知道在这个引用上可以执行哪些操作。在使用它之前是否需要获得一个锁?是否可以修改它的状态,或者只能读取它?许多并发错误都是由于没有理解共享对象的这些“既定规则”而导致的。当发布一个对象时,必须明确地说明对象的访问方式。
4
0
分享到:
评论

相关推荐

    北大青鸟 accp5.0 JSPServletJavaBean复习总结

    - **JAX-WS技术**:简化了使用Java技术开发Web服务的工作,使开发者能够轻松地创建、发布和调用Web服务。 - **JNDI技术**:Java Naming and Directory Interface(Java命名和目录接口),它提供了一套标准的API来...

    C#入门与进阶-微软中国公司开发合作部

    "C#入门与进阶"是微软中国公司开发合作部发布的一份教程,旨在帮助初学者和有一定经验的开发者深入理解C#的核心概念和技术。 C#的设计目标是实现对C++的忠诚度,同时简化编程模型并增强可扩展性。它支持组件编程,...

    rust语言进阶教程

    在Rust的开发版章节,教程介绍了如何使用编译器插件、内联汇编、不使用标准库、固有功能、语言项以及链接进阶知识,这些内容对于深入理解Rust构建系统和底层特性至关重要。此外,还包含了基准测试、装箱语法和模式、...

    maven的安装配置及入门

    2. **项目结构**:Maven项目的基本结构包括`pom.xml`(项目对象模型),`src/main/java`(源代码),`src/test/java`(测试代码)等。 3. **POM文件**:`pom.xml`是Maven项目的灵魂,它包含了项目的信息、依赖管理...

    asp入门进阶源代码

    - **Application对象**:在所有用户间共享数据,常用于存储全局变量。 - **Server对象**:提供服务器级别的功能,如执行脚本、URL编码等。 - **Cookies对象**和**ClientScript对象**:分别处理客户端的Cookie和...

    JSP项目开发全程实录(完整源代码).rar

    JSP和Servlet可以通过`&lt;jsp:useBean&gt;`和`&lt;jsp:setProperty&gt;`标签进行关联,实现数据的共享。 在**博客系统**的实现中,还会涉及到数据库操作。JDBC(Java Database Connectivity)是Java连接数据库的标准API,...

    Java语言程序设计.进阶篇(原书第8版).pdf

    - **同步和锁**:为了防止多个线程同时访问共享资源导致的问题,Java提供了synchronized关键字和Lock接口来进行同步控制。 - **并发工具类**:Java并发库提供了丰富的工具类,如Executor框架、CountDownLatch、...

    Java2实用教程电子教案.pdf

    类变量对所有对象共享。 - **通过类名直接访问类变量**: 不需要对象引用。 - **实例方法和类方法的区别**: 类方法不依赖于对象的存在,可以直接通过类名调用。 **4.5 THIS 关键字** - 用于在方法内部引用当前对象。...

    ASP实例开发源码-asp讯客分类信息发布系统 v4.6.zip

    在这个系统中,开发者可能用到了ASP内置的对象,如Response、Request、Session和Application,来处理用户请求、收集表单数据、管理会话状态以及共享应用程序级信息。此外,还可能使用了ADODB对象来连接和操作数据库...

    基于ASP+ACCESS实现的学生管理系统(源代码+论文).zip

    2. **内置对象**:ASP提供了Request、Response、Session、Application等内置对象,用于处理HTTP请求、发送响应、管理会话和共享应用程序级数据。 3. **服务器端包含**:ASP支持服务器端包含指令(&lt;!--#include--&gt;)...

    asp精华心得学习ASP快速上手进阶

    - **包含文件**:使用`&lt;% @include file="filename.asp" %&gt;`语句,可以将常用代码片段放入单独文件,方便维护。 - **ASP.NET集成**:虽然ASP和ASP.NET是不同的技术,但在IIS中可以共存,ASP.NET的一些功能(如C#...

    asp动态网站编程

    2. ASP对象模型: - Request对象:获取客户端发送的数据,如表单数据、查询字符串、cookies等。 - Response对象:向客户端发送响应,如HTML页面、文件或HTTP头信息。 - Session对象:用于存储用户会话期间的信息...

    Fortran编程教材-世界顶级高校教材

    - **面向对象编程**:讲解对象、类和继承等面向对象编程的核心概念。 - **函数式编程**:介绍函数式编程的基本思想,如不可变性、纯函数等。 - **设计模式**: - 探讨常用的设计模式,如工厂模式、策略模式等,...

    java课件资料共享部分

    Java是一种广泛使用的面向对象的编程语言,自1995年发布以来,它已经成为软件开发领域的核心工具。Java以其“一次编写,到处运行”的特性,跨平台兼容性以及强大的安全性能,深受开发者喜爱。本课件资料共享部分将...

    gauravpandey44-kubernetes-k8s.pdf

    使用 `kubectl get pods -n &lt;namespace&gt;` 查看特定命名空间下的 Pod。 ### Kubernetes 实际应用场景 - **微服务架构**:Kubernetes 非常适合管理微服务架构中的多个服务实例。 - **DevOps 流程集成**:可以与 CI/CD...

    unit8:练习 2 单元 8 git

    【描述】"单元8 练习 2 单元 8 git" 提到的“练习 2”意味着这是一个逐步进阶的学习过程,旨在深化对Git操作的理解和应用。在编程学习中,练习通常涉及实际操作,如创建分支、合并代码、解决冲突等。 【标签】"Java...

    Java面试常见问题从基础到进阶

    Java是一种广泛使用的编程语言,由Sun Microsystems(现已被Oracle公司收购)于1995年发布。它的设计理念是“一次编写,到处运行”,这得益于Java虚拟机(JVM)的跨平台特性。Java的特点包括垃圾回收机制、面向对象...

Global site tag (gtag.js) - Google Analytics