JAVA-CONCURRENCY IN PRACTICE章节三翻译

参考了很多童老师团队的翻译,自己也看英文对照,有修改

章节三 共享对象

我们在第二章开始时就讲到,编写正确的并发程序的关键是管理好共享容易被改变的状态。上一章主要讲了如何用同步机制阻止多线程同时访问这些相同的变量。本章主要检验一些技巧,用于编写被多线程安全使用的共享的、公开的对象。通过使用 java.util.concurrent 中的类,这两章一同为我们创建线程的安全类和并发应用中安全构造。

我们已经知道如何使用synchronized块和synchronized方法来实现一组操作的原子性。但是总有一种错误想法认为,synchronized仅仅跟原子性相关或者是划分出关键的部分,实际上不止如此,synchronized也包含内存可见性这类较为重要且微妙的方面。我们不仅要确保能够阻止当线程试图修改一个被其他线程使用的对象的状态这种情况,还要能够保证当一个线程修改了某个共享对象的状态之后,其他线程能够看到该共享对象状态的变化。但是如果没有同步,这就很难发生。你可以使用两种方式确保对象被安全的发布,要么使用明确的同步机制,要么使用类库里的同步的优点来实现。

3.1. 内存可见性

可见性问题是非常微妙的,因为可见性错误往往是违反直觉的。在单线程环境下,如果你向一个变量写入一个值,随后你可以从该变量中读取这个值,只要你在读取之前没再做修改,这个值就会正确的返回,这是很自然的事情。但是在多线程程序中情况就不是这样了,这可能开始是很难接受的。一般来说,你无法保证甚至根本就不可能保证,一个线程向能够及时读取到由另一个线程写入变量的值。为了确保多线程间写入时的内存可见性,你必须使用同步机制。

下面的代码简单演示了当多线程共享数据时不用同步可能出现的问题。一共有两个线程,主线程和一个reader线程访问共享的变量ready 和 number。Main 线程先启动 reader 线程,然后为 reader 和 number 赋值true和42。reader线程会停滞延长至ready被改变为true,然后再打印出number。乍一看,打印结果应该是 42,但实际上打印结果很有可能是 0,甚至程序一直运行下去无法终止。由于同步不足,无法确保 main 线程对 ready 和 number 的赋值对reader线程是可见的。

 

NoVisibility类可能无限循环下去,因为在main线程中为ready赋的值可能永远无法被reader线程看见。更奇怪的是,也可能打印0,因为 reader 可能先看到 ready为true,比看到对number的修改为42要早--这是一种叫做重新排序的现象。在一个线程中的操作并不能保证是按着程序给的代码的顺序执行的,哪怕是重新排序对于别的线程是表面上可见的,但是实际上也是无法被那个线程检测到的。如果主线程在没有使用同步的情况下先改number、再改ready,reader线程看到的可能是这些写入赋值操作是以相反的顺序进行的,或者根本没什么顺序。

在没有同步机制的情况下,编译器、处理器和运行时可能对操作执行的顺序做一些较为明显的、令人匪夷所思的调整在同步不够充足的情况下想要推理出多线程程序在内存中的“必然”执行顺序,当然得到的结果几乎都是错误的。

NoVisibility 类算是最简单的并发程序了,只有两个线程,两个共享变量, 但是仍然容易出错--关于它做了什么的结论,甚至是无法终止的错误。想要推断同步不足的并发程序是十分困难的。

这听起来可能有点可怕,但是也应该保持畏惧。幸运的是有一种解决这个问题的简单方法:不管变量什么时候被多线程共享,总是使用合适的同步机制。

3.1.1 陈旧数据

NoVisibility 演示了同步不充分造成结果的情况中的一种,并发程序可能造成出乎意料的计算结果:陈旧数据。当 reader线程读取ready变量的时候,它可能得到陈旧的数据。除非每次对变量进行访问的时候都使用同步机制,否则这种可能性就会存在。更糟的是陈旧数据不是全都是陈旧的或者全都不是:一个线程看到的一个变量是最新的,但是另一个变量可能是之前写的陈旧数据。

食物过期那还能吃--顶多是难吃点,但是过期数据可能就是非常危险了。尽管web应用中的过时的点击计数器可能没那么糟,但是过期数据确实会造成严重的安全问题和活跃性问题。在 NoVisibility 类中,陈旧数据可能导致打印出错误的值,或者导致程序无法终止。如果陈旧数据是对象引用的话,情况会变得更复杂,例如LinkedList里的链表指针。一般来说,陈旧数据可能导致严重的、令人疑惑的错误,像程序异常、数据结构破坏、计算精度损失和无限循环等等。

