Java并发编程:线程安全性——之先检查后执行

前言

“共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。一个对象是否需要是线程安全,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。

正题

在开始编写文章前,有几个问题需要思考一下:

  • 什么是线程安全
  • 原子性
  • 加锁机制
  • 用锁来保护状态
  • 活跃性与性能

1. 什么是线程安全

在线程安全的定义中,最核心的概念就是正确性。如果对线程安全的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。正确性的含义是:某个类的行为与其规范完全一致。在良好的规范中,通常会定义各种不变性条件来约束对象的状态,以及定义各种后验条件来描述对象操作的结果。由于我们通常不会编写详细的规范,那么如何知道这些类是否正确呢?我们无法知道,但这并不妨碍我们在确信“类的代码能工作”后使用它们。这种“代码可信性”非常接近于我们对正确性的理解,因为我们可以将单线程的正确性近似定义为“所见即所得”。在对“正确性”给出了一个较为清晰的定以后,就可以定义线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的

由于单线程程序也可以看成是一个多线程程序,如果某个类在单线程环境中都不是正确的,那么它肯定不会是线程安全的。如果正确地实现了某个对象,那么在任何操作中(包括调用对象的共有方法或者对其共有域进行读 / 写操作)都不会违背不变性条件或后验条件。在线程安全类的对象实例上执行任何串行或并行操作都不会使对象处于无效状态。在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。

2. 原子性

当我们在无状态对象中增加一个状态时,会出现什么情况?假设我们希望增加一个“命中计数器”(Hit Counter)来统计所处理的请求数量。一种直观的方法是在 Servlet 中增加一个 long 类型的域,并且每处理一个请求就将这个值加 1。


     
     
  1. @NotThreadSafe
  2. public class UnsafeCountingFactorizer extends GenericServlet implements Servlet {
  3. private long count = 0;
  4. public long getCount() {
  5. return count;
  6. }
  7. public void service(ServletRequest req, ServletResponse resp) {
  8. BigInteger i = extractFromRequest(req);
  9. BigInteger[] factors = factor(i);
  10. ++count;
  11. encodeIntoResponse(resp, factors);
  12. }
  13. }

不幸的是,UnsafeCountingFactorizer 并非线程安全的,尽管它在单线程环境中能正确运行。这个类很可能会丢失一些更新操作。虽然递增操作 ++count 是一种紧凑的语法,使其看上去只是一个操作,但这个操作并非原子的,因而它并不会作为一个不可分割的操作来执行。实际上,它包含了三个独立地操作:读取 count 的值,将值加 1,然后将计算结果写入 count。这是一个“读取 - 修改 - 写入”的操作序列,并且其结果状态依赖于之前的状态。

你可能会认为,在基于 Web 的服务器中,命中计数器值的少量偏差或许是可以接受的,在某些情况下也确实如此。但如果该计数器被用来生成数值序列或者唯一的对象标识符,那么在多次调用中返回相同的值将导致严重的数据完整性问题。在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件(Race Condition)。

2.1 竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确的结果要取决于运气。最常见的竞态条件类型就是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步的动作。竞态失效就是大多数竞态条件的本质——基于一种可能失效的观察结果来做出判断或者执行某个计算。这种类型的竞态条件称为“先检查后执行”:首先观察到某个条件为真(例如文件 X 不存在),然后根据这个观察结果采用相应的动作(创建文件 X),但事实上,在你观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另一个线程在这期间创建了文件 X),从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)。

使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行。同时要确保只被初始化一次。


     
     
  1. @NotThreadSafe
  2. public class LazyInitRace {
  3. private ExpensiveObject instance = null;
  4. public ExpensiveObject getInstance() {
  5. if (instance == null)
  6. instance = new ExpensiveObject();
  7. return instance;
  8. }
  9. }
在 LazyInitRace 中包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程 A 和线程 B 同时执行 getInstance。A 看到 instance 为空,因而创建一个新的 ExpensiveObject 实例。B 同样需要判断 instance 是否为空。此时的 instance 是否为空,要取决于不可预测的时序,包括线程的调度方式,以及 A 需要花多长时间来初始化 ExpensiveObject 并设置 instance。如果当 B 检查时,instance 为空,那么在两次调用 getInstance 时可能会得到不同的结果,即使 getInstance 通常被认为是返回相同的实例。

