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