在下图中MutableInteger 类不是线程安全的,因为 value的get 和 set 方法都没有使用同步机制。在其他的危险中,陈旧数据是最容易出现的:如果一个线程在调用 set 方法,另一个线程正在调用 get 方法就可能获取到陈旧的数据。

 

3.1.2 非原子性的64位操作

当一个线程读取一个未被同步的共享变量的时候,它可能获取到一个陈旧的数据,但是它应该至少能获取到一个之前被其他线程写入的数据,而不是获取到一个随机值,这种安全保证称为最低安全性保障。

最低安全性保障对所有变量都是适用的,但是64 比特的非volatile类型的变量(包括 double 和 long)比较特殊。Java内存模型需要保证它的获取和存储操作都要是原子性的,但是对于64 比特非volatile类型的变量,jvm虚拟机是被允许分为两个 32 比特进行读写操作的。如果在不同的线程中读写操作发生,就会导致当你读取一个 long类型共享变量值的时候,高32位是一个线程写入的,低32位是另一个线程写入的。因此,即使你不担心陈旧数据的问题,在多线程程序中使用非 volatile 的 long 或 double 类型的易变共享变量都是不安全的,除非程序里适用volatile修饰、或者用锁保护。

3.1.3 锁以及可见性

如下图所示,内部锁可以被用来确保一个线程能够看到另一个线程按着我们期望的形式产生的结果。当线程 A 进入synchronized块后,线程 B 也要按顺序进入被同一把锁保护的 synchronized 块。对于A来说变量的值是可见的,它会先释放锁,B在此基础上获得锁之后也能够被保证得到被A操作过的变量值。换句话说,线程 A 中在synchronized块先做的操作--对变量的修改可以确保对线程 B 可见。如果没有同步,这些保证也不复存在。

对于坚持所有线程在访问共享易变变量时都需要用相同的锁来控制同步这一原则我们现在有了新的理由了--为了保证一个线程修改的变量的值对于其他的线程都是可见的。否则的话,如果一个线程读取一个没有合适的锁来保护的变量,就有可能读取一个过期的数据。

锁不仅能够提供互斥功能,也可以提供内存可见性。为了确保所有线程都能够看到易被改变的共享变量的最新值,所有对该变量的读写操作都必须被同一个锁同步控制。

 

3.1.4 volatile修饰的变量

Java 语言也提供了一种可选择的、较弱的机制,volatile修饰变量,来确保一个线程对共享变量修改能够对其他线程可见。当一个变量被声明为 volatile 的时候,编译器和运行时就知道该变量是共享变量,与其相关的操作不能被内存相关操作改变执行顺序。Volatile修饰的变量不会被缓存到暂存器中或者是其他处理器的缓存中,因此,读取一个 volatile 共享变量总是能够获取到其他线程对它的最新赋值。

想要理解volatile变量修饰的变量,你可以假设它的读写操作概略的讲是等价于 SynchronizedInteger 类中带 synchronized 的 get 和 set 方法。不过 Volatile 变量并没有使用锁,因此不会导致执行线程阻塞,因此相比于 synchronized 块,volatile 是一个轻量级的同步机制。

Volatile修饰的变量产生的可见性影响效应要超过它本身的价值。当线程A向一个volatile变量写入,线程B按顺序随后过来读取时,A优先修改、B之后也会获得可见性并读取数据。所以从内存可见性角度看,写入一个volatil的变量就像是退出synchronized块而读取就像是进入synchronized块。我们并不建议过多地依赖于 volatile关键字,为了可见性使用 volatile修饰变量的代码更加脆弱,并且比使用锁实现内存可见性的代码更加难以理解。

只有当volatile能够简化实现时或者是核查你的同步策略时再使用volatile。但是如果说用了它,却需要你在验证正确性的时候,精细的去推断可见性,那就不要使用它了。利用好volatile在几个方面:一个是确保他们修饰的变量的可见性,另一个是能够声明生命周期的重要事件(初始化或者关闭)的发生。

下面的代码演示了 volatile 变量的一种典型用法:检查一个状态flag标志来决定是否退出循环。在这个例子中,我们弄了个人性化的线程,这个线程尝试通过数羊这种“老字号”方法“睡着”。为了让这个例子有效的跑起来,我们需要让asleep这个变量被volatile修饰。否则当别的线程修改了这个asleep这个变量时,这个线程时注意不到的。我们也可以使用锁来实现asleep变量可见性,但是那样代码就没这么简洁了。