在 UnsafeCountingFactorizer 的统计命中计数操作中存在另一种竞态条件。在“读取 - 修改 - 写入”这种操作(例如递增一个计数器)中,基于对象之前的状态来定义对象状态的转换。要递增一个计数器,你必须知道它之前的值,并确保在执行更新的过程中没有其他线程会修改或使用这个值。

与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行时序。然而,竞态条件也可能导致严重的问题。假定 LazyInitRace 被用于初始化应用程序范围内的注册表,如果在多次调用中返回不同的实例,那么要么会丢失部分注册信息,要么多个行为对同一组注册表对象表现出不一致的视图。如果将 UnsafeSequence 用于在某个持久化框架中生成对象的标识,那么两个不同的对象最终将获得相同的标识,这就违反了标识的完成性约束条件。

2.2 复合操作

LazyInitRace 和 UnsafeCountingFactorizer 都包含一组需要以原子执行(或者说不可分割)的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式阻止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态过程中。

如果 UnsafeSequence 中的递增操作是原子操作,那么竞态条件就不会发生,并且递增操作在每次执行时都会把计数器增加 1。为了确保线程安全性,“先检查后执行”(例如延迟初始化)和“读取 - 修改 - 写入”(例如递增运算)等操作必须是原子的。我们将 “先检查后执行” 以及 “读取 - 修改 - 写入” 等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。


     
     
  1. @ThreadSafe
  2. public class CountingFactorizer extends GenericServlet implements Servlet {
  3. private final AtomicLong count = new AtomicLong( 0);
  4. public long getCount() { return count.get(); }
  5. public void service(ServletRequest req, ServletResponse resp) {
  6. BigInteger i = extractFromRequest(req);
  7. BigInteger[] factors = factor(i);
  8. count.incrementAndGet();
  9. encodeIntoResponse(resp, factors);
  10. }
  11. }
在 java.util.concurrent.atomic 包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用 AtomicLong 来代替 long 类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。由于 Servlet 的状态就是计数器的状态,并且计数器是线程安全的,因此这里的Servlet 也是线程安全的。

我们在因数分解的 Servlet 中增加了一个计数器,并通过使用线程安全类 AtomicLong 来管理计数器的状态,从而确保了代码的线程安全。当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。然而,当状态变量的数量有一个变为多个时,并不会像状态变量数量由零个变为一个那么简单。

在实际情况中,应尽可能地使用现有的线程安全对象(例如 AtomicLong)来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全。

3. 加锁机制

要保证状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

3.1 内置锁

Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字 synchronized 来修饰的方法就是一种横跨整个方法的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的 synchronized 方法以 Class 对象作为锁。


     
     
  1. synchronized (lock) {
  2. //访问或修改由锁保护的共享状态
  3. }

每个 Java 对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获取锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是自动从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。

Java 的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程 A 尝试获取一个由线程 B 持有的锁时,线程 A 必须等待或阻塞,直到线程 B 释放这个锁。如果 B 永远不释放锁,那么 A 也将永远地等待下去。

由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义—— 一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到其他线程正在执行由同一个锁保护的代码块。

