【Java并发编程实践】线程安全性、对象的共享和对象的组合

   【线程安全性】

构建稳健的并发程序,必须要正确地使用线程和锁。“共享”意味着变量可以有多个线程同时访问,而“可变”则意味着变量的值在起生命周期内可以发生变化。
一个对象是否需要是线程安全的,取决于它是否被多个线程访问。
Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但是“同步”这个术语还包括volatile类型的变量,显示锁(explicit Lock)已经原子变量。

**如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种当时可以修复这个问题:
1)不在线程之间共享该状态变量
2)讲状态变量修改为不可变得变量
3)在访问状态变量时使用同步**
面向对象这种技术不仅有助于编写出结构优雅、可维护性高的类,还有助于编写出线程安全的类。
当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。
线程安全的程序是否完全由线程安全类构成?答案是否定的。完全由线程安全类构成的程序并不一定就是线程安全的,而在线程安全类中也可以包含非线程安全的类。

1.什么是线程安全性
安全的含义:在线程安全的定义中,最核心的概念是正确性。正确性的含义是,某个类的行为与其规范完全一致。
当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么这就称这个线程时安全的。
当多个线程访问某个类时,不管运行环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为, 就能表现出正确的行为,那么就称这个类时线程安全的。
如果对“正确性”的定义有些模糊,那么可以将线程安全类认为是一个在并发环境和单线程环境中都不会被破坏的类。
在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。
示例:一个无状态的Servlet
大多数Servlet既不包含任何域,也不包含对任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。因为多个线程并没有共享状态,就好像它们都在访问不同的实例。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因而无状态对象是线程安全的。
无状态对象一定是线程安全的。
大多数servlet都是无状态的,从而极大地降低了在实现Servlet线程安全性时的复杂性,只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题。

2.原子性
如果在无状态的servlet程序中,新增统计已处理请求数量的功能,虽然++count是一个紧凑的语法,使其看上去指只是一个操作,但这个操作并非原子的,因为它并不会作为一个不可分割的操作来执行。实际上,这是一个“读取-修改-写入”的操作序列,并且其结果状态依赖之前的状态。
在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件(Race Condition)

2.1 竞态条件
当某个计算的正确性去取决于多个线程的交替执行时序时,那么就会发生竞态条件。
竞态条件(Race Condition)经常会与数据竞争(Data Race)相混淆,数据竞争是指,如果在访问共享的非final类型的域时没有采用同步进行协同,那么就会出现数据竞争。
当一个线程写入一个变量而另一个线程接下来读取这个变量,或者读取一个之前被另一个线程写入的变量时,并且在这两个线程之间没有使用同步,那么就可能出现数据竞争。
最常见的竞态条件类型就是“先检查后执行”(Check-Then-Act)操作,即通过一个可能失效的观测结果来决定下一步的动作。
比如,两个人约见在星巴克会面,但是约见地点存在两家星巴克门店,两个人都不确定对方去了哪家店,发现朋友不在就回去另一个门店去找,这种情况就会出现竞态条件。
在这个竞态条件中,因为要获得正确的结果(与朋友会面),必须取决于事件的发生时序(当我们到达星巴克时,在离开并去另一家星巴克之前会等待多长时间….)。当你迈出前门时,你在星巴克A的观察结果将变得无效,你的朋友可能从后门进来了,你却不知道。
这种观察结果的失效就是大多数竞态条件的本质————基于一种可能失效的观察结果来做出判断或者执行某个计算,这种类型的竞态条件成为“先检查后执行”:首先观察到某个条件为真(例如文件X不存在),然后根据这个观察结果采用相应的动作(创建文件X),但事实上,在你观察到这个结果一斤开始创建文件之间,观察结果可能变得无效(另一个线程在这个期间创建了文件X),从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)。

2.2 延迟初始化中的竞态条件
使用“先检查后执行”的一种常见情况就是迟延初始化。迟延初始化的目的是将对象的初始化操作推迟到实际被使用时候才进行,同事要确保只被初始化一次。
和大多数并发错误一样,竞态条件并不总是发生错误,还需要某种不恰当的执行时序。然后,竞态条件也可能导致严重的问题。