Volatile域用起来比较方便,但是它也有局限性。Volatile域最常见的用法是作为完成标志、中断标志和状态标志,比如上例中的asleep。Volatile修饰的变量也可以被用于存储其他类型的状态信息,但是如果你企图这么用的话必须要小心了。例如,volatile语义无法确保 count++操作是原子的,除非你可以确定只有一个线程可对该变量进行写操作,其他线程都是进行读操作。(Atomic原子性变量提供了读取-修改-写入原子性操作并且可作为“最好的volatile变量被”经常使用)

锁可以保证原子性和内存可见性,而 volatile 域只能确保内存可见性。

只有在满足如下条件的情况下才能使用 volatile关键字: l

·对变量的写操作不依赖于它的当前值,或者你可以确保只有一   个线程能够更新该变量的值。 l

·该变量不和其他变量一起组成对象的某个正确性约束关系。 l

·任何时候对变量的访问确实不需要使用锁。

3.2 发布与逸出

发布一个对象意味着使它能够被当前范围之外的代码使用,比如存储一个指向该对象的引用,可供其他代码找到并使用;通过从一个非私有方法中返回这个对象或者将这个对象作为参数传入其他类的方法中。在许多情况下,我们想要确保对象和它的内部引用没有被发布;在另一些情况下我们想要发布一个对象进行常规的使用,但是如果想要在线程安全的前提下做这个事需要同步机制。发布内部状态变量可能会在封装属性上有所妥协,并且会导致不变约束关系很难保护;在对象被完整的构造出来之前发布出来的话会在线程安全方面产生影响。如果一个对象被不恰当地发表了,就称为逸出。3.5里面会介绍常用的安全发布的方法,现在,让我们来看一下对象是怎么逸出的。

最明显的发布的形式是将一个对象的引用存储在 public static 修饰的变量中,这样任何类和线程都能够访问它。如下代码所示,initialize()这个方法创建了一个 HashSet 对象实例,然后将引用赋值给 public static修饰的 kownSecrets,这样就是发布了。

发布一个对象可能间接地发表其他对象,如果向knownSecrets这个set集合中添加一个Secret对象,那么你发布集合的同时也发布了这个Secret对象。因为任何代码都可以通过遍历knownSecrets来获取对该Secret对象的引用。相似地,从一个非private方法中返回一个对象引用,其实也发布了这个对象,如下代码将私有的状态缩写的数组对象通过 return 语句发布出去:

上图中,以这种方式发布states这个变量是有问题的,因为任何一个调用者都能修改它的内容。在这种情况下,这个states数组已经从它原来准备的范围内逃逸出来--原来被设定为私有的,现在可以作为public一样的功效去使用。

发布一个对象也发布了它的非 private修饰的所有对象变量。更确切地说, 所有非 private修饰的变量和通过非私有方法调用链上可被获取的对象都被一起发布了。

从一个类 C 的角度看,异化方法是那些行为不被C掌握的方法,包括所有其他类中的方法和类C中的可覆盖方法(非 private 且非 final 的方法)。将一个对象作为参数传递给异化方法就意味着发布了这个对象。这是因为你并不知道什么样的代码会被执行,你也不知道异化方法会不会发布这个对象或者是保留和对象的关系引用--被其他线程使用。

不管是否会有其他线程引用你发布的对象,风险都是存在的,一旦有对象逃逸,你应该设想其他类或者线程会恶意地或无意地错用这个对象。因此,我们有足够的理由使用封装,因为这样使得分析程序的正确性变得实际可行并且更不容易在意外情况下违背设计约束。

最后一种发布对象内部状态或者对象本身的机制,是发表内部类的实例。如下代码所示,当 ThisEscape 发表内部匿名类实例的时候,会隐式地把 ThisEscape对象也发布出去了,因为内部类的实例隐式地包含了对外部类实例的引用。

3.2.1 安全构造实践

上例中的 ThisEscape 类是一个很特殊的案例--在其构造函数构造期间就把this的引用发布了。这是因为当内部类EventListener实例被发布的时候,封闭的ThisEscapde实例也一起被发布了。对象只有在构造函数返回实例之后才是可预测的、一致的状态,但是由于构造函数还没有退出,发布出去的this是一个没有完全构造好的对象,这是非常危险的。即便是在整个构造函数中最后一句代码才是发布的那个动作也是会造成这种情况的。如果this对象的引用在构造期间发生逃逸,对象就会被认为没有被合适的构造创建出来。

