22 第2章线程安全 LazyInitRace中的竞争条件会破坏其正确性。比如说线程A和B同时执行 getInstance,A看到instance是null,并实例化一个新的ExpensiveObject。同时B 也在检查instance是否为null。此时此刻的instance是否为null,这依赖于时序, 这是无法预期的。它包括调度的无常性,以及A初始化ExpensiveObject并设置instance 域的耗时。如果B检查到instance为null,两个getInstance的调用者会得到不同的 结果。然而,我们期望getInstance总是返回相同的实例。 UnsafeCountingFactorizer中的命中计数操作中还存在另一种竞争条件“读-改-写” 操作,比如递增计数器,它按照对象先前的状态来定义对象的状态转换。递增一个计数器, 你必须要知道先前值,并且要确保你在更新的过程中,没有其他线程改变或使用计数器的 值。 像大多数并发错误一样,竞争条件并不总是导致失败:还需要某些特殊的分时。但是 竞争条件会引起严重的问题。如果LazyInitRace用于实例化一个应用级的注册器,让它 在多次调用中返回不同的实例,会引起注册信息的丢失,或者多个活动得到不一致的已注 册对象集合的视图。如果UnsafeSequence用于为持久性框架生成实体标识符,两个对象 会由于相同的D而消亡,因为它们破坏了标识符的完整性约束。 2.2.3复合操作 LazyInitRace和UnsafeCountingFactorizer都包含一系列操作,相对于在同一状 态下的其他操作而言,必须是原子性的或不可分割的。为了避免竞争条件,必须阻止其他 线程访问我们正在修改的变量,让我们可以确保:当其他线程想要查看或修改一个状态时, 必须在我们的线程开始之前或者完成之后,而不能在操作过程中。 假设有操作A和B,如果从执行A的线程的角度看,当其他线程执行B时,要 么B全部执行完成,要么一点都没有执行,这样A和B互为原子操作。一个原子操 作是指:该操作对于所有的操作,包括它自己,都满足前面描述的状态。 如果UnsafeSequence中的自增是原子操作,那么就不会发生第6页图1.1所阐释的 竞争条件。每次执行自增,都会产生预期的结果,即计数器准确地加1。为了确保线程安 全,“检查再运行”操作(如惰性初始化)和读改写操作(如自增)必须是原子操作。 我们将“检查再运行”和读-改写操作的全部执行过程看作是复合操作:为了保证线程安 全,操作必须原子地执行。我们会在下一节考虑用Java内置的原子性机制锁。现在, Java并发编程实践
2.3锁 23 我们先用其他方法修复这个问题一使用已有的线程安全类,如清单2.4的Counting- Factorizer所示。 清单2.4 Servlet使用AtomicLong统计请求数 @Threadsafe public class CountingFactorizer implements Servlet private final AtomicLong count new AtomicLong(0); public long getCount()(return count.get();} public void service(ServletRequest req,ServletResponse resp)( BigInteger i=extractFromRequest(req); BigInteger[]factors factor(i); count.incrementAndGet(); encodeIntoResponse(resp,factors); java.util.concurrent.atomic包中包括了原子变量(atomic variable)类,这些类 用来实现数字和对象引用的原子状态转换。把long类型的计数器替换为AtomicLong类 型的,我们可以确保所有访问计数器状态的操作都是原子的5。计数器是线程安全的了, 而计数器的状态就是Servlet的状态,所以我们的Servlet再次成为线程安全的了。 我们可以向Factoring Servlet中加入一个计数器,并利用已有的线程安全类 (AtomicLong)管理计数器的状态,维护Servlet的线程安全性。当只向无状态类中加入 唯一的状态元素,而这个状态完全被线程安全的对象所管理,那么新的类仍然是线程安全 的。但是,正如我们在下一节所见的,状态的数量从一个增加到多个的情况,远远不像从 0个增加到1个这么简单。 利用像AtomicLong这样已有的线程安全对象管理类的状态是非常实用的。相 比于非线程安全对象,判断一个线程安全对象的可能状态和状态的转换要容易得多。 这简化了维护和验证线程安全性的工作。 2.3锁 通过使用线程安全对象来管理Servlet的全部状态,可以维护Servlet的线程安全性, 这样我们只能在Servlet中加入一个状态变量。但是我们如果想加入更多的状态,可以仅 5 CountingFactorizeri调用incrementAndGet不但使计数值递增,同时还会返回递增的结 果;不过这里忽略了返回值。 Java并发编程实践
24 第2章线程安全 仅加入更多的线程安全的状态变量吗? 想象下面的情形:我们缓存最新的计算结果,以应对两个连续的客户请求相同的数字 进行因数分解,希望由此提高Servlet的性能。(这未必是一个有效的缓存策略:在5.6节 我们会提供一个更好的。)要实现这个策略,我们需要记住两件事:最新请求的数字和它 的因数。 我们在前面曾经是用AtomicLong,以线程安全的方式管理计数量的状态:我们还可 以使用同系的AtomicReference6类型管理缓存的数字和它的因数吗?清单2.5中的 UnsafeCachingFactorizer作了这种尝试: 清单2.5没有正确原子化的Servlet试图缓存它的最新结果(不要这样徽) @NotThreadSafe public class UnsafeCachingFactorizer implements Servlet private final AtomicReference<BigInteger>lastNumber new AtomicReference<BigInteger>(); private final AtomicReference<BigInteger[]>lastPactora new AtomicReference<BigInteger[]>(); public void service(ServletRequest req,ServletResponse resp){ BigInteger i extractFromRequest(req); if (i.equals (lastNumber.get ()) encodeIntoResponse(resp,lastFactors.get()); else BigInteger[]factors factor(i); lastNumber.set(i); lastPactors.set(factors); encodeIntoResponse(resp,factors); 很不幸,这种方法并不正确。尽管原子引用(atomic references)自身是线程安全的, 不过UnsafeCachingFactorizer中存在竞争条件,导致它会产生错误的答案。 线程安全性的定义要求无论是多线程中的时序或交替操作,都要保证不破坏那些不变 约束。UnsafeCachingFactorizer的一个不变约束是:缓存在lastFactors中的各个因 子的乘积应该等于缓存在1 astNumber中的数值。只有遵守这个不变约束,我们的Servlet 才是正确的。当一个不变约束涉及多个变量时,变量间不是彼此激立的:某个变量的值会 6正如AtomicLongz是long和integer的线程安全的holder类,AtomicReference是对象引用 的线程安全holder类。原子变量(Atomic variable)以及它们的好处将在第15章介绍。 Java并发编程实践
2.3锁 25 制约其他几个变量的值。因此,更新一个变量的时候,要在同一原子操作中更新其他几个。 在一些特殊的时序中,UnsafeCachingFactorizer可能破坏这一不变约束。即使是 用原子引用,并且每个set调用都是原子的,我们也无法保证会同时更新1 lastNumber和 lastFactors:当某个线程只修改了一个变量而另一个还没有开始修改时,其他线程将看 到Servlet违反了不变约束,这样会形成一个程序漏洞。类似地,也不能保证每个线程都 会同时获得两个值:当线程A尝试获取两个值的时间里,线程B可能已经修改了它们,线 程A过后会观察到Servlet违反了不变约束。 为了保护状态的一致性,要在单一的原子操作中更新相互关联的状态变量。 2.3.1内部锁 Java提供了强制原子性的内置锁机制:synchronized块。(第3章将介绍锁和同步 机制的另一个重要方面:可见性)一个synchronized块有两部分:锁对象的引用,以及 这个锁保护的代码块。synchronized方法是对跨越了整个方法体的synchronized块的 简短描述,至于synchronized方法的锁,就是该方法所在的对象本身。(静态的 synchronized方法从Class对象上获取锁。) synchronized (lock){ ∥访问或修改被锁保护的共享状态 每个Java对象都可以隐式地扮演一个用于同步的锁的角色:这些内置的锁被称作内部 锁(intrinsic locks)或监视器锁(monitor locks)。执行线程进入synchronized块之前 会自动获得锁:而无论通过正常控制路径退出,还是从块中抛出异常,线程都在放弃对 synchronized块的控制时自动释放锁。获得内部锁的唯一途径是:进入这个内部锁保 护的同步块或方法。 内部锁在Java中扮演了互斥锁(mutual exclusion lock,也称作mutex)的角色,意味 着至多只有一个线程可以拥有锁,当线程A尝试请求一个被线程B占有的锁时,线程A 必须等待或者阻塞,直到B释放它。如果B永远不释放锁,A将永远等下去。 同一时间,只能有一个线程可以运行特定锁保护的代码块,因此,由同一个锁保护的 synchronized块会各自原子地执行,不会相互干扰。在并发的上下文中,原子性的含义 与它在事务性应用中相同一一 组语句(statements)作为单独的,不可分割的单元运行。 Java并发编程实践
26 第2章线程安全 执行synchronized块的线程,不可能看到会有其他线程能同时执行由同一个锁保护的 synchronized块。 同步机制简化了恢复factoring servlet线程安全的工作。清单2.6中,我们将service 方法声明为synchronized,所以同一时间内只有一个线程可以进入service方法。现在 ynchronizedFactorizer又是线程安全的了;但是这种方法过于极端,它完全禁止多个 用户同时使用factoring servlet-一这导致糟糕的、令人无法接受的响应性。这个问题一 一个性能问题,而非线程安全问题一将在2.5节中深入讨论。 清单2.6缓存了最新结果的seet,但响应性令人无法接受(不要这样徽) @ThreadSafe public class SynchronizedFactorizer implements Servlet @GuardedBy("this")private BigInteger lastNumber; @GuardedBy("this")private BigInteger[]lastFactors; U public synchronized void service(ServletRequest req, ServletResponse resp){ BigInteger i extractFromRequest(req); if (i.equals(lastNumber)) encodeIntoResponse(resp,lastFactors); else BigInteger[]factors factor(i); lastNumber =i; lastFactors factors; encodeIntoResponse(resp,factors); 2.3.2 重进入(Reentrancy) 当一个线程请求其他线程已经占有的锁时,请求线程将被阻塞。然而内部锁是可重进 入的,因此线程在试图获得它自己占有的锁时,请求会成功。重进入意味着所的请求是基 于“每线程(per--thread)”.,而不是基于“每调用(per-invocation)”的7。重进入的实 现是通过为每个锁关联一个请求计数(acquisition count)和一个占有它的线程。当计数为 0时,认为锁是未被占有的。线程请求一个未被占有的锁时,VM将记录锁的占有者,并 且将请求计数置为1。如果同一线程再次请求这个锁,计数将递增:每次占用线程退出同 7这与pthread(POSIX threads)的互斥锁的默认锁行为不同,它的授权是基于“每调用”的. Java并发编程实践