2.3 复合操作
要避免竞态条件问题,就必须在某个线程修改某一变量时,通过某种方式防止其他线程使用这个变量。
假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么不执行B,那么A和B对彼此来说都是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
为了确保线程安全性,“先检查后执行”(例如延迟初始化)和“读取-修改-写入”(例如递增运算)等操作必须是原子的。我们将“先检查后执行”,以及“读取-修改-写入”等操作成为复合操作:包含一组必须已原子方式执行的操作已确保线程安全性。
在java.util.concurrent.atomic中包含了一些原子变量类,用户实现在数值和对象引用的原子状态装换。通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。
当在无状态的类中添加一个状态时,如果该状态完全由线程安全来管理,那么这个类仍然是线程安全的。
在实际情况中,应尽可能地使用现有的线程安全对象(例如AtomicLong)来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态对其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。

3.加锁机制
假设我们希望提升Servlet的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无须重新计算。(虽然这并非一种有效的缓存策略)要实现该缓存策略,需要保存两个状态:最近执行因数分解的数值以及分解结果。
尽管这些原子引用本身都是线程安全的,但是这其中存在着竞态条件。在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。但是其中重要的不变性条件是,缓存的因数之积应该等于缓存中的数值,以上实现方法不能保证两者的对应关系不变。
当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值对其他变量的值产生约束。因此,当更新一个变量时,需要在同一个原子操作中对其他变量同时进行更新。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

3.1 内置锁
Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。
每个Java对象都可以做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock))或监听器锁(Monitor Lock)。
Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这个锁。

3.2 重入
内置锁时可重入的,如果一个线程试图获取一个已经由它持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程者。当计数值为0时,这个锁被任务是没有任何线程持有。
当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。

4.用锁来保护状态
由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,请我们称状态变量是由这个锁保护的。
每个共享的和可变的变量都应该只由一个锁来保护,从而使维护知道哪个锁。
当类的不变形条件设计多个状态变量时,那么有另外一个需求,在不变性条件中的每个变量都必须由同一个锁来保护。因此可以在单个原子操作中访问或更新这些变量,从而确保不变性条件不被破坏。
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
将每个方法都作为同步方法还可能导致活跃性问题(Liveness)或性能问题(Performance)。

5.活跃性与性能
由于service是一个synchronized方法,因此每次只有一个线程可以执行,这就背离了Servlet框架的初衷,即Servlet需要能同时处理多个请求,在这个负载过高的情况下将给用户带来糟糕的体验。我们将这种Web应用程序称之为不良并发(Poor Concurrency)应用程序。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。
将之前在缓存中获取已经计算好的因数分解程序,修改为使用两个独立的同步代码块,每个同步代码块都只包含一小段代码。其中一个同步代码块负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列,另一个同步代码块则负责确保对缓存的数值和因数分解结果进行同步。位于同步代码块之外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会在多个线程间共享,因此不需要同步。
由于我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,也不会在性能或安全性上带来任何好处,因此在这里不使用原子变量。
当访问状态变量或者在复合操作的执行期间,需要持有锁,但是执行时间较长的因数分解之前要释放锁。这样既确保了线程安全性,也不会过多地影响并发性,而且在每个同步代码中的代码路径都“足够短”。
通常,在简单性与性能之间存在相互制约的因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)。
无论时执行计算密集的操作,还是执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。
当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。

【对象的共享】

在访问共享的可变状态时需要进行正确的管理。上一章主要介绍了如何通过同步来避免多个线程在同一时刻访问相同的数据,而本章主要介绍如何共享和发布对象,从而使它们能够安全地有多个线程同时访问。
同步代码块和同步方法可以确保以原子的方式执行操作,但一个常见的误解是,认为关键字synchronized只能用户实现原子性或者确定“临界区”(Critical Section)。同步还有另一个重要的方面:内存可见性。我们希望确保当一个线程修改了对象状态后,其他的线程能够看到发生的状态变化。

1.可见性
通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要相对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
【重排序】这看上去似乎是个失败的设计,但却能使JVM充分利用现代多核处理器的强大性能。例如,在缺少同步的情况下,JAVA内存模型允许编译器对操作顺序进行重排序,并将数值存在寄存器中。此外,它还允许CPU对操作顺序进行重排序,并将数值缓存在处理器特定的缓存中。
只要有数据在多个线程之间共享,就使用正确的同步。