不允许在对象构造完成之前发布它的引用。

一个常见的导致this对象在构造时逸出的做法是在它构造函数中创建一个线程。当一个对象在他的构造函数中创建了一个线程时,这个类会和新的线程显式(把引用传递到构造函数中)或隐式地(这种一般是Thread或者Runnable是内部类的情况)共享 this 对象的引用。新创建的线程就可以在对象构造完成之前访问 this 对象。在构造函数中创建一个线程并没有什么问题,但不要立即启动它,作为替代的方法,你可以暴露一个开始或者初始化方法来开启线程。在构造函数中调用一个可被覆盖的实例方法(既不是private也不是final修饰的)也可能造成 this 对象的逃逸。

如果你想在某个对象的构造函数中注册一个事件监听器或者启动一个线程,你可以先创建一个 private 构造函数,然后创建一个 public 工厂方法生成这个对象的实例,这样就可避免在对象构造完成之前发表 this,如下例所示:

3.3 线程限制

访问共享易变的数据需要使用同步机制。如果不共享该对象也就不需要使用同步机制了。线程封闭是达到线程安全性目的的最简单的方法。如果一个对象只能被一个线程访问,也就不需要同步机制了。试想一下当一个对象被限制于只对一个线程,即使这个对象本身不是线程安全的,这种用法也能自动地保证线程安全。

 Swing 框架中广泛地使用了线程封闭技术。可视化组件和数据对象模型不是线程安全的,但是通过只在单一的事件分发线程中访问它们使线程安全。为了更恰当的使用Swing框架,在其他线程中运行的代码不应该访问这些对象。(为了让实现的方式更简单,Swing框架提供了invokeLaterjishi机制并在事件触发的线程中为Runnable排序 )Swing 中的很多并发错误,都是由于在其他线程中,对这些本应对一个线程封闭组件和数据模型的不恰当访问造成的。

另一个常见的使用线程封闭技术的例子是 JDBC 连接池。连接池中的 Connection 对象不是线程安全的,因为不需要如此。在典型的服务器端应用程序中,一个线程从连接池中获取一个Connection 对象,将其用于处理单个客户端请求,然后将其返还给连接池。由于大多数客户端请求(像servlet请求或者是EJB调用)的处理过程都是单线程的,并且在Connection被返回之前,连接池也不会同时将一个连接发送给多个线程使用,这种 Connection 对象管理模式,在请求期间,隐式地将一个 Connection 对象封闭在了客户端请求对应的单个线程之中了。

就像语言里没有机制强制性要求变量要被锁保护起来,也没有强制要求把一个对象限制在一个线程内。线程封闭只是一种程序设计技巧,是编程者在编写程序的时候自己实现的。编程语言和核心类库提供了机制来维护线程封闭--本地变量和ThreadLocal类。但是即便是有这些,确保线程封闭的对象不被其他线程共享也是开发者的责任。

3.3.1 特别的线程封闭

特别的线程封闭指的是线程封闭的责任完全落到了编程者的肩上,编程者要确保被封闭的对象不被其他线程共享。这种线程封闭的方法比较脆弱,因为没有使用语言或核心库的支持(像可见性关键字或者本地变量)。事实上,像GUI应用里,可视化组件和数据模型这种某个线程封闭的对象的引用,通常都是在public关键字修饰的字段内。

使用线程封闭技术往往是由于要构造一个像GUI这种特殊的单线程子系统。单线程子系统有时是能提供一些简易性的好处的,这种好处超过了特殊线程封闭的脆弱性弊端。

线程封闭的一个特例是 volatile修饰的变量。如果你可确定只有一个线程可对 volatile变量执行写操作,那么对共享volatile变量执行“读取-修改-写入”操作就是安全的。在这种情况下,你相当于把“修改”限制在一个单独的线程中,阻止了竞争条件的出现,并且 volatile 变量的可见性确保了其他线程能够看到该域的最新值。

由于特殊线程封闭比较脆弱,尽量不要使用。你可以使用健壮性更好的线程封闭技术之一(堆栈封闭和 ThreadLocal)。

3.3.2 堆栈封闭

堆栈线程封闭是一种特殊的线程封闭技术,对象只能通过局部变量接触。就如同封装保护不变的约束关系一样,局部变量使得将一个对象封闭到一个线程中更容易。局部变量本质上就属于其所在的执行线程,它们存在于执行线程的堆栈之中,其他线程是无法访问到的。堆栈(也被叫做线程内或者线程局部用法,但是不要和ThreadLocal类混淆)封闭比特殊的线程封闭更简单易维护而且没那么脆弱了。