这种同步机制使得要确保因数分解 Servlet 的线程安全性变得更简单。如下代码使用了关键字 synchronized 来修饰 service 方法,因此在同一时刻只有一个线程可以执行 Service方法。现在的 SynchronizedFactorizer 是线程安全的。然而,这种方法却过于极端,因为多个客户端无法同时使用因数分解 Servlet,服务的响应性非常低,无法令人接收。这是一个性能问题,而不是线程安全问题。


     
     
  1. @ThreadSafe
  2. public class SynchronizedFactorizer extends GenericServlet implements Servlet {
  3. @GuardedBy( "this") private BigInteger lastNumber;
  4. @GuardedBy( "this") private BigInteger[] lastFactors;
  5. public synchronized void service(ServletRequest req,
  6. ServletResponse resp) {
  7. BigInteger i = extractFromRequest(req);
  8. if (i.equals(lastNumber))
  9. encodeIntoResponse(resp, lastFactors);
  10. else {
  11. BigInteger[] factors = factor(i);
  12. lastNumber = i;
  13. lastFactors = factors;
  14. encodeIntoResponse(resp, factors);
  15. }
  16. }
3.2 重入

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。重入的一种实现方法是:为每个锁关联一个获取计数器值和一个所有者线程。当计数值为 0 时,这个锁就被认为是没有被任何线程持有的。当线程请求一个未被持有的锁时,JVM 将会记下锁的持有者,并且将获取计数值置为 1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会响应的递减。当计数值为 0 时,这个锁将被释放。

重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。在下面代码清单中,子类改写了父类的 synchronized 方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。由于 Widget 和 LoggingWidget 中的 doSomething 方法都是 synchronized 方法,因此每个 doSomething 方法在执行前都会获取 Widget 上的锁。然而,如果内置锁不是可重入的,那么在调用 super.doSomething 时将无法获取 Widget 上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远无法获得的锁,重入则避免了这个死锁情况的发生。


     
     
  1. public class Widget {
  2. public synchronized void doSomething() {}
  3. }
  4. public class LoggingWidget extends Widget {
  5. public synchronized void doSomething() {
  6. super.doSomething();
  7. }
  8. }

4. 用锁来保护状态

由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。访问共享状态的复合操作,例如命中计数器的递增操作(读取 - 修改 - 写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁

  • 一种常见的错误是认为:只有在写入共享变量时才需要使用同步,然而事实并非如此。
  • 对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。

 对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。在获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。你需要自行构造加锁协议或者同步策略来实现共享状态的安全访问,并且在程序中自始至终地使用它们。每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

 一种常见的加锁约定是,将所有的可变状态都封装到对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全类中都使用了这种模式,例如 Vector 和其他的同步集合类。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。然而,这种模式并没有任何特殊之处,编译器或运行时都不会强制实施这种(或者其他的)模式。如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议很容易被破坏。

并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。当添加一个简单的异步事件时,例如 TimerTask,整个程序都需要满足线程安全性要求,尤其是当程序状态的封装性比较槽糕时。考虑一个处理大规模数据的单线程程序,由于任何数据都不会在多个线程之间共享,因此在单线程程序中不需要同步。现在,假设希望添加一个新的功能,即定期地对数据处理进度生成快照,这样当程序崩溃或者必须停止时无须再次从头开始。你可能会选择使用 TimeTask,没十分钟触发一次,并将程序状态保存到一个文件中。

由于 TimeTask 在另一个(由 Timer 管理的)线程中调用,因此现在就有两个线程同时访问快照中的数据:程序的主线程与 Timer 线程。这意味着,当访问程序的状态时,不仅 TimerTask 代码必须使用同步,而且程序中所有访问相同数据的代码路径也必须使用同步。原本在程序中不需要使用同步,现在变成了在程序的各个位置都需要使用同步。

当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在某一同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。因此可以在单个原子操作中访问或更新这些变量,从而确保不变性条件不被破坏。在 SynchronizedFactorizer 类中说明了这条规则:缓存的数值和因数分解结果都由 Servlet 对象的内置锁来保护。

  • 对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

如果同步可以避免竞态条问题,那么为什么不在每个方法声明时都使用关键字 synchronized?事实上,如果不加区别的滥用 synchronized,可能导致程序中出现过多的同步。此外,如果只是将每个方法都作为同步方法,例如 Vector,那么并不足以确保 Vector 上复合操作都是原子的:


     
     
  1. if(!victor.contains(element)) {
  2. victor.add(element);
  3. }

虽然 contains 和 add 等方法都是原子方法,但在上面这个“如果不存在则添加(put - if - absent)”的操作中仍然存在竞态条件。虽然 synchronized 方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还需要额外的加锁机制。此外,将每个方法都作为同步方法还可能导致活跃性问题或性能问题。

5. 活跃性与性能

在 UnsafeCachingFactorizer 中,我们通过因数分解 Servlet 中引入缓存机制来提升性能。在缓存中需要使用共享状态,因此需要通过同步来维护状态的完整性。然而,如果使用 synchronizedFactorizer 中的同步方式,那么代码的执行性能将非常槽糕。SynchronizedFactorizer 中采用的同步策略是,通过 Servlet 对象的内置锁来保护每一个状态,该策略的实现方式也就是对整个  service 方法进行同步。虽然这种简单且粗粒度的方法能确保线程安全性,但付出的代价却很高。

由于 service 是一个 synchronized 方法,因此每次只有一个线程可以执行。这就背离了 Servlet 框架的初衷,即 Servlet 需要能同时处理多个请求,这在负载过高的情况下将给用户带来糟糕的体验。如果 Servlet 处理完当前的请求,才能开始另一个新的因数分解运算。如果客户端必须一直等待,直到 Servlet 处理完当前的请求,才能开始另一个新的因数分解。如果在系统中有多个 CPU 系统,那么当负载很高时,仍然会有处理器处于空闲状态。即使一些执行时间很短的请求,比如访问缓存的值,仍然需要很长时间,因为这些请求都必须等待前一个请求执行完成。

下图给出了当多个请求同时到达因数分解 Servlet 时发生的情况:这些请求将排队等待处理。我们将这种 Web 应用程序称之为不良并发(Poor Concurrency)应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。幸运的是,通过缩小同步代码块的作用,我们很容易做到既确保 Servlet 的并发性,同时又维护线程安全性。要确保同步代码块不要过小,并且不要将本应原子的操作拆分到多个同步代码块中应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。


在下面程序清单中的 CachedFactorizer 将 Servlet 的代码修改为使用两个独立地同步代码块,每个同步代码块都只包含一小段代码。其中一个同步代码块负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列,另一个同步代码块则负责确保对缓存的数值和因数分解结果进行同步更新。此外,我们还重新引入了“命中计数器”,添加了一个“缓存命中”计数器,并在第一个同步代码块中更新这两个变量。由于这两个计数器也是共享可变状态的一部分,因此必须在所有访问它们的位置上都使用同步。位于同步代码快之外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会再多个线程间共享,因此不需要同步。


     
     
  1. public class CachedFactorizer extends GenericServlet implements Servlet {
  2. @GuardedBy("this") private BigInteger lastNumber;
  3. @GuardedBy("this") private BigInteger[] lastFactors;
  4. @GuardedBy("this") private long hits;
  5. @GuardedBy("this") private long cacheHits;
  6. public synchronized long getHits() {
  7. return hits;
  8. }
  9. public synchronized double getCacheHitRatio() {
  10. return (double) cacheHits / (double) hits;
  11. }
  12. public void service(ServletRequest req, ServletResponse resp) {
  13. BigInteger i = extractFromRequest(req);
  14. BigInteger[] factors = null;
  15. synchronized (this) {
  16. ++hits;
  17. if (i.equals(lastNumber)) {
  18. ++cacheHits;
  19. factors = lastFactors.clone();
  20. }
  21. }
  22. if (factors == null) {
  23. factors = factor(i);
  24. synchronized (this) {
  25. lastNumber = i;
  26. lastFactors = factors.clone();
  27. }
  28. }
  29. encodeIntoResponse(resp, factors);
  30. }
  31. void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
  32. }
  33. BigInteger extractFromRequest(ServletRequest req) {
  34. return new BigInteger("7");
  35. }
  36. BigInteger[] factor(BigInteger i) {
  37. return new BigInteger[]{i};
  38. }
  39. }

在 CachedFactorizer 中不再使用 AtomicLong 类型的命中计数器,而是使用了一个 long 类型的变量。当然也可以使用 AtomicLong 类型,但使用 CountingFactorizer 带来的好处更多。对在单个变量上实现原子操作来说,原子变量是很有用的,但由于我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,也不会在性能或安全性上带来任何好处,因此在这里不使用原子变量。

重新构造后的 CachedFactorizer 实现了简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)之间平衡。在获取与释放锁等操作上都需要一定的开销,因此如果将同步代码块分解得过细(例如将 ++this 分解到它自己的同步代码块中),那么通常并不好,尽管这样做不会破坏原子性。当访问状态变狼或者在复合操作的执行期间,CacheFactorizer 需要持有锁,但在执行时间较长的因数分解运算之前要释放锁。这里即确保了线程安全,也不会过多的影响并发性,而且在每个同步代码块中的代码路径都“足够短”。