1.1 失效数据
缺乏同步的程序中可能产生错误的一种情况:失效数据。为了避免失效数据,通过对类中get和set等方法进行同步,可以使类成为一个线程安全的类。仅对set方法进行同步是不够的,调用get的线程仍然会看见失效值。

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

1.3 加锁与可见性
内置锁可以用于确保某个线程以一种可预测的方式查看另一个线程的执行结果。
当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中所有操作结果。如果没有同步,那么久无法实现上述保证。
现在,我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。否则如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效值。
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

1.4 Volatile变量
Java语言提供了一种削弱的同步机制,volatile变量,用来确保变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意这个变量时共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量时一种比synchronized关键字更轻量级的同步机制。
volatile变量对可见性的影响比volatile变量本身更为重要。从内存可见性的角度来看,写入volatile变量相当于同步代码块,而读取volatile变量相当于进入同步代码块。
仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性需要对可见性进行复杂的判断,那么就不要使用volatile变量,volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生。(例如,初始化或关闭)
虽然volatile变量很方便,但也有存在一些局限性。volatile变量通常用做某个操作完成、发生中断或者状态的标志。volatile语义不足以确保递增操作(count++)的原子性,除非你能确保只有一个线程对变量执行写操作。
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
当且仅当满足以下所有条件时,才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者只有单个线程更新变量的值。
- 对变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。

2.发布与逸出
“发布(Public)”一个对象的意思是指,是对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。如果在对象构造完成之前就发布该对象,就会破坏线程安全性。当某个不应该发布的对象被发布后,这种情况就成为逸出(Escape)
发布对象的最简单方法就是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看到该对象。
当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。
当某个对象逸出后,你必须假设有某个类或线程可能会误用该对象,这正是需要使用封装的主要原因:封装能够使得程序的正确性分析变得可能,并使得无意中破坏设计约束条件变得更难。

安全的对象构造过程
当从对象的构造函数中发布对象时,知识发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此,如果this引用在构造函数中逸出,那么这种对象就被认为是不正确构造。
不要在构造过程中使this引用逸出。
在构造构成中使this引用逸出的常见错误是,在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论时显式创建(通过将它传给构造函数)还是隐式创建(由于Thread或Runnable是该对象的一个内部类),this引用都会被创建的线程共享。在对象尚未完全构造之前,新的线程就可以看见它。
如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法(Factory Method),从而避免不正确的构造过程。

3.线程封闭
当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术称为线程封闭,它是实现线程安全性的最简单方式之一。
当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本书不是线程安全的。
线程封闭技术的另一个常见应用是JDBC(Java Database Connectivity)的Connection对象。由于大多数请求(例如Servlet请求或EJB调用等)都是单个线程采用同步的方式来处理,并且在Connection对对象返回之前,连接池不会再将分配给其他线程,因此,这种连接管理模式在处理请求时隐含地将Connection对象封闭在线程中
在Java语言中并没有强制规定某个变量必须有锁来保护,同样在Java语音中也无法强制对象封闭在某个线程中。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类,DNA也即便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出。