基本数据类型的局部变量总是满足堆栈线程封闭条件的(下边方法loadTheArk里的numPairs),因为你无法获取一个原始数据类型变量的引用,语言的语义就确保了基本数据类型的局部变量总是线程封闭的。

维护对象引用的堆栈封闭需要在程序中做一些协调帮助的工作,来确保引用不会逃逸。在上例中我们实例化了一个TreeSet对象,然后将其引用存储在animals局部变量中。这时候只有一个指向该TreeSet对象的引用,并存放在局部变量中, 因此该TreeSet对象对于执行的线程是线程封闭的。不过,如果我们发表了这个Set的引用(或者是它内部任何引用),就会打破线程封闭,导致animals逸出。

如果一个对象处在堆栈封闭的上下文环境下,即使这个对象不是线程安全的,也不会造成线程安全问题。但要注意,这个对象要封闭在执行线程的设计需求,或者是了解这个封闭对象不是线程安全的这种认识,是只存在于写代码的时候开发者的头脑之中。假设如果没有以文档方式记录下来堆栈封闭的用法,将来的维护者可能不知道某个对象必须是线程封闭的,从而造成问题。

3.3.3 ThreadLocal

实现线程封闭的最正式的方式是使用 ThreadLocal,它可以每个对象的值保持在每个线程自己内部。ThreadLocal提供了get和set访问方法,用以维护每个线程使用的、涉及的相关值的独立备份。这样一个get方法返回得最新的值,会从目前正在执行的线程传递给set方法

ThreadLocal变量通常被用来阻止设计中基于可变单例模式或者全局变量的共享。例如,一个单线程应用可能会维护一个全局的数据库连接对象,这个数据库连接对象时再开始的时候就初始化完成的用来避免每个方法都开启一个连接。由于 JDBC 中的 Connection 对象不是线程安全的,如果多线程在没有使用任何额外的协调措施的情况下,使用一个全局共享的 Connection 对象也不是线程安全的。通过使用 ThreadLocal存储JDBC连接对象,每个线程将会获得自己的 Connection 对象,各个线程之间互不干扰。代码如下:

当一个经常被使用的操作需要一个暂时的对象(例如一个buffer)并且想要避免在每次执行过程中都再分配一个暂时性的对象,上边的技巧就能被使用了。例如,在java5.0之前,Integer.toString就使用ThreadLocal来存储用以格式化结果的12字节的buffer,而不是使用一个共享的statci buffer(用这个就需要用锁了)或者每次执行都分配一个新的buffer。

当一个线程第一次调用ThreadLocal的get 方法的时候,将会调用 initialValue 方法获取初始值。从概念上讲,你可以认为一个ThreadLocal<T>就是一个 Map<Thread,T>,用来存储线程到T对象的映射关系(键是某个线程,值是该线程对应的那个存在ThreadLocal里的值),虽然事实上并不是这样实现的,线程特定的值是存储在 Thread 对象本身的,在线程终止之后将会被垃圾回收器回收。

如果你正要把一个单线程的应用移植到多线程环境下,那么在共享全局变量语义允许的情况下,你可以通过把全局共享变量转化成ThreadLocal来保证线程安全。应用范围级别的缓存如果被转换成许多ThreadLocal缓存就不起作用了。

ThreadLocal在应用框架中广泛的被使用。例如,J2EE容器在EJB调用期间就把事务上下文context和执行线程关联起来。这是通过使用static ThreadLocal控制事务上下文简单实现的:当框架里代码需要确定当前正在运行的的事务是什么,它就会从ThreadLocal里获取这个事务上下文。这种方式非常简单,它减少了向每个方法传递执行上下文信息的需要,但是却能在框架中使用这种机制来结合任何的代码。

ThreadLocal是很容易就被滥用的,把线程封闭属性作为使用全局变量的许可或者作为创建“隐藏”方法参数的一种方法,这两种行为都不对。就像全局变量一样,thread-local变量能够增强可重用性,在类之间引入隐藏的关系,但是因此也要小心使用它。

3.4 不变性

在同步需求的最终运行阶段还有的其他操作就是使用不变的对象了。到目前为止,我们描述的几乎所有的原子性和可见性风险(像看到过期数据、丢失了更新或者观察到一个对象处于不一致的状态)都是由于多线程同时访问共享的、容易被改的状态引起的。如果一个对象的状态不能被修改,那么这种风险也就不复存在了。