要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这个需求必须得到满足)、简单性和性能。有时候,在简单性与性能之间会发生冲突,但在 CachedFactorizer 中已经说明了,在二者之间通常能找到某种合适的平衡。

通常,在简单性与性能之间存在着相互制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)。

当使用锁时,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。

当执行时间较长的计算或者可能无法快速完成的操作时(例如,网路 I/O 或控制台 I/O),一定不要持有锁。

转载:https://blog.csdn.net/c_wangbin/article/details/77834409

前言

“共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。一个对象是否需要是线程安全,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。

正题

在开始编写文章前,有几个问题需要思考一下:

  • 什么是线程安全
  • 原子性
  • 加锁机制
  • 用锁来保护状态
  • 活跃性与性能

1. 什么是线程安全

在线程安全的定义中,最核心的概念就是正确性。如果对线程安全的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。正确性的含义是:某个类的行为与其规范完全一致。在良好的规范中,通常会定义各种不变性条件来约束对象的状态,以及定义各种后验条件来描述对象操作的结果。由于我们通常不会编写详细的规范,那么如何知道这些类是否正确呢?我们无法知道,但这并不妨碍我们在确信“类的代码能工作”后使用它们。这种“代码可信性”非常接近于我们对正确性的理解,因为我们可以将单线程的正确性近似定义为“所见即所得”。在对“正确性”给出了一个较为清晰的定以后,就可以定义线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的