3.1 Ad-hoc 线程封闭
Ad-hoc线程封闭是指,维护线程封闭性的职责完全有由程序实现来承担。事实上,对线程封闭对象的引用通常保存在共有变量中。
当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现一个单线程子系统。
在修改操作封闭在一个单个线程中以防止发生竞态条件,并且volatile变量的可见性保证还确保了其他线程能看到最新的值。
由于ad-hoc线程封闭技术的脆弱性,因此在程序中要尽量少用,在可能的情况下,应该使用更强的线程封闭技术(例如,栈封闭或TreadLocal类)
3.2 栈封闭
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。
栈封闭(也被称为线程内部使用或者线程局部使用,不要与核心类库中的ThreadLocal混淆)比Ad-hoc线程封闭更易于维护,也更加健壮。
3.3 ThreadLocal类
**维持线程封闭性的一种更规范方法是使用threadLocal,这个类能使线程中的某个值与保存值的对象关联起来。**ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回当前执行线程在调用set时设置的最新值。
ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。通过将JDBC的连接保存在ThreadLocal对象中,每个线程都会拥有属于自己的连接。
当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时重新分配该临时对象,就可以使用这项技术。例如,在java 5.0之前,Integer.toString()方法使用ThreadLocal对象来保存一个12字节大小的缓存区,用户对结果进行格式化,而不是使用共享的静态缓冲区(这需要使用锁机制)或者在每次调用时都分配一个新的缓冲区。
假设你需要将一个单线程应用程序移植到多线程中,通过将共享的全局变量转换为ThreadLocal对象(如果全局变量的语义允许),可以维护线程安全性。然而,如果将应用程序范围内的缓存转换成线程局部的缓存,就不会有太大作用。
在实现应用程序框架时大量使用了ThreadLocal。例如,在EJB调用期间,J2EE容器需要将一个事物上下文(Transaction Context)与某个执行中的线程关联起来。通过将事物上下文保存在静态的ThreadLocal对象中,可以多容易地实现这个功能:当框架代码需要判断当前运行的是哪一个事物时,只需从这个ThreadLocal对象中读取事务上下文。这种机制很方便,因为它避免了在调用每个方法时都要传递执行上下文信息,然而这也将使用该机制的代码与框架耦合在一起的
ThreadLocal变量类似于全局变量,它能降低代码的可复用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。

4. 不变性
满足同步需求的另一种方法时使用不可变对象(Immutable Object)
如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。
线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。
不可变对象一定是线程安全的。不可变对象很简单,它们只有一种状态,并且该状态由构造函数来控制。
当满足以下条件时,对象才是不可变的:
对象创建以后其状态就不能修改。
对象的所有域都是final类型
对象是正确创建的(在对象的创建期间,this引用没有逸出)

在“不可变的对象”与“不可变的对象引用”之间存在着差异。保存在不可变对象中的程序状态仍然需要更新,即通过将一个保存新状态的实例来“替换”原来的不可变对象。

4.1 Final 域
关键字final可以视为C++中const机制的一种受限版本,用于构造不可变性对象。*final类型的域是不能修改的(但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的)。然而,在Java内存模型中,final域还有着特殊的语义。final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。*
正如“除非需要更高的可见性,否则应将所有的域都声明为私有域”是一个良好的编程习惯,除非需要某个域是可变的,否则将其声明为final域也是一种良好的编程习惯。

4.2 示例:使用Volatile类型来发布不可变对象
每当需要对一组相关数据以原子方式来执行某个操作时,就可以考虑创建一个不可变得类来包含这些数据。
对于在访问和更新多个变量时出现的竞争条件问题,可以通过将这些变量全部保存一个不可变对象中来消除。
通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个volatile类型的引用来确保可见性。

5.安全发布
到目前为止,我们重点讨论的是如何确保对象不被发布,例如让对象封闭在线程或另一个对象的内部。当然,在某些情况下我们希望在多个线程间共享对象,此时必须确保安全地进行共享。将对象引用保存在公有域中,那么还不足以安全地发布这个对象。不正确的发布导致其他线程看到尚未创建完成的对象。

5.1 不正确的发布:正确的对象被破坏
你不能指望一个尚未被完全创建的对象拥有完整性。

5.2 不可变对象与初始化安全性
由于不可变对象是一种非常重要的对象,因此Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证
即使在发布不可变对象的引用时没有使用同步,也仍然可以安全地访问该对象。为了维持这种初始化安全性的保证,必须满足不可变性的所有需求:状态不可修改,所有域都是final类型,以及正确的构造过程。
任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象没有使用同步。
(因为Java内存模型中有特殊的final变量的语义,保证这一过程的顺利进行)
如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。

5.3 安全发布的常用模式

可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。
要安全发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见:一个正确构造的对象可以通过以下方式来安全地发布:
在静态初始化函数中初始化一个对象引用。
将对象的引用保存在volatile类型或者AtomicReferance对象中
将对象的引用保存到某个正确构造对象的final类型中
将对象的应用保存刀一个由锁保护的域中。

通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器,静态初始化器由JVM在类的初始化阶段执行,由于在JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布。

5.4 事实不可变对象
如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象”(Effectively Immutable Object)。”
在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变性对象。

5.5 可变对象
对于可变对象,不仅在发布对象时需要使用同步,而且每次对象访问时同样需要使用同步来确保后续修改操作的可见性。