不可变(Immutable)对象是那种在创建完成之后其状态不能被改变的对象。不可变对象与生俱来就是线程安全的。他们的约束关系是由构造函数建立的,并且只要他们的状态不改变,这些约束关系也不会改变。

不可变对象总是线程安全的。

不可变对象比较简单。他们只会是一种状态,这是由构造函数精确控制的。程序设计的一个最困难的地方就是搞清楚复杂对象的所有可能的状态。对于不可变对象来说这根本不是问题。

不可变对象比易改变对象安全得多。将易变对象传递给其他不可信的代码或者发布到不可信代码能够找到的地方是危险的,因为不可信的代码可能修改易变对象的状态,更糟的是,它保留了引用,并可能会在之后在其他线程中修改状态。然而,不可变对象却不担心恶意代码、有缺陷代码会对其进行修改,因为它是安全共享的,所以它们不需要进行保护性拷贝即可自由地发表。

Java 语言语法和内存模型都没有正式地定义不可变性。但是不可变性并不是简简单单仅仅将某个对象的所有字段都声明为final。一个对象即便所有字段都是final,也可能一直是可变的,因为final修饰的字段可能是掌握可变对象的引用的字段。

当且仅当一个对象满足如下三个条件,它才是不可变的:

·在创建之后其状态不能被改变

·所有字段都是 final 的 n

·被恰当的构造出来(在构造函数中,this 对象不能逃逸)

不可变对象内部可以使用可变对象管理其状态。如下面的代码所示,尽管 stooges 集合内部是可变的,但是 ThreeStooges 类的设计使得对象创建之后其状态不能被修改(集合stooges指向的引用是固定的)。Stooges这个集合是 final 的,所以所有对象状态的访问都只能通过final修饰的字段。三个条件中最后一个,由于构造函数中什么也没做,当然就没有允许 this 对象逃逸,所以是合适的构造函数创建出来的。

由于程序的状态一直在变化,你可能认为不可变对象用处不大,事实上并非如此。对象不可变与对象引用不可变是两个概念。存储在不可变对象的程序状态是能够被更新改变的,通过用持有新状态的新实例来“替换”不可变对象。(我这里理解是指对象内部不能被改,但是如果说你换一个新的实例那算新的不可变对象了,不是对象可以改变的意思)下一节会提供一个这种技术的例子。

3.4.1 final 修饰的字段

final 关键字,是来自C++但限制更多的的一种常量机制的版本,支持 Immutable 对象的构建。Final修饰的字段初始化之后不能被修改(如果它指向一个可变对象的话,这个可变对象的状态还是可以被修改的)。在java内存模型中final修饰的是由特殊语义的,正是因为final是使用,使得在没有同步机制的情况下,不可变对象可以被自由的访问和共享,并且能够保证初始化的数据安全不被更改。

即使一个对象是可变的,使它的一些字段被final修饰也能简化对它状态的推测的难度,因为限制对象的可变性限制了它的可能状态集。一个对象“大多数情况下不变”但是有一个或者两个可变的状态变量,比有许多可变变量的对象要更简单。通过声明final,也能以文档的记录形式告诉维护者们这些个字段是不希望被改变的。

就像如果不需要更多的可见度时把所有字段都变成私有的一样,如果变量不需要改变,把他们都设置为final也是很好的方法。

3.4.2 例子:使用volatile关键字来发布不可变对象

在2.3节的UnsafeCachingFactorizer类中,我们使用了两个 AtomicReference 类来存储上一个要分解的数和上一个分解结果。但是这个 Servlet 不是线程安全的,因为我们无法原子地更新两个相关联的变量,即使使用volatile关键字修饰变量也会因为同样的理由无法保证线程安全。然而不可变对象有时却可以提供一种较为脆弱的原子性。

UnsafeCachingFactorizer类中有两种操作必须保持原子性:更新缓存和在请求数字和缓存数字匹配的情况下从缓存中取得分解结果。无论任何时候,有一组相关联的数据项需要原子性行为的操作,都可以考虑为他们创建一个不可变对象持有这些数据,就像下图中的那个类:

 

在对多个相关联变量进行访问或者更新操作时,产生的竞争条件是可以通过使用不可变对象持有所有的变量,来消除这种隐患。在一个可变的持有这些变量的对象中,你必须使用锁来确保原子性;但是如果使用一个不可变对象,一旦一个线程获得了指向这个对象的引用,就再也不需要担心其他线程修改他的状态。如果变量想要被更新,可以创建一个新的持有变量的对象,不过和前一个持有变量的对象交互的线程仍然用原来的一致的数据。