由于单线程程序也可以看成是一个多线程程序,如果某个类在单线程环境中都不是正确的,那么它肯定不会是线程安全的。如果正确地实现了某个对象,那么在任何操作中(包括调用对象的共有方法或者对其共有域进行读 / 写操作)都不会违背不变性条件或后验条件。在线程安全类的对象实例上执行任何串行或并行操作都不会使对象处于无效状态。在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。

2. 原子性

当我们在无状态对象中增加一个状态时,会出现什么情况?假设我们希望增加一个“命中计数器”(Hit Counter)来统计所处理的请求数量。一种直观的方法是在 Servlet 中增加一个 long 类型的域,并且每处理一个请求就将这个值加 1。


  
  
  1. @NotThreadSafe
  2. public class UnsafeCountingFactorizer extends GenericServlet implements Servlet {
  3. private long count = 0;
  4. public long getCount() {
  5. return count;
  6. }
  7. public void service(ServletRequest req, ServletResponse resp) {
  8. BigInteger i = extractFromRequest(req);
  9. BigInteger[] factors = factor(i);
  10. ++count;
  11. encodeIntoResponse(resp, factors);
  12. }
  13. }

不幸的是,UnsafeCountingFactorizer 并非线程安全的,尽管它在单线程环境中能正确运行。这个类很可能会丢失一些更新操作。虽然递增操作 ++count 是一种紧凑的语法,使其看上去只是一个操作,但这个操作并非原子的,因而它并不会作为一个不可分割的操作来执行。实际上,它包含了三个独立地操作:读取 count 的值,将值加 1,然后将计算结果写入 count。这是一个“读取 - 修改 - 写入”的操作序列,并且其结果状态依赖于之前的状态。

你可能会认为,在基于 Web 的服务器中,命中计数器值的少量偏差或许是可以接受的,在某些情况下也确实如此。但如果该计数器被用来生成数值序列或者唯一的对象标识符,那么在多次调用中返回相同的值将导致严重的数据完整性问题。在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件(Race Condition)。

2.1 竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确的结果要取决于运气。最常见的竞态条件类型就是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步的动作。竞态失效就是大多数竞态条件的本质——基于一种可能失效的观察结果来做出判断或者执行某个计算。这种类型的竞态条件称为“先检查后执行”:首先观察到某个条件为真(例如文件 X 不存在),然后根据这个观察结果采用相应的动作(创建文件 X),但事实上,在你观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另一个线程在这期间创建了文件 X),从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)。

使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行。同时要确保只被初始化一次。


  
  
  1. @NotThreadSafe
  2. public class LazyInitRace {
  3. private ExpensiveObject instance = null;
  4. public ExpensiveObject getInstance() {
  5. if (instance == null)
  6. instance = new ExpensiveObject();
  7. return instance;
  8. }
  9. }
在 LazyInitRace 中包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程 A 和线程 B 同时执行 getInstance。A 看到 instance 为空,因而创建一个新的 ExpensiveObject 实例。B 同样需要判断 instance 是否为空。此时的 instance 是否为空,要取决于不可预测的时序,包括线程的调度方式,以及 A 需要花多长时间来初始化 ExpensiveObject 并设置 instance。如果当 B 检查时,instance 为空,那么在两次调用 getInstance 时可能会得到不同的结果,即使 getInstance 通常被认为是返回相同的实例。