对象的发布需求取决于它的可变性:
不可变对象可以通过任意机制来发布。
事实不可变对象可以通过安全方式来发布。
可变对象必须通过安全方式来发布,并且必须时线程安全的或者由某个锁保护起来。

5.6 安全地共享对象
当发布一个对象时,必须明确地说明对象的访问方式。
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
保护对象。被保护的对象只能通过持有特定的锁来访问,保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

 【对象的组合】

本章将介绍一些组合模式,这些模式能够使一个类更容易成为线程安全的,并且在维护这些类时不会无意中破坏类的安全性保证。

1.设计线程安全的类
在设计线程安全类的状态,需要包含以下三个基本要素:
找出构成对象状态的所有变量
找到约束状态变量的不变性条件
建立对象状态的并发访问管理策略

同步策略(Synchronized Policy)定义了如何在不违背对象不变性条件或后验条件的情况下对其状态的访问操作进行协同。
同步策略规定了如何将不可变性、线程封闭与加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。

1.1 收集同步需求

要确保类的线程安全性,就需要确保它的不变性条件不会再并发访问的情况下被破坏,这就需要对其状态进行推断。对象和变量都是一个状态空间,即所有可能的取值。状态空间越小,越容易判断线程的状态。
在许多类中定义了一些不可变条件,用户判断状态时有效的还是无效的。同样,在操作中还会包含一些后验条件来判断状态迁移是否是有效的。当下一个状态需要依赖当前状态时,这个操作就必须是一个符合操作。
由于不变性条件以及后验条件在状态和状态转移上施加了各种约束条件,因此就需额外的同步与封装。 包含多个变量的不变性条件将带来原子性需求:这些相关的变量必须在单个原子操作中进行读取或更新。
如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。要满足在状态变量的有效性或状态转换上的各种约束条件,就需要借助于原子性和封装性。

1.2 依赖状态的操作

类的不变性条件与后验条件约束了在对象上有哪些状态和状态装换是有效的。在某些对象的方法中还包含了一些基于状态的先验条件(Precondition).例如,不能从空队列中移除一个元素,在删除元素前,队列必须处于“非空的”状态。如果在某个操作中包含有基于状态的先验条件,那么这操作就称为依赖状态的操作。
在Java中,等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密关联,要想正确地使用它们并不容易。要想实现某个等待先验条件为真时才执行的操作,一个更简单的方法时通过现有库的类(例如阻塞队列blocking Queue 或信号量Semaphore)来实现依赖状态的行为。

1.3 状态的所有权

在定义哪些变量将构成对象的状态时,只考虑对象拥有的数据。无论如何,垃圾回收机制使我们避免了如何处理所有权的问题。
许多情况下,所有权与封装性总是相互关联的:对象封装它拥有的状态,反之也成立,即对它封装的状态拥有所有权。状态变量的所有者将决定采取何种加锁协议来维持变量状态的完整性。所有权意味着控制权。然而,如果发布了某个可变对象的引用,那么久不再拥有独占的控制权,最多是“共享控制权”。
容器类通常表现出一种“所有权分离”的形式,其中容器类拥有其自身的状态,而客户代码则拥有容器中各个对象的状态。
由Servlet容器实现的ServletContext对象必须是线程安全的,因为它肯定会被多个线程同事访问。当调用setAttribute和getAtribute时,Servlet不需要使用同步,但当使用保存在ServletContext中的对象时,则可能需要使用同步。这些对象由应用程序拥有,Servlet容器只是替应用程序保管它们。
为了防止多个线程在并发访问同一个对象时产生的相互干扰,这些对象应该要么是线程安全的对象,要么是事实不可变的对象,或者有锁来保护的对象。

2. 实例封闭

