2.1什么是线程安全性 17 更困难些。而且,程序的线程安全性会变得更加脆弱,这增加了开发与维护的开销和风险。 第4章详述了在什么条件下你可以安全地打破对状态变量的封装。 到目前为止,我们几乎可以互换地使用着术语“线程安全类”和“线程安全程序”。 一个线程安全程序是完全由线程安全类构成的么?不必要—完全由线程安全类构成的 程序未必是线程安全的,线程安全程序也可以包含非线程安全的类。围绕着组合线程安全 类的话题,还会在第4章提到。无论如何,只有当类封装了自己的状态时,“线程安全类” 的概念才有意义。“线程安全性”可能成为用于约束代码的条款,或成为状态的条款,并 且只能用于封装了自身状态的代码的整体,这个整体可能是一个对象,或是一个完整的程 序。 2.1什么是线程安全性 给“线程安全性”下个定义相当棘手。很多正式的定义都显得过于复杂,并没有给出 实用的指导或者精到的见解;而其他非正式的描述看上去又完全是在兜圈子。在Google 上搜索了一下,查到很多定义略举一二: 可以被多个程序线程调用,这些线程之间没有非预期的互交。 .可以同时被多个线程调用,而调用者不需要任何动作(来确保线程的安全性)。 给出这样的定义,让我们对线程安全性产生困惑是不足为奇的!它们听上去令人怀疑: “如果一个类可以安全地被多个线程使用,它就是线程安全的。”你无法对此论述提出任 何争议,但也无法从中得到更多有意义的帮助。我们如何辨别线程安全与非线程安全的 类?我们甚至又该如何理解“安全”呢? 任何一个合理的“线程安全性”定义,其关键在于“正确性”的概念。如果我们关于 线程安全性的定义是模糊的,那是因为缺少一个明确的“正确性”定义。 正确性意味着一个类与它的规约保持一致。良好的规约定义了用于强制对象状态的不 变约束(invariants)以及描述操作影响的后验条件(postconditions)。通常我们不会为类 写足够的规约,那么我们还能够知道程序的正确与否么?不能,但是只要我们相信“代码 是可以工作的”,就不会阻止我们使用这些类。这种“代码自信”与我们所要实现的正确 性紧密相关,所以不妨假设单线程化的正确性是“所见即所知”的事物。乐观地将“正确 性”定义为“可被认知事物”后,我们现在可以少兜些圈子来定义“线程安全性”了:一 个类是线程安全的,是指在被多个线程访问时,类可以持续进行正确的行为。 Jav阳并发编程实践
18 第2章线程安全 当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交 替执行,并且不需要额外的同步及在调用方代码不必作其他的协调,这个类的行为 仍然是正确的,那么称这个类是线程安全的。 任何单线程化的程序同时也是合法的多线程化的程序,倘若程序在单线程化的环境尚 且不正确2,那么该程序必然不是线程安全的。对于一个正确实现的对象,顺序性的操作 比如调用公共的方法,读写公共域一不会破坏任何一个不变约束以及后验条件。对 于线程安全类的实例进行顺序或并发的一系列操作,都不会导致实例处于无效状态。 线程安全的类封装了任何必要的同步,因此客户不需要自己提供。 2.1.1 示例:一个无状态(stateless)的servlet 在第1章,我们列出了很多框架,这些框架会创建线程,并在这些线程中调用你的组 件,而将确保组件线程安全的责任留给了你。通常需要线程安全的,并不是直接使用线程 的情况,而是那些使用了便利工具(如Servlets框架)的情况。我们会展示一个例子,基 于Servlet的因数分解服务,并逐步扩展它,添加新特性,同时确保它的线程安全性。 清单2.l展示了我们简单的因数分解的Servlet。.它从Servlet Request中解包数据,然 后将这个数据进行因数分解,最后将结果封包到Servlet Response中。 清单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); 2如果在这里随意使用“正确性”会令你迷惑,你不妨将线程安全类看作这种类:它在并发环 境中的隐患不会多于单线程环境下的隐患。 Java并发编程实践
2.2原子性 19 StatelessFactorizer像大多数Servlet一样,是无状态的:它不包含域也没有引用 其他类的域。一次特定计算的瞬时状态,会唯一地存在本地变量中,这些本地变量存储在 线程的栈中,只有执行线程才能访问。一个访问StatelessFactorizer的线程,不会影 响访问同一个Servlect的其他线程的计算结果;因为两个线程不共享状态,它们如同在访 问不同的实例。因为线程访问无状态对象的行为,不会影响其他线程访问该对象时的正确 性,所以无状态对象是线程安全的。 无状态对象永远是线程安全的。 多数Servlet都可以实现为无状态的,这一事实极大地降低了确保serv1et线程安 全的负担,只有当Servlet要为不同的请求记录一些信息时,才会将线程安全的需求提到 日程上来。 2.2 原子性 我们向无状态对象中加入一个状态元素会怎样?假设我们想要添加“命中数(it counter)”来计算处理请求的数量。显而易见的方法是在Servlet中加入一个long类型的 域,并在每个请求中递增它。如同清单2.2的UnsafeCountingFactorizer所示。 清单2.2 Servlet计算请求数量而没有必要的同步(不要这样做) @NotThreadSafe public class UnsafeCountingFactorizer implements Servlet private 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(resp,factors); 很遗憾,UnsafeCountingFactorizer并非线程安全的,尽管它在单线程的环境中运 行良好。正如第6页中的UnsafeSequence,它很容易遗失更新(lost updates)。自增操 作+cout由于其紧凑的语法格式,看上去更像一个单独的操作。然而,它不是原子操作。 Java并发编程实践
20 第2章线程安全 这意味着,它不能作为一个单独的、不可分割的操作去执行。相反,自增操作是3个离散 操作的简写形式:获得当前值,加l,写回新值。这是一个“读-改.写(read-modify-write)” 操作的实例,其中,结果的状态衍生自它先前的状态。 第6页的图1.1演示了两个线程在缺乏同步的条件下,试图同时更新一个计数器时所 发生的事情。假设计数器的初始值为9,在某些特殊的分时里,每个线程都将读它的值, 并看到值是9,然后同时加1,最后都将counter设置为10。很明显,这不是我们期望发生 的事情:一次递增操作凭空消失了,一次命中计数被永久地取消了。 你可能会想命中计数上的轻傲错误所导致准确率的误差,在基于Wb的服务中是可 以接受的。有时的确如此。但是如果计数器用于生成序列或对象唯一的标识符,.多重调用 返回相同的结果会导致严重的数据完整性问题3。在一些偶发时段里,出现错误结果的可 能性对于并发程序而言非常重要,以致于专门用一个名词来描述它们:竞争条件。 2.2.1竞争条件 UnsafeCountingFactorizer中存在数个竟争条件,导致其结果是不可靠的。当计算 的正确性依赖于运行时中相关的时序或者多线程的交替时,会产生竞争条件:换句话说, 想得到正确的答案,要依赖于“幸运”的时序4。最常见的一种竞争条件是“检查再运行 (check-then-act)”,使用一个潜在的过期值作为决定下一步操作的依据。 在现实生活中,我们也常常会遇到竞争条件。比如说你打算中午到University Avenue 的星巴克去见一个朋友。不过当你到达这里后,发现这里有两个星巴克,而你并不确定和 朋友约在哪一个星巴克见面。12:10的时候,你还没有在星巴克A见到你的朋友,于是你 向星巴克B走去,看看他是否在那里,可惜也不在。这存在几种可能性:你的朋友迟到了, 没有出现在任何一个星巴克中:你的朋友在你离开后到达了星巴克A:你的朋友曾经在星 巴克B,但是为了找你,现在在去星巴克A的途中。接下来,让我们假设最糟糕的情况, 不妨称其为最终的可能性。现在是12:15,你们已经去过了所有的星巴克,你们也想知道 对方是否已经等在那里了。你现在做什么?回到另一个星巴克?你打算走上多少个来回? 3 UnsafeSequence与UnsafeCountingFactorizer所米用的方法还存在其他严重的问 题,包括出现过期数据的可能性(3.1.1节)。 4术语竞争条件通常会和一个相关的术语数据竞争(data race)混淆。数据竞争出现于没有使用 同步来协调所有那些共享的非final域访问的情况。一个线程写入一个变量,可以被另一个线程 读取;一个线程读取刚刚被另一个线程写入的变量,如果两个线程都没有使用同步,你将会处 于数据竞争的风险中。处于数据竞争下的代码,在Java存储模型中并没有明确定义的语义。不 是所有竞争条件都是数据竞争,同样不是所有的数据竞争都是竞争条件,不过他们都会引起并 发程序以不可预期的方式失败。UnsafeCountingFactorizer中既有竞争条件,又有数据竞 争,关于数据竞争的更多细节,参见第16章。 J归v阳并发编程实践
2.2原子性 21 除非你和你的朋友间有某些约定,否则你们会无精打采,倍感沮丧地在University Avenue 走上一整天。 “我打起精神沿街走,看朋友是不是在另一处。”这种作法的问题在于当你沿街走时, 你的朋友可能己经离开了。你在星巴克A寻找朋友,发现“他不在这里”,然后继续寻找 他。你在星巴克B可以做完全相同的事,但不是在同时做。沿街走要花几分钟,在这几分 钟的时间里,系统状态可能已经更改。 星巴克的例子阐释了竞争条件的诱因:为获取期望的结果(见到你的朋友),需要依 赖相关的事件的分时(当你到达一家星巴克时,会在这里等上多久而后离开,等等)。只 要你一走出星巴克A的大门,“朋友不在这里”的观察结果就会潜在地变为无效结果:你 的朋友可能已经从后门走进来而你并不知道。这些无效的观察结果,指出了大多数竞争条 件的特点一使用潜在的过期观察值来作决策或执行计算。这种竞争条件被称作检查再运 行(check-then-act):你观察到一些事情为真(文件X不存在),然后(then)基于你的 观察去执行一些动作(创建文件X);但事实上,从观察到执行操作的这段时间内,观察 结果可能已经无效了(有人在此期间创建了文件X),从而引发错误(非预期的异常,重 写数据或者破坏文件)。 2.2.2示例:惰性初始化中的竞争条件 检查再运行的常见用法是情性初始化(lazy initialization)。惰性初始化的目的是延迟 对象的初始化,直到程序真正使用它,同时确保它只初始化一次。清单23示范了惰性初 始化的用法,getInstance方法首先检查ExpensiveObject是否已被初始化,如果是, 返回已经存在的实例:否则就创建一个新实例,然后保留它的引用,最后将它返回。由此, 在这之后的调用可以避免执行代价昂贵的代码路径。 清单2.3惰性初始化中存在竞争条件(不要这样徽) @NotThreadSafe public class LazyInitRace private Expensiveobject instance null; public Expensiveobject getInstance(){ U if (instance =null) instance new Expensiveobject(); return instance; Jav阳并发编程实践