在 UnsafeCountingFactorizer 的统计命中计数操作中存在另一种竞态条件。在“读取 - 修改 - 写入”这种操作(例如递增一个计数器)中,基于对象之前的状态来定义对象状态的转换。要递增一个计数器,你必须知道它之前的值,并确保在执行更新的过程中没有其他线程会修改或使用这个值。

与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行时序。然而,竞态条件也可能导致严重的问题。假定 LazyInitRace 被用于初始化应用程序范围内的注册表,如果在多次调用中返回不同的实例,那么要么会丢失部分注册信息,要么多个行为对同一组注册表对象表现出不一致的视图。如果将 UnsafeSequence 用于在某个持久化框架中生成对象的标识,那么两个不同的对象最终将获得相同的标识,这就违反了标识的完成性约束条件。

2.2 复合操作

LazyInitRace 和 UnsafeCountingFactorizer 都包含一组需要以原子执行(或者说不可分割)的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式阻止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态过程中。

如果 UnsafeSequence 中的递增操作是原子操作,那么竞态条件就不会发生,并且递增操作在每次执行时都会把计数器增加 1。为了确保线程安全性,“先检查后执行”(例如延迟初始化)和“读取 - 修改 - 写入”(例如递增运算)等操作必须是原子的。我们将 “先检查后执行” 以及 “读取 - 修改 - 写入” 等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。


  
  
  1. @ThreadSafe
  2. public class CountingFactorizer extends GenericServlet implements Servlet {
  3. private final AtomicLong count = new AtomicLong( 0);
  4. public long getCount() { return count.get(); }
  5. public void service(ServletRequest req, ServletResponse resp) {
  6. BigInteger i = extractFromRequest(req);
  7. BigInteger[] factors = factor(i);
  8. count.incrementAndGet();
  9. encodeIntoResponse(resp, factors);
  10. }
  11. }
在 java.util.concurrent.atomic 包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用 AtomicLong 来代替 long 类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。由于 Servlet 的状态就是计数器的状态,并且计数器是线程安全的,因此这里的Servlet 也是线程安全的。

我们在因数分解的 Servlet 中增加了一个计数器,并通过使用线程安全类 AtomicLong 来管理计数器的状态,从而确保了代码的线程安全。当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。然而,当状态变量的数量有一个变为多个时,并不会像状态变量数量由零个变为一个那么简单。

在实际情况中,应尽可能地使用现有的线程安全对象(例如 AtomicLong)来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全。

3. 加锁机制

要保证状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

3.1 内置锁

Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字 synchronized 来修饰的方法就是一种横跨整个方法的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的 synchronized 方法以 Class 对象作为锁。


  
  
  1. synchronized (lock) {
  2. //访问或修改由锁保护的共享状态
  3. }

每个 Java 对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获取锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是自动从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。

Java 的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程 A 尝试获取一个由线程 B 持有的锁时,线程 A 必须等待或阻塞,直到线程 B 释放这个锁。如果 B 永远不释放锁,那么 A 也将永远地等待下去。

由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义—— 一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到其他线程正在执行由同一个锁保护的代码块。