封装简化了线程安全类的实现过程,它提供了一种实例封闭机制(Instance Confinement),通常也简称为“封闭”。
将数据封装在对象内部,可以将数据的访问限制在对象额方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
被封闭对象一定不能超出它们既定的作用域。对象可以封闭类的一个实例(例如作为类的一个私有成员)中,或者封闭在某个作用域内(例如一个局部变量),再或者封闭在线程内(例如在某个线程中对对象从一个方法传递到另一个方法,而不是在多个线程之间共享该对象)。当然,对象本身不会逸出——出现逸出情况的原因通常是由于开发人员在发布对象时超出了对象既定的作用域。
实例封闭是构建线程安全类的一个最简单方式,它还使得在锁策略的选择上拥有更多的灵活性。实例使用内置锁来保护自身状态,但对于其他形式的锁来说,只要自始至终都使用同一个锁,就可以保护状态。实例封闭还使得不同的状态变量可以由不同的锁来保护。
在Java平台的类库中有很多线程封闭的示例,其中有些类的唯一用途就是将非线程安全的类转化为线程安全的类。
封装机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。

从线程封闭原则以其逻辑推论可以得到Java监视器模式。遵循Java监视器模式的对象会把所有可变状态都封装起来,并由对象自己的内置锁来保护。
在许多类中都使用了Java监视器模式,例如Vector和Hashtable
使用私有的锁对象而不是对象的内置锁(或任何其他科通过共有方式访问的锁),有许多优点。私有的锁对象可以将锁封装起来,使客户代码无法得到锁,但客户代码错误地获得了另一个对象的锁,以便(正确或者不正确地)参与到它的同步策略中

3 线程安全性的委托

大多数对象都是组合对象。当从头开始构建一个类,或者将多个非线程安全的类组合为一个类时,Java监视器模式是非常有用的。但是,如果类中的各个组件都已经是线程安全的,会是什么情况呢?我们是否需要再增加一个额外的线程安全层?答案是“视情况而定”。

3.1 示例:基于委托的车辆追踪器
一个实际的委托示例:构造一个委托给线程安全类的车辆跟踪器。车辆位置保存在一个Map对象中,因此首先要实现一个线程安全的Map类,ConcurrentHashMap。

3.2 独立的状态变量

到目前为止,这些委托示例都仅仅委托给了单个线程安全的状态变量。我们还可以将线程安全性委托给多个状态变量,只要这些变量时彼此独立的,即组合而成的类并不会在其包含的多个状态变量上增加任何不变性条件。

比如一个图形组件VisualComponent,允许客户程序注册监控鼠标和键盘等事件的监听器。它是每种类型的事件都备有的一个已注册监听器列表,因此当某个事件发生时,就会调用相应的监听器。然而,在鼠标事件监听器与键盘事件监听器之间就不存在任何关联,二者时彼此独立的。因此VisualComponent可以将线程安全性委托给这两个线程安全的监听器列表。

VisualComponent使用CopyOnWriteArrayList来保存各个监听器列表。它是一个线程安全的链表,特别适合管理监听器列表

3.3 当委托失效时

大多数组合对象都不会像VisualComponent这样:在它们的状态变量之间存在着某些不变性条件。比如使用两个AtomicInteger来管理状态,并且含有一个约束条件,即第一个数值要小于或等于第二个数值。这种约束条件下,因为存在“先检查后执行”的复合操作,所以不是线程安全的,没有维持对下界和上界进行约束的不变性条件
因此,虽然AtomicInteger是线程安全的,但经过组合得到的类却不是。由于状态变量lower和upper不是彼此独立的,因此这种组合对象的情况不能将线程安全性委托给它的线程安全状态变量。
这种含有复合操作的情况,仅靠委托并不足以实现线程安全性。
如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不能包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。

3.4 发布底层的状态变量

当把线程安全性委托给某个对象的底层状态变量时,在什么条件下才可以发布这些变量从而使其他类能修改它们?答案仍然取决于在类中对这些变量施加了哪些不变性的条件。
如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在不允许的状态转换,那么就可以安全性发布这个变量。

4.在现在的线程安全类中添加功能

Java类库包含许多有用的“基础模块”类。通常,我们应该优先选择重用这些现有的类而不是创建新的类:重用能降低开发工作量、开发风险(因为现有的类都已经通过测试)以及维护成本。 有时候,某个现有的线程安全类能支持我们需要的所有操作,但更多时候,现有的类只能支持大部分的操作,此时就需要在不破坏线程安全性的情况下添加一个新的操作。

