CH02-线程安全性
你或许会感到奇怪,线程或锁在并发编程中的作用,类似于铆钉和工字梁在土木工程中的作用。要建造一座坚固的桥梁,必须正确的使用大量的铆钉和工字梁。同理,在构件稳健的并发程序时,必须正确的使用线程和锁。但这些终归是一些机制。要编写线程安全的代码,“其核心在于对状态访问操作进行管理,特别是对共享的可变状态的访问”。
从非正式的意义上来说,对象的状态是存储在状态变量中的数据。对象的状态可能包括其他依赖对象的域。例如,HashMap 的状态不仅存储在 HashMap 对象本身,还存储在 Map.Entry 对象中。对象的状态中包含了任何可能影响其外部可见行为的数据。
“共享”意味着变量可以被多个线程同时访问,而“可变”则意味着变量的值在其声明周期内可以发生变化。我们将像讨论代码一样来讨论线程安全性,但更侧重于如何防止在数据上发生不受控的并发访问。
一个对象是否需求提供线程安全性,取决于它是否会被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。
当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java 中的主要同步机制是关键字 synchronized,它提供了一种独占的加锁方式,但“同步”这个术语还包括 volatile 类型的变量、显式锁、原子变量。
在上述规则中并不存在一些“想象中的例外情况”。即使在某个程序中省略了必要的同步机制并且看上去似乎能正确执行,而且通过了测试并在随后几年时间里都能正确运行,但程序仍可能在某个时刻发生错误。
如果当多个线程访问同一个可变状态变量时没有使用合适的同步,那么程序会出现错误。有三种方式可以修复这个问题:
- 不在线程之间共享该状态变量。
- 将状态变量修改为不可变变量。
- 在访问状态变量时使用同步。
如果在设计类的时候没有考虑并发访问的情况,那么在采用上述方法时可能需要对设计进行重大修改,因此要修复这个问题可谓是知易行难。如果从一开始就设计一个线程安全的类,那么比在以后再将这个类修改为线程安全的类要容易的多。
在一些大型程序中,要找出多个线程在哪些位置上将访问同一个变量是非常复杂的。幸运的是,面向对象这种技术不仅有助于编写出结构优雅、可维护性高的类,还有助于编写出线程安全的类。访问某个变量的代码越少,就越容易确保对变量的所有访问都正确的实现了同步,同时也更容易找出变量在哪些条件下被访问。Java 语言并没有强制要求将状态都封装在类中,开发人员完全可以将状态保存在某个公开的域(甚至是公开的静态域)中,或者提供一个对内部对象的公开引用。然而,程序状态的封装性越好,就越容易实现程序的线程安全性,并且代码的维护人员也越容易保持这种方式。
当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。
在某些情况下,良好的面向对象设计技术与实际的需求并不一致。在某些情况下,可能需要牺牲一些良好的设计原则,以换取性能或者对遗留代码的向后兼容性。有时候,面向对象中的抽象和封装会降低程序的性能,但是在编写并发应用程序时,一种正确的编程方法就是:“首先使代码正确运行,然后再提高代码的运行速度”。即便如此,最好也只是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境中确实能够带来提升时,才进行优化。
如果你必须打破封装,那么也并非不可,你仍然可以实现程序的线程安全性,只是更困难。而且,程序的线程安全性将更加脆弱,不仅增加了成本和风险,而且也增加了维护的成本和风险。
到目前为止,我们使用了“线程安全类”和“线程安全程序”两个术语,二者的含义基本相同。线程安全的程序是否完全由线程安全的类构成?答案是否定的,完全由线程安全的类构成的程序并不一定是一个线程安全的程序,而在线程安全的程序中也可以包含非线程安全的类。在任何情况下,“只有当类中仅包含自己的状态时,线程安全类才是有意义的”。线程安全性是一个在代码上使用的术语,但它只是与状态相关的,因此只能应用于封装其状态的整个代码,这可能是一个对象,也可能是整个程序。
2.1 什么是线程安全性
要对线程安全性给出一个确切的定义是非常复杂的。定义越正式,就越复杂,不仅很难提供有实际意义的指导建议,而且也很难从直观上去理解。因此,下面给出了一些非正式的描述,看上去令人困惑。比如:
- ……可以在多个线程中调用,并且在线程之间不会出现错误的交互。
- ……可以同时被多个线程调用,而调用者无需执行额外的同步动作。
看看这些定义,难怪我们会对线程安全性感到困惑。他们听起来非常像“如果这个类可以在线程中安全的使用,那么他就是一个线程安全的类”。对于这种说法,虽然没有太多的争议,但同样也不会带来太多的帮助。我们如何区分线程安全的类以及非线程安全的类?进一步说,“安全”的含义是什么?
在线程安全性的定义中,最核心的概念是正确性。如果对线程安全的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。
“正确性的定义是,某个类的行为与其规范完全一致”。在良好的规范中通常会定义各种不变性条件来约束对象的状态,以及定义各种后验条件来描述对象操作的结果。由于我们通常不会对类编写详细的规范,那么如何知道这些类是正确的呢?我们无法知道,但这并不妨碍我们在确信“类的代码能够工作”后使用它们。这种“代码可信性”非常接近于我们对正确性的理解,因此我们可以将单线程的正确性近似定义为“所见即所得”。在对“正确性”给出一个较为清晰的定义后,就可以定义线程安全性:“当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。”
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
由于单线程程序也可以被看成是一个多线程程序,如果某个类在单线程环境中都不是正确的,那么它肯定也不是线程安全的。如果正确的实现了某个对象,那么在任何操作中(包括调用对象的公有方法或者对其公有域进行读写操作)都不会违背不变性条件或后验条件。在线程安全类的对象实例上执行的任何串行或并行操作都不会使对象处于无效状态。
在线程安全类中封装了必要的同步机制,因此客户端无需进一步采取同步措施。
示例:一个线程安全的 Servlet
我们在第一章中列出了一组框架,其中每个框架都能创建多个线程并在这些线程中调用你编写的代码,因此你需要保证编写的代码是线程安全的。通常,线程安全性的需求并非来源于线程的直接调用,而是使用像 Servlet 这样的框架。我们来看一个简单的示例——一个基于 Servlet 的因数分解服务,并逐渐扩展它的功能,同时确保它的线程安全性。
程序清单 2-1 给出了一个简单的因素分解 Servlet。这个 Servlet 从请求中提取出数值,执行因数分解,然后将结果封装到该 Servlet 的响应中。
@ThreadSafe
public class StatelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}
与大多数 Servlet 相同,StatelessFactorizer 是无状态的:它既不包含任何域,也不包含任何对其他类的域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问 StatelessFactorizer 的线程不会影响另一个访问同一个 StatelessFactorizer 的线程的计算结果,因为这两个线程并没有共享状态,就好像它们都在访问不同的实例。由于线程访问无状态对象的行为不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。
无状态对象一定是线程安全的。
大多数 Servlet 都是无状态的,从而极大降低了在实现 Servlet 线程安全性时的复杂性。只有当 Servlet 在处理请求时需要保存一些信息,线程安全性才会成为一个问题。
2.2 原子性
当我们在无状态对象中增加一个状态时,会出现什么状况?假设我们希望增加一个“命中计数器”来统计所处理的请求数量。一种直观的方法是在 Servlet 中增加一个 long 类型的域,并且每处理一个请求就将该值加 1,如程序清单 2-2 所示:
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
public long count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(factors);
}
}
不幸的是,UnsafeCountingFactorizer 并非是线程安全的,尽管它在单线程环境中能正确运行。与前面的 UnsafeSequence 一样,这个类很可能会丢失一些更新操作。虽然递增操作 ++count
是一种紧凑的语法,使其看上去只是一个操作,但是该操作并非是原子的,因而它并不会作为一个不可分割的操作来执行。实际上,它包含了三个独立的操作:读取 count 值、将值加 1、然后将计算结果写入 count。这是一个“读取——修改——写入”的操作序列,并且其结果状态依赖于之前的状态。
图 1-1 给出了两个线程在没有同步的情况下同时对一个计数器执行递增操作时发生的情况。如果计数器的初始值为 9,那么在某些情况下,每个线程读到的值都为 9,接着执行递增操作,并且都将计数器的值设为 10。显然,这并不是我们希望看到的结果,如果有一次递增操作丢失了,命中计数器的值就将偏差 1。
你可能会认为,在基于 Web 的服务中,命中计数器的少量偏差或许是可以接受的,在某些情况下也确实如此。但如果计数器被用来生成数值序列或唯一的对象标识符,那么在多次调用中返回相同的值将导致严重的数据完整性问题。在并发编程中,这种由于不恰当的执行时序而出现不正确结果是一种非常重要的情况,他有一个正式的名字:竟态条件。
2.2.1 竟态条件
在 UnsafeCountingFactorizer 中存在多个竟态条件,从而使结果变得不可靠。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会出现竟态条件。换句话说,就是正确的结果要取决于运气。最常见的竟态条件类型是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。
在实际情况中经常会遇到竟态条件。例如,假定你计划中午在 University Avenue 的星巴克与一位朋友烩面。但当你达到那里时,发现在 University Avenue 上有两家星巴克,并且你不知道约定的是哪一家。在 12:10 时,你没有在星巴克 A 看到朋友,那么就会去星巴克 B 看看他是否在那里,但他也不在那里。这有几种可能:你的朋友迟到了,还没有抵达任何一家星巴克;你的朋友在你离开后到了星巴克 A;你的朋友在星巴克 B,但是当它去星巴克 A 找你时,你此时正在赶往星巴克 B 的途中。我们假设的最后一种情况最为糟糕。现在是 12:15,你们两个都去过了两家星巴克,并且都开始怀疑对方失约了。现在你会怎么做?回到另一家星巴克?来来回回要走多少次?除非你们之间约定了某种协议,否则你们整天都在 University Avenue 上走来走去,倍感沮丧。
在“我去看看他是否在另一家星巴克”这种方法中,问题在于:当你在街上走时,你的朋友可能已经离开了你要去的星巴克。你首先看了看星巴克 A,发现“他不在”,并且开始去找他。你可以在星巴克 B 中做同样的选择,但不是同时发生。两家星巴克之间有几分钟的路程,而就在这几分钟时间里,系统的状态可能会发生变化。
在星巴克这个示例中说明了一种竟态条件,因为要获得正确的结果(与朋友会面),必须取决于事件的发生时序(当你们到达星巴克时,在离开去另一家星巴克之前会等待剁成事件……)。当你迈出前门时,你在星巴克 A 的观察结果将变得无效,你的朋友可能从后门进来了,而你却不知道。这种观察结果的失效就是大多数竟态条件的本质——“基于一种可能失效的观察结果来做出判断或者执行某个计算”。这种类型的竟态条件被称为“先检查后执行”:首先观察到某个条件为真,然后根据观察结果采用相应的动作,但事实上,在你观察到这个结果以后以及开始执行动作之前,观察结构可能变得无效,从而导致各种问题。
竟态条件这个术语经常与另一个相关术语“数据竞争(Data Race)”相混淆。数据竞争是指,如果在访问共享的非 final 类型的域时没有采用同步来进行协同,那么就会出现数据竞争。当一个线程写入一个变量而另一个线程接下来读取这个变量,或者读取一个之前由另一个线程写入的变量时,并且在这两个线程之间没有使用同步,那么就可能出现数据竞争。在 Java 内存模型中,如果代码中存在数据竞争,那么这段代码就没有确定的语义。并非所有竟态条件都是数据竞争,同样并非所有的数据竞争都是竟态条件,但二者都可能使并发程序失败。
2.2.2 示例:延迟初始化中的竟态条件
使用“先检查后执行”的一种常见情况就是“延迟初始化”。延迟初始化的目的是将对象的初始化操作推迟到第一次实际被使用时才进行,同时要确保只被初始化一次。在程序清单 2-3 中的 LazyInitRace 说明了这种延迟初始化情况。getInstance 方法首先判断 ExpensiveObject 是否已经被初始化,如果已经初始化则返回现有的实例,否则他将创建一个新的实例并返回一个引用,从而在后来的调用中值无需执行这段高开销的代码路径。
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if(instance == null)
intstance = new ExpensiveObject();
return instance;
}
}
LazyInitRace 中包含一个竟态条件,他可能会打破这个类的正确性。假定线程 A 和 B 同时执行 getInstance。A 看到 instance 为空,因而创建一个新的 ExpensiveObject 实例。B 同样需要判断 instance 是否为空。此时的 instance 是否为空,要取决于不可预测的时序,包括线程的调度方式,以及 A 需要花多长时间来初始化 ExpensiveObject 并设置 instance。如果当 B 检查时 instance 为空,那么在两次调用 getInstance 时可能会得到不同的结果,即使 getInstance 通常被认为是返回相同的实例。
在 UnsafeCountingFactorizer 的统计命中计数操作中存在另一种竟态条件。在“读取——修改——写入”这种操作中,基于对象之前的状态来定义对象状态的转换。要递增一个计数器,你必须知道它之前的值,并确保在执行更新过程中没有其他线程会修改或使用这个值。
与大多数并发错误一样,竟态条件并不总是会产生错误,还需要某种不恰当的执行时序。然而,竟态条件也可能导致严重的问题。假定 LazyInitRace 被用于初始化应用程序范围内的注册表,如果在多次调用中返回不同的实例,那么要么会丢失部分注册信息,要么多个执行行为对同一个组注册对象表现出不一致的视图。如果将 UnsafeSequence 用于在某个持久化框架中生成对象的标识,那么两个不同的对象最终将获得相同的标识,这就违反了标识的完整性约束条件。
2.2.3 复合操作
LazyInitRace 和 UnsafeCountingFactorizer 都包含一组需要以原子方式执行(不可分割)的操作。要避免竟态条件问题,就必须在某个线程修改变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,从而避免在修改状态的过程中观察到失效状态。
假定两个操作 A 和 B,如果从执行 A 的线程来看,当另一个线程执行 B 时,要么将 B 全部执行完,要么完全不执行 B,那么 A 和 B 对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
如果 UnsafeSequence 中的递增操作是原子操作,那么图 1-1 中的竟态条件就不会发生,并且递增操作在每次执行时都会把计数器加 1。为了确保线程安全性,“先检查后执行”和“读取—修改-写入”等操作都必须是原子的。我们将“先检查后执行”以及“读取—修改-写入”等操作统称为“复合操作”:“包含了一组必须以原子方式执行的操作以确保线程安全性”。在 2.3 节中,我们将介绍锁机制,这是 Java 中用于确保原子性的内置机制。就目前而言,我们先采用另一种凡事来修复这个问题,即使用一个现有的线程安全类,如程序清单 2-4 中的 CountingFactorizer 所示:
@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(factors);
}
}
在 java.util.concurrent.atomic 包中包含了一些原子变量类,用于实现在数值的对象引用上的原子状态转换。通过用 AtomicLong 来代替 long 类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。由于 Servlet 的状态就是计数器的状态,并且计数器是线程安全的,因此这里的 Servlet 也是线程安全的。
我们在因数分解的 Servlet 中增加了一个计数器,并且通过使用线程安全类 AtomicLong 来管理计数器的状态,从而确保了代码的线程安全性。挡在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。然而,在 2.3 节你将看到,当状态变量的数量不再是一个时,并不会像仅有一个状态变量时那么简单。
在实际情况中,应尽可能的使用现有的线程安全对象来管理状态。与非线程安全对象相比,判断线程安全对象的状态空间及其状态迁移情况要更为容易,从而也更容易维护和验证线程安全性。
2.3 加锁机制
当在 Servlet 中添加一个状态变量时,可以通过线程安全的对象来管理 Servlet 的状态以维护 Servlet 的线程安全性。但如果想在 Servlet 中添加更多的状态,那么是否只需要添加更多的线程安全状态变量就足够了?
假设我们希望提升 Servlet 的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无需重新计算。要实现该策略,需要保存两个状态:最近执行因数分解的数值,和分解结果。
我们曾通过 AtomicLong 以线程安全的方式来管理计数器的状态,那么,在这里是否可以使用类似 AtomicReference 来管理最近执行因数分解的数值及其分解结果吗?在程序清单 2-5 中的 UnsafeCachingFactorizer 实现了这种思想。
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
private fianl AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extactFromRequest(req);
if(i.equals(lastNumber.get())) {
encodeIntoResponse(resp, lastFactors.get());
}
else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(factors);
}
}
}
然而,这种方法并不正确。尽管这些原子引用本身都是线程安全的,但在 UnsafeCachingFactorizer 中存在着竟态条件,这可能产生错误的结果。
在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。UnsafeCachingFactorizer 的不变性条件之一是:在 lastFactors 中缓存的因数之积应该等于在 lastNumber 中缓存的数值。只有确保了这个不变性条件不被破坏,上面的 Servlet 才是正确的。当在不变性条件中涉及到多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某个变量时,需要在同一个原子操作中对其他变量同时进行更新。
在某些执行时序中,UnsafeCachingFactorizer 可能会破坏这个不变性条件。在使用原子引用的情况下,尽管对 set 方法的每次调用都是原子的,但仍然无法同时更新 lastNumber 和 lastFactors。如果只修改了其中一个变量,那么在两次修改操作之间,其他线程发现不变性条件被破坏了。同样,我们也不能保证会同时获取两个值:在线程 A 获取两个值的过程中,线程 B 可能修改了它们,这样线程 A 就发现不变性条件被破坏了。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
2.3.1 内置锁
Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包含两个部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字 synchronized 来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的 synchronized 方法以 Class 对象作为锁。
synchronized (lock) {
// 访问或修改由锁保护的共享状态
}
每个 Java 对象都可以用作一个实现同步的锁,这些所被称为内置锁或监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java 的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程 A 尝试获取一个右线程 B 持有的锁时,线程 A 必须等待或阻塞,直到线程 B 释放这个锁。如果 B 永远不释放锁,那么 A 也将永远的等下去。
由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会互相干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义——“一组语句作为一个不可分割的单元被执行”。任何一个执行同步代码块的线程,都不可能看到其他线程正在执行由同一个锁保护的同步代码块。
这种同步机制使得要确保因数分解 Servlet 的线程安全性变得更加简单。在程序清单 2-6 中使用了关键字 synchronized 来修饰 service 方法,因此在同一时刻只有一个线程可以执行 service 方法。现在的 SynchronizedFactorizer 是线程安全的。然而,这种方法却过于极端,因为多个客户端无法同时使用因素分解 Servlet,服务的响应性非常低,令人无法接受。这是一个性能问题,而非线程安全问题,我们将在 2.5 中解决该问题。
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
@GuardBy("this") private BigInteger lastNumber;
@GuardBy("this") private BigInteger[] lastFactors;
public synchronized void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extactFromRequest(req);
if(i.equals(lastNumber)) {
encodeIntoResponse(resp, lastFactors);
}
else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(factors);
}
}
}
2.3.2 重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,“由于内置锁是可重入的”,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”(同一个线程可以发起多次调用)。重入的一种实现方式是,为每个锁关联一个获取计数值和一个拥有者线程。当计数器为 0 时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM 将记录锁的持有者,并且将获取计数值设为 1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应的递减。当计数值为 0 时,这个锁将被释放。
“重入”进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。在程序清单 2-7 的代码中,子类改写了父类的 synchronized 方法,然后调用父类中的方法,此时如果没有可重入锁,那么这段代码将产生死锁。由于 Widget 和 LoggingWidget 中 doSomething 方法都是 synchronized 方法,因此每个 doSomething 方法在执行前都会获取 Widget 上的锁。然而,如果内置锁不是可重入的,那么在调用 super.doSomething 时将无法获得 Widget 上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。重入则避免了这种死锁的发生。
public class Widget {
public synchronized void doSomething() {...}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
2.4 用锁来保护状态
由于锁能使其保护的代码路径以“串行形式”被访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
访问共享状态的复合操作,例如命中计数器的递增操作(读取-修改-写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竟态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步块。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。
一种常见的错误是认为,只有在写入共享变量时才需要同步,然而事实并非如此。
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量由这个锁保护。
在程序清单 2-6 的 SynchronizedFactorizer 中,lastNumber 和 lastFactors 这两个变量都是由 Servlet 对象的内置锁来保护的,在注解 @GuardBy 中也已经说明了这一点。
对象的内置锁与其状态之间没有内置的关联。虽然大多数类都将内置锁用作一种有效的加锁机制,但对象的域并不一定要通过内置锁类保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式的创建锁对象。你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中至始至终的使用它们。
每个共享的可变的变量都应该由一个锁来保护,从而使维护人员知道是哪一个锁。
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有的访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多多线程安全类中都使用了这种模式,例如 Vector 和其他的同步集合类。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。然而,这种模式并没有任何特殊之处,编译器或运行时都不会强制实施这种(或其他)模式。如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏。
并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。第一章曾介绍过,当添加一个简单的异步事件时,例如 TimerTask,整个程序都需要满足线程安全性要求,尤其是当程序状态的封装性比较糟糕时。考虑一个处理大规模数据的单线程程序,由于任何数据都不会在多个线程之间共享,因此在单线程程序中不需要同步。现在,假设希望添加一个功能,即定期对数据处理进度生成快照,这样当程序崩溃或必须停止时无需再次从头开始。你可能会选择使用 TimerTask,每十分钟触发一次,并将程序状态保存到一个文件中。
由于 TimerTask 在另一个(由 Timer 管理的)线程中调用,因此现在就有两个线程同时访问快照中的数据:程序的主线程与 Timer 线程。这意味着,当访问程序的状态时,不仅 TimerTask 代码必须使用同步,而且程序中所有访问相同数据的代码路径也必须使用同步。原本在程序中不需要使用同步,现在变成了在程序的各个位置都需要使用同步。
当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另一个需求:在不变性条件中的每个变量都需要由同一把锁来保护。因此可以在单个原子操作中访问或更新所有这些变量,从而确保不变性条件不被破坏。在 SynchronizedFactorizer 类中说明了这条规则:缓存的数值和因数分解结果都由 Servlet 对象的内置锁来保护。
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
如果同步可以避免竟态条件问题,那么为什么不在每个方法声明时都使用关键字 synchronized ?事实上,如果不加区别的滥用 synchronized,可能导致程序中出现过多的同步。此外,如果只是将每个方法都作为同步方法,例如 Vector,那么并不足以确保 Vector 上复合操作都是原子的:
if(!vector.contains(element))
vector.add(element);
虽然 contains 和 add 等方法都是原子的,但在上面这个“如果不存在则添加(put-if-absent)”的操作中仍然存在竟态条件。虽然 synchronized 方法可以确保单个操作的原子性,但如果要把多个操作合并成一个复合操作,还是需要额外的加锁机制。此外,将每个方法都作为同步方法还可能导致活跃性问题或性能问题,我们在 SynchronizedFactorizer 中已经看到了这些问题。
活跃性与性能
在 UnsafeCachingFactorizer 中,我们通过在因数分解 Servlet 中引入了缓存机制来提升性能。在缓存中需要使用共享状态,因此需要通过同步来维护状态的完整性。然而,如果使用 SynchronizedFactorizer 中的同步方式,那么代码的执行性能将非常糟糕。SynchronizedFactorizer 中采用的同步策略是,通过 Servlet 对象的内置锁来保护每一个状态变量,该策略的实现方式也就是对整个 service 方法进行同步。虽然这种简单且粗粒度的方法能确保线程安全性,但付出的代价却很高。
由于 service 是一个 synchronized 方法,因此每次只有一个线程可以执行。这就背离了 Servlet 的初衷,即 Servlet 需要能够同时处理多个请求,这在负载过高的情况下将给用户带来糟糕的体验。如果 Servlet 在对某个大数值进行因数分解时需要很长的执行时间,那么其他的客户端必须一致等待,知道 Servlet 处理完当前的请求,才能开始另一个新的因数分解运算。如果在系统中有多个 CPU,那么当负载很高时,仍然会有处理器处于空闲状态。即使一些执行时间很短的请求,比如访问缓存的值,仍然需要很长的时间,因为这些请求都必须等待前一个请求执行完成。
图 2-1 给出了当多个请求同时到达因数分解 Servlet 时发生的情况:这些请求将排队等待处理。我们将这种 Web 应用程序称之为不良并发应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身架构的限制。幸运的是,通过缩小同步代码块的作用范围,我们很容易做到既确保 Servlet 的并发性,同时又维护线程安全性。要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。
程序清单 2-8 中的 CachedFactorizer 将 Servlet 的代码修改为使用两个独立的同步代码块,每个同步代码块都只包含一小段代码。其中一个同步代码块负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列,另一个同步代码块则负责确保对缓存的数值和因数分解结果进行同步更新。此外,我们还重新引入了“命中计数器”,添加了一个“缓存命中”计数器,并在第一个同步代码块中更新这两个变量。由于这两个计数器也是共享可变状态的一部分,因此必须在所有访问它们的位置上使用同步。位于同步代码块之外的代码将以独占的方式来访问局部(位于栈上)的变量,这些变量不会在多个线程间共享,因此不需要同步。
@ThreadSafe
public class CachedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
@GuardedBy("this") private long hits;
@GuardedBy("this") private long cacheHits;
public synchronized long getHits() { return hits; }
public synchronized double getCacheHitRatio() {
return (double) cacheHits / (double) hits;
}
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = null;
synchronzied (this) {
++hits;
if(i.equals(lastNumber)) {
++cacheHits;
factors = lastFactors.clone();
}
}
if(factors == null) {
factors = factor(i);
synchronized (this) {
lastNumber = i;
lastFactors = factors.clone();
}
}
encodeIntoResponse(resp, factors);
}
}
在 CachedFactorizer 中不再使用 AtomicLong 类型的命中计数器,而是使用了一个 long 类型的变量。当然也可以使用 AtomicLong 类型,但使用 CountingFactorizer 带来的好处更多。对于在单个变量上实现原子操作来说,原子变量是很有用的,但由于我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,而不在性能和安全性上带来任何好处,因此这里不再使用原子变量。
重新构造后的 CachedFactorizer 实现了在简单性(对整个方法进行同步)与并发性(对仅可能短的代码路径进行同步)之间的平衡。在获取与释放锁等操作上需要一定的开销,因此如果将同步代码块分解的过细(例如将 ++hits
分解到它自己的同步代码块中),那么通常并不好,尽管这样做不会破坏原子性。当访问状态变量或者在复合操作的执行期间,CachedFactorizer 需要持有锁,但在执行时间较长的因素分解运算之前要释放锁。这样既能确保线程安全性,也不会过多的影响并发性,而且在每个同步代码块中的代码路径都“足够短”。
要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这是必要需求)、简单性、性能。有时候,在简单性与性能之间会发生冲突,但在 CachedFactorizer 中已经说明了,在二者之间通常能够找到某种合理的平衡。
通常,在简单性和性能之间存在相互制约因素。当实现某个同步策略时,一定不要盲目的为了性能而牺牲简单性(这可能会破坏安全性)。
当使用锁时,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。
当执行时间较长的计算或者可能无法快速完成的操作时,如 IO、控制台 IO,一定不要持有锁。
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.