这种同步机制使得要确保因数分解 Servlet 的线程安全性变得更简单。如下代码使用了关键字 synchronized 来修饰 service 方法,因此在同一时刻只有一个线程可以执行 Service方法。现在的 SynchronizedFactorizer 是线程安全的。然而,这种方法却过于极端,因为多个客户端无法同时使用因数分解 Servlet,服务的响应性非常低,无法令人接收。这是一个性能问题,而不是线程安全问题。


  
  
  1. @ThreadSafe
  2. public class SynchronizedFactorizer extends GenericServlet implements Servlet {
  3. @GuardedBy( "this") private BigInteger lastNumber;
  4. @GuardedBy( "this") private BigInteger[] lastFactors;
  5. public synchronized void service(ServletRequest req,
  6. ServletResponse resp) {
  7. BigInteger i = extractFromRequest(req);
  8. if (i.equals(lastNumber))
  9. encodeIntoResponse(resp, lastFactors);
  10. else {
  11. BigInteger[] factors = factor(i);
  12. lastNumber = i;
  13. lastFactors = factors;
  14. encodeIntoResponse(resp, factors);
  15. }
  16. }
3.2 重入

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。重入的一种实现方法是:为每个锁关联一个获取计数器值和一个所有者线程。当计数值为 0 时,这个锁就被认为是没有被任何线程持有的。当线程请求一个未被持有的锁时,JVM 将会记下锁的持有者,并且将获取计数值置为 1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会响应的递减。当计数值为 0 时,这个锁将被释放。

重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。在下面代码清单中,子类改写了父类的 synchronized 方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。由于 Widget 和 LoggingWidget 中的 doSomething 方法都是 synchronized 方法,因此每个 doSomething 方法在执行前都会获取 Widget 上的锁。然而,如果内置锁不是可重入的,那么在调用 super.doSomething 时将无法获取 Widget 上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远无法获得的锁,重入则避免了这个死锁情况的发生。


  
  
  1. public class Widget {
  2. public synchronized void doSomething() {}
  3. }
  4. public class LoggingWidget extends Widget {
  5. public synchronized void doSomething() {
  6. super.doSomething();
  7. }
  8. }

4. 用锁来保护状态

由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。访问共享状态的复合操作,例如命中计数器的递增操作(读取 - 修改 - 写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁

  • 一种常见的错误是认为:只有在写入共享变量时才需要使用同步,然而事实并非如此。
  • 对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。

 对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。在获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。你需要自行构造加锁协议或者同步策略来实现共享状态的安全访问,并且在程序中自始至终地使用它们。每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

 一种常见的加锁约定是,将所有的可变状态都封装到对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全类中都使用了这种模式,例如 Vector 和其他的同步集合类。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。然而,这种模式并没有任何特殊之处,编译器或运行时都不会强制实施这种(或者其他的)模式。如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议很容易被破坏。

并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。当添加一个简单的异步事件时,例如 TimerTask,整个程序都需要满足线程安全性要求,尤其是当程序状态的封装性比较槽糕时。考虑一个处理大规模数据的单线程程序,由于任何数据都不会在多个线程之间共享,因此在单线程程序中不需要同步。现在,假设希望添加一个新的功能,即定期地对数据处理进度生成快照,这样当程序崩溃或者必须停止时无须再次从头开始。你可能会选择使用 TimeTask,没十分钟触发一次,并将程序状态保存到一个文件中。

由于 TimeTask 在另一个(由 Timer 管理的)线程中调用,因此现在就有两个线程同时访问快照中的数据:程序的主线程与 Timer 线程。这意味着,当访问程序的状态时,不仅 TimerTask 代码必须使用同步,而且程序中所有访问相同数据的代码路径也必须使用同步。原本在程序中不需要使用同步,现在变成了在程序的各个位置都需要使用同步。

当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在某一同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。因此可以在单个原子操作中访问或更新这些变量,从而确保不变性条件不被破坏。在 SynchronizedFactorizer 类中说明了这条规则:缓存的数值和因数分解结果都由 Servlet 对象的内置锁来保护。

  • 对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

如果同步可以避免竞态条问题,那么为什么不在每个方法声明时都使用关键字 synchronized?事实上,如果不加区别的滥用 synchronized,可能导致程序中出现过多的同步。此外,如果只是将每个方法都作为同步方法,例如 Vector,那么并不足以确保 Vector 上复合操作都是原子的:


  
  
  1. if(!victor.contains(element)) {
  2. victor.add(element);
  3. }

虽然 contains 和 add 等方法都是原子方法,但在上面这个“如果不存在则添加(put - if - absent)”的操作中仍然存在竞态条件。虽然 synchronized 方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还需要额外的加锁机制。此外,将每个方法都作为同步方法还可能导致活跃性问题或性能问题。

5. 活跃性与性能

在 UnsafeCachingFactorizer 中,我们通过因数分解 Servlet 中引入缓存机制来提升性能。在缓存中需要使用共享状态,因此需要通过同步来维护状态的完整性。然而,如果使用 synchronizedFactorizer 中的同步方式,那么代码的执行性能将非常槽糕。SynchronizedFactorizer 中采用的同步策略是,通过 Servlet 对象的内置锁来保护每一个状态,该策略的实现方式也就是对整个  service 方法进行同步。虽然这种简单且粗粒度的方法能确保线程安全性,但付出的代价却很高。

由于 service 是一个 synchronized 方法,因此每次只有一个线程可以执行。这就背离了 Servlet 框架的初衷,即 Servlet 需要能同时处理多个请求,这在负载过高的情况下将给用户带来糟糕的体验。如果 Servlet 处理完当前的请求,才能开始另一个新的因数分解运算。如果客户端必须一直等待,直到 Servlet 处理完当前的请求,才能开始另一个新的因数分解。如果在系统中有多个 CPU 系统,那么当负载很高时,仍然会有处理器处于空闲状态。即使一些执行时间很短的请求,比如访问缓存的值,仍然需要很长时间,因为这些请求都必须等待前一个请求执行完成。

下图给出了当多个请求同时到达因数分解 Servlet 时发生的情况:这些请求将排队等待处理。我们将这种 Web 应用程序称之为不良并发(Poor Concurrency)应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。幸运的是,通过缩小同步代码块的作用,我们很容易做到既确保 Servlet 的并发性,同时又维护线程安全性。要确保同步代码块不要过小,并且不要将本应原子的操作拆分到多个同步代码块中应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。


在下面程序清单中的 CachedFactorizer 将 Servlet 的代码修改为使用两个独立地同步代码块,每个同步代码块都只包含一小段代码。其中一个同步代码块负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列,另一个同步代码块则负责确保对缓存的数值和因数分解结果进行同步更新。此外,我们还重新引入了“命中计数器”,添加了一个“缓存命中”计数器,并在第一个同步代码块中更新这两个变量。由于这两个计数器也是共享可变状态的一部分,因此必须在所有访问它们的位置上都使用同步。位于同步代码快之外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会再多个线程间共享,因此不需要同步。


  
  
  1. public class CachedFactorizer extends GenericServlet implements Servlet {
  2. @GuardedBy("this") private BigInteger lastNumber;
  3. @GuardedBy("this") private BigInteger[] lastFactors;
  4. @GuardedBy("this") private long hits;
  5. @GuardedBy("this") private long cacheHits;
  6. public synchronized long getHits() {
  7. return hits;
  8. }
  9. public synchronized double getCacheHitRatio() {
  10. return (double) cacheHits / (double) hits;
  11. }
  12. public void service(ServletRequest req, ServletResponse resp) {
  13. BigInteger i = extractFromRequest(req);
  14. BigInteger[] factors = null;
  15. synchronized (this) {
  16. ++hits;
  17. if (i.equals(lastNumber)) {
  18. ++cacheHits;
  19. factors = lastFactors.clone();
  20. }
  21. }
  22. if (factors == null) {
  23. factors = factor(i);
  24. synchronized (this) {
  25. lastNumber = i;
  26. lastFactors = factors.clone();
  27. }
  28. }
  29. encodeIntoResponse(resp, factors);
  30. }
  31. void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
  32. }
  33. BigInteger extractFromRequest(ServletRequest req) {
  34. return new BigInteger("7");
  35. }
  36. BigInteger[] factor(BigInteger i) {
  37. return new BigInteger[]{i};
  38. }
  39. }

在 CachedFactorizer 中不再使用 AtomicLong 类型的命中计数器,而是使用了一个 long 类型的变量。当然也可以使用 AtomicLong 类型,但使用 CountingFactorizer 带来的好处更多。对在单个变量上实现原子操作来说,原子变量是很有用的,但由于我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,也不会在性能或安全性上带来任何好处,因此在这里不使用原子变量。

重新构造后的 CachedFactorizer 实现了简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)之间平衡。在获取与释放锁等操作上都需要一定的开销,因此如果将同步代码块分解得过细(例如将 ++this 分解到它自己的同步代码块中),那么通常并不好,尽管这样做不会破坏原子性。当访问状态变狼或者在复合操作的执行期间,CacheFactorizer 需要持有锁,但在执行时间较长的因数分解运算之前要释放锁。这里即确保了线程安全,也不会过多的影响并发性,而且在每个同步代码块中的代码路径都“足够短”。

要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这个需求必须得到满足)、简单性和性能。有时候,在简单性与性能之间会发生冲突,但在 CachedFactorizer 中已经说明了,在二者之间通常能找到某种合适的平衡。

通常,在简单性与性能之间存在着相互制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)。

当使用锁时,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。

当执行时间较长的计算或者可能无法快速完成的操作时(例如,网路 I/O 或控制台 I/O),一定不要持有锁。

猜你喜欢

转载自blog.csdn.net/q1054261752/article/details/88371757
今日推荐