要添加一个新的原子操作,最安全的地方是修改原始的类,但这通常无法做到,因为你可能无法访问或修改类的源代码。要想修改原始的类,就需要理解代码中的同步策略,这样增加的功能才能与原有的设计保持一致。如果直接将新方法添加到类中,那么意味着实现同步策略的所有代码仍然处于源代码文件中,从而更容易理解和维护。

另一种方法是扩展这个类,假定在设计这个类时考虑了可扩展性。“扩展”方法比直接将代码添加到类中更加脆弱,因为现在的同步策略实现被分布到多个单独维护的源代码文件中。如果底层的类改变了同步策略并选择了不同的锁来保护它的状态变量,那么子类会被破坏,因为在同步策略改变后无法再使用正确的锁来控制对基类状态的并发控制。

4.1 客户端加锁机制

对于由Collection.synchronizedList封装的ArrayList,这两种方法在原始类中添加一个方法或者对类进行扩展是行不通的,因为客户代码并不知道在同步封装器工厂方法中返回的List对象的类型。第三种策略时扩展类的功能,但并不是扩展类本身,而是将扩展代码放入一个“辅助类”中。 但要想使这个方法能正确执行,必须使List在实现客户端加锁或外部加锁时使用同一个锁
客户端加锁是指,对于使用某个对象X的客户端代码,使用X本身用于保护其状态的锁来保护这段客户代码。要使用客户端加锁,你必须知道对象X使用的是哪一个锁。
通过添加一个原子操作来扩展类是脆弱的,因为它将类的加锁代码分布到多个类中。然而,客户端加锁却更加脆弱,因为它将类C的加锁代码放到与C完全无关的其他类中。当在那些并不承诺遵循加锁策略的类上使用客户端加锁时,要特别小心。
客户端加锁机制与扩展类机制有许多共同点,二者都是将派生类的行为与基类的实现耦合在一起,客户端加锁同时也会破坏同步策略的封装性。

4.2 组合
当为所有的类添加一个原子操作时,有一种更好的方法:组合(Composition)。程序实例ImprovedList通过将List对象的操作委托给底层的List实例来实现List的操作,同时还添加了一个原子的putIfAbsent方法(若没有则添加)(与Collections.synchronizedList和其他容器封装器一样,ImprovedList假设把某个链表对象传给构造函数以后,客户代码不会再直接使用,而只能通过ImprovedList来访问它。)

ImprovedList通过自身的内置锁增加了一层额外的加锁。它并不关心底层的List是否是线程安全的,即使List不是线程安全的或者修改了它的加锁实现, ImprovedList也会提供一致的加锁机制来实现线程安全性。虽然额外的同步层可能导致轻微的性能损失,但与模拟另一个对象的加锁策略相比,ImprovedList更为健壮。

5.将同步策略文档化

在文档中说明客户代码需要了解的线程安全性保证,以及代码维护人员需要了解的同步策略。
synchronized、volatile或者任何一个线程安全性都对应于某种同步策略,用于在并发访问时确保数据的完整性。
在设计同步策略时需要考虑多个方面,例如,将那些变量声明为volatile类型,那些变量用锁来保护,那些锁保护那些变量,那些变量必须是不可变的或者被封闭在线程中的,那些操作必须是原子操作等。其中某些方面是严格的实现细节,应该讲它们文档化以便于日后的维护。还有一些方面会影响类中加锁行为的外在表现,也应该将其作为规范的一部分写入文档。最起码,应该保证类中的线程安全性文档化。
在平台的类库中,线程安全性方面的文档也是很难令人满意。当你阅读某个类的javadoc时,是否曾怀疑过它是线程安全的?大多数类都没有给出任何提示。许多正式的Java技术规范,例如servlet和JDBC,也没有再它们的文档中给出线程安全性的保证和需求。
尽管我们不应该对规范之外的行为进行猜测,但有时候处于工作需要,将不得不面对各种糟糕的假设。更糟糕的是,我们得直觉通常是错误的:我们任务“可能是线程安全”的类通常并不是线程安全的。
如果没有某个类没有明确地声明是线程安全的,那么就不要假设它是线程安全的,从而有效地避免类似于SimpleDateFromat的问题,而另一个方面,如果不对容器提供对象(例如HttpSession)的线程安全性做某种问题的假设,也就不可能开发出一个基于Servet的应用程序。不要使你的客户或同事也做这样的猜测。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值