如下3.13代码使用了 OneValueCache 类来存储缓存的数字和分解后的因数结果。由于被声明为 volatile 的,当一个线程将 cache字段修改为指向一个新的 OneValueCache 对象之后,这个新的对象立刻就能够被其他线程看见。

缓存关联操作不会互相影响,因为OneValueCache 是不可变的,缓存的那几个字段只会在每个相关代码路径中独立的被访问。对于这种多个状态变量有一个不可变对象持有的组合方式,实际上就是把他们都关联到一个不变量中,并通过volatile修饰这个不变量的引用,来确保这个不变量是时时刻刻对其他线程可见的。这样就能保证即使VolatileCachedFactorizer没有显示锁的情况下依然是线程安全的。

3.5 安全发布

迄今为止,我们始终聚焦于确保一个对象在某些情况下不被发布,例如:当这个对象被认为要封闭于一个线程或者是在另一个对象内部(内部类)。当然了,我们也存在这样的需求,需要跨线程共享对象,在这种情况下,我们也要保证安全。不幸的是,简单地把一个对象引用存储在public修饰的字段中,如上3.14代码那样,是无法保证安全发布这个对象的。

你可能想象不到上面看起来没多大损害的代码是有多糟糕,由于可见性问题,对于其他线程来说 holder 可能处于不一致状态,即使 Holder 类的构造函数能够保证其正确的创建了!这种不恰当的发布会导致其他线程观察到一个特殊的构造出来的对象。

3.5.1 不恰当的发布:好的对象什么时候变坏的

你不能依赖于没有构建完成的对象的“完整性”。一个观察线程会观察一个易变的对象状态,即使在对象发布后并没有做任何修改,在观察后的某一刻也可能会发现状态突然发生改变。事实上,如果 Holder 对象被以像上一节那样的方式发布的话,另一个线程调用 assertSanity 方法可能会抛出 AssertionError 异常,Holder 类代码如下:

这是因为发表 Holder 对象的时候没有使用同步,其他线程可能看不到 public holder 的改变。由此可能引起两种问题:另一个线程可能看到陈旧的 holder 值,是 null或者其他更老的值,即使holder已经被替换成新的值了。但是更糟的是,另一个线程可能看到了holder指向的引用更新到了最新的值,可是Holder类里的状态值还是陈旧的数值。更准确的说,一个线程可能一开始看到了陈旧的值,然后读取了它,下一刻一个更新的值就被设置了,这就是为什么会从而抛出 AssertionError 异常。

因为存在重复相关操作的风险,当在多线程之间共享数据,又没有足够的同步措施的话,将会发生非常诡异的事情。

3.5.2 不可变对象和初始化安全性

由于不可变对象十分重要,Java 内存模型为共享不可变对象提供了针对于初始化安全的特别的保证。正如我们所看到的,一个对象的引用对于别的线程是可见的并不意味着对象的某些状态也对这些消费线程可见。我们需要同步来保证对象状态展示出来的是一致的。

不可变对象在任何情况下都可以被安全地访问,即使是在缺乏同步措施的情况下发布不可变对象的引用也没有问题。为了保证初始化安全,不可变对象所有的条件都要被满足:不能被修改的状态,所有字段都要被final修饰,恰当的构造创建。(如果上面的 Holder 类是不可变的话,将 Holder 对象没有被恰当的发布,assertSanity方法也不会抛出异常)

不可变对象可以在没有额外同步的情况下被任何线程安全的使用,即使在发布这些对象时也没有使用同步。

恰当构造函数创建对象时,这种特别的保证可以扩展到所有 final修饰的字段;final修饰的字段可以在没有额外同步的条件下被安全的访问。但是如果final修饰指向的是可变对象的话,使用者线程对该可变对象状态的操作需要同步机制。

3.5.3 安全发布的常用方法

可变对象必须被安全地发布,发布线程和使用线程都需要使用同步机制。现在,让我们把重心放在确保使用线程能够看到正确的对象发布出来的状态。我们将很快处理发布后修改的的可见性。

要想安全的发布一个对象,指向对象的引用和对象的状态都要同时对其他线程可见。一个被恰当构造创建的对象通过以下几点能被安全的发布:

·在静态static修饰下初始化一个对象引用 u

·将对象的引用存储在一个volatile修饰的字段中或在一个AtomicReference类中 u

·将对象的引用存储在一个恰当构建的对象的final修饰的字段中 u

·将对象的引用存储在一个被锁保护的字段中

线程安全集合(例如 Vector 和 SynchronizedList)的内部同步机制就是使用上述的第四种方式。如果线程 A 将对象 X 插入到一个线程安全集合中,线程 B 随后从这个线程安全集合中获取对象 X,那么获取到的对象 X 的状态就是线程 A 插入的对象的状态。即使调用者应用代码里没有使用任何明确的同步机制,线程安全类也能够保证线程安全性。尽管Javadoc文档并没有太明确一些功能,线程安全集合类库中的一些类还是提供了如下安全发布功能: l

·将一个键或者值放置在HashTable、SynchronizedMap或者ConcurrentMap中都安全地将其发布给其他线程(不管是直接获取还是通过迭代器)。 l

·将一个元素放置在Vector、CopyOnWriteArrayList、 CopyOnWriteArraySet、SynchronizedList 或者 SynchronizedSet 中都能安全地将其发布给其他线程。 l

·将一个元素放置在BlockingQueue 或者 ConcurrentLinkedQueue 中都能安全地将其发布给其他线程。

其他的没涉及到的机制(如Future和Exchanger)也能实现安全发布;在之后我们遇到这些的时候会对此进行说明。

使用静态static初始化的方式是最简单也最安全的对象发表方式,例如:

public static Holder holder = new Holder(42);

静态域初始化是在 JVM 加载类的时候执行的。由于 JVM 的内部同步机制,这种发表对象的方式总是安全的。

3.5.4 等效的不可变对象

如果对象发表之后,使用者不会修改对象的状态,那么上面介绍的方法就可以保证对象发布的安全性了。这种安全发布机制能保证,当一个对象的引用对其他访问线程可见时,对象的已发布状态也对访问线程可见。并且只要这个状态接下来不会改变,那么就足以确保任何访问都是安全的。

如果一个对象不是不可变的,但是发布之后,使用者不会修改其状态,那么这种对象被称为等效的不可变对象。这种对象不需要满足3.4节里关于不可变那么严格的定义,他们仅仅需要程序在发布后像对待不可变对象一样。使用等效的不可变对象可以简化开发,还能通过减少同步的使用来提高性能。

安全发布的等效不可变对象可以在没有额外同步机制的条件下被任何线程安全的使用。

例如,Date 对象是可变的,但是如果你将其作为不可变对象使用,那么在跨线程共享的时候你就可以避免使用锁。假设你要维护一个 Map,用来存储每一个用户的上一次登录时间:

public  Map<String, Date>  lastLogin = Collections.synchronizedMap(new HashMap<String, Date>());

如果这些 Date 对象在被放置到 Map 中之后不需要被修改的话,那么 SynchronizedMap 类就能提供足够的安全性,保证Date的值安全发布,在访问他们的时候也不需要额外的同步。

3.5.5 可变对象

如果一个对象构造出来后可能被使用者修改,安全发布机制只能确保已发布状态的可见性。同步不仅仅是发布一个可变对象时需要用,对对象的每一次访问以及顺序性修改也需要同步来保证可见性。为了安全的共享可变对象,一定要确保这些对象被安全的发布,并且保证这些对象被锁保护或者是线程安全的。

对发布对象的需求依赖于对象自己的可变性:

·对于不可变对象,可以使用任何方式发布,不用担心线程安全问题。 l

·对于等效的不可变对象,必须被安全地发布(3.5.3 节的四种方法)。 l

·对于可变对象,不仅需要安全地发布,还要保证是线程安全的或者使用者对其读写操作时使用同步机制。

3.5.6 安全地共享对象

无论任何时候,当你获得一个的对象的引用,你要知道你被允许对它做什么事。在使用他之前需要获得一个锁么?你可以修改它的某些状态还是只能读取呢?许多并发错误都与源于没有正确理解共享对象的“管理规则”。当你发布一个对象时,你应该做好文档记录,记下这个对象如何被访问。

在一个并发程序中使用、共享对象最常用的策略如下:

线程封闭对象:一个线程封闭对象被一个线程独占并封闭,只能被拥有它的线程修改。

共享只读对象:一个只读的共享对象可以在没有同步的条件下被多线程并发访问,但是不能被任何线程修改。共享的只读对象包括不可变对象和等效不可变对象。

共享线程安全对象:一个线程安全对象在内部执行同步机制,因此多个线程能够在没有进一步同步的条件下通过他的公共接口自由地访问这个对象。

被保护对象:一个被保护的对象只能被持有特定锁的线程访问。被保护对象包括那些被封装在其他线程安全对象内的对象和那些已知的被特殊锁保护起来并发布的对象。

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值