JAVA-CONCURRENCY IN PRACTICE章节二翻译

参考了很多童老师团队的翻译,自己也看英文对照,有修改

章节二 线程安全

 

或许会让你感到惊讶的是,在并发编程中不会过多地讨论多线程和锁,就像现实中土木工程不仅仅是铆钉和工字钢。当然要想建一个不会垮掉大桥也需要正确的使用许多铆钉和工字钢,就如同在创建一个并发编程的程序也需要合理正确使用多线程和锁机制。但这些只是机制而已--用来达到目的的手段。事实上编写多线程安全代码的核心是管理对对象状态的访问,特别是那种共享的、易变的状态。

非正式地阐述一下,一个对象的状态,实际就是存储在实例域或者静态域这种状态变量中的,是它自己的数据。一个对象的状态,可能包括有依赖关系的、其他对象的域。一个HashMap的状态值部分存储于HashMap对象本身,还有一些存储在许多Map.Entry对象中。一个对象的状态包括一些能够影响它其对外行为的数据。

这里说的共享对象,是指变量能够被多线程访问的对象;而可变对象指的是在其生命周期中变量值会变化的对象。我们讨论的线程安全就像是在讨论有关代码的事,但是其实是讨论如何尽力保证数据不被不受预期控制的并发访问。

一个对象是否需要是线程安全的保证依赖于它是否会被多线程访问。这要看对象的性质--对象在程序中如何被使用,而不是对象会做什么。要想使一个对象线程安全,需要使用同步来协调多线程对该 对象易变状态变量的访问。如果不能这么做将会导致数据破坏或者其他糟糕的结果。

无论何时,当超出一个的线程同时访问某个被提供给大家的对象状态变量,并且其中一个可能会对这个变量进行修改,那么所有的线程都必须使用同步机制来协调对这个变量的访问。在Java中,主要的同步机制是synchronized关键字,它提供了排他锁。但是同步机制也还包括volatile关键字修饰的共享变量、atomic原子操作变量以及显式锁的使用。

你应该放弃幻想上述规则不好使的“特殊”情况。如果一个程序需要使用同步机制,可是你遗漏了,或许可以通过测试甚至能够正常运行好几年,但最终还是会出问题。

如果多个线程同时访问或修改某个对象的易变状态变量,又没有合理地进行同步的话,你的程序就是有毛病的。你可以使用如下三种方法来修复这个问题: 

·避免多线程对该变量状态的共享 

·将该变量改成不变的 

·在访问或修改该变量状态值的时候使用 synchronized 关键字

如果你不是在设计类的时候就考虑到了并发访问问题,上面三种方法中的有些方法做起来可能比较麻烦,比说的麻烦多了。最好的办法是在设计类的时候就将其设计成线程安全的。

在一个大型的程序中,确定某个对象的变量是否可能被多个线程访问比较困难。幸运的是,那些能够帮助你创建结构良好、可维护性强的类的面向对象设计原则,比如封装和数据隐藏可以帮助你创建线程安全类。对一个特定变量的访问所写的代码越少,确保它使用合理的同步机制就越容易,同时推理变量在哪种条件下被访问也会更容易。Java语言并不强制你封装状态值--它完全允许你把变量存储在public域中(甚至是public static域中),或者公开给其他内部对象引用--但是你把程序的状态封装的越好,是你的程序线程安全就越简单,维护人员也会更容易操作。

当设计线程安全的类的时候,优秀的面向对象设计技巧--封装、不变性、不变量的明确规范--都是你的好朋友。

有时候好的设计方法会和需求产生冲突,这些情况下就需要在优先保证对遗留代码的兼容性的良好设计规则或者优先保证性能的良好设计规则做出选择。有时候,封装抽象和性能是有冲突的,许多开发人员尽管并不相信这种情况是经常发生的,但是实际上确实如此。实践中能比较好的方式是首先保证代码正确,然后在保证性能足够好。即使这样,除非你的性能测试和需求告诉你或者在现实情况下这些测试都诉你必须对性能做出改变,否则不要一味地追求最优化。

如果你觉得你必须打破封装性原则,你还是可以确保你的程序是线程安全的,只是更加困难。此外,这样做将会使你的程序在线程安全方面更加脆弱,在开发和维护期间都将会须要更多的成本,并冒更大的风险。第四章将会讨论在哪些条件下放松封装性原则是安全的。

到目前为止,我们一直替换地使用“线程安全类“和”线程安全程序“两个术语。那么一个线程安全程序就是全部使用线程安全类构建的程序吗?这并不是必然性的--全部由线程安全类构成的程序未必是线程安全的。第四章将讨论线程安全类的组成的问题。在任何情况下,线程安全类的概念都只有在这个类封装好自己的现相关状态值时才是讲得通的。线程安全可能被用作代码的一种术语,但实际是关于状态的。只能被用于当代码里的对象或者整个程序,因为他们在整个代码体里封装了他们的状态。

2.1什么是线程安全

很难准确地定义线程安全这个概念,较为正式的定义都比较复杂,也只是给出了很少的实用性方面的指导并且不太好理解,剩下的看起来都是一些非常圆滑无用的非正式描述。从 Google 中能够搜索很多“定义”:

...可以被多个线程调用,多线程之间没有我们并不需要的交互。

...可以在同一时间内被多个线程调用,调用者不需要额外的操作。

看看这些定义就会明白,难怪线程安全让我们如此费解!像“如果一个类可以安全地被多线程访问,那么它是线程安全的”这样的话听起来令人疑惑,虽然找不出什么毛病,但是对于实际编程没有什么指导意义,你又没办法和一个陈述做讨论。我们如何区分线程安全类和非线程安全类呢?这里说的安全到底是什么意思?

所有合理的线程安全定义的核心部分都涉及到正确性的概念。线程安全的定义之所以模糊是因为我们缺乏对正确性清楚的定义。

类的正确性意味着类必须遵守其规格说明,一个好的类规格说明必须明确定义对象状态的不变约束条件,以及对象的各个操作所带来影响的后置条件。鉴于我们常常对类的规格说明描述得比较简略的这个现状,我们可能知道一个类的实现是否满足正确性吗?不能,但是一旦我们自己告诉自己这个类没毛病,我们就无论如何都会使用这些类。这种“代码的信心”是我们面对正确性时许多人都会有的心态,所以就让我们假设单线程的正确性是“我们看到了就能清晰的了解”。如果乐观地将正确性定义为一种我们能够识别的东西,我们可以直接将线程安全定义为:如果一个类在被多线程访问的时候能够持续地保持行为的正确性,那么它就是一个线程安全类。

更确切地说,如果一个类在被多线程访问的时候能够持续地保持正确性,不受运行时交叉或者定时进行的其他线程影响,并且在调用代码中不需要任何同步措施或者其他协调方式的话,那么这个类就是一个线程安全类。 

由于单线程程序也是一个有效的多线程程序特例,所以如果一个类在单线程环境下的时候都不能正确工作,在多线程调用情况下肯定也不能。如果一个线程安全类实现正确的话,那么任何由 public 方法调用和 public变量的读写组成的操作序列都不能够破坏该对象的约束条件和后置条件。对一个线程安全类的对象进行任何操作(序列的或者并发的)都不能导致该对象处于数据不一致状态。

线程安全类将所有必要的同步机制都封装到其内部,因此调用者不需要再提供额外的同步。

2.1.1 例子:一个无状态的Servlet

在第一章中我们列举了一些框架,它们可以自动创建线程,并在多线程中调用你编写的逻辑组件。这样,你必须确保你的逻辑组件是线程安全的。线程安全性的需求产生,往往不是由于直接使用多线程机制引起的,而是由于使用了如 Servlet 这样的框架引起的。我们将开发一个简单的基于 Servlet 的因数分解服务作为一个例子,然后慢慢地扩展它,同时保持线程安全性。

表2.1展示了因数分解servlet的例子。它会先从servlet请求取得并解压数字,然后因数分解,再把结果封装打包到servlet响应结果中。

像大多数 Servlet 一样,StatelessFactorizer 类是无状态的。它没有字段,也没有引用其他类的字段,计算结果单独的存储在执行线程栈中的本地变量内,并且只能被执行线程访问。一个访问 StatelessFactorizer的线程无法影响另一个访问 StatelessFactorizer 的线程所得出的结果。这是因为两个线程不共享状态,就好像它们在访问不同的实例一样。一个线程对某个无状态对象的访问不会影响其他线程对该对象的访问操作的所得出结果的正确性。无状态对象总是线程安全的。

无状态对象总是线程安全的。

大多数 servlet 都可以被实现为无状态类,这种操作极大地减轻了Servlet的线程安全设计难度。只有在一个请求的数据要影响其他请求的时候,才需要考虑线程安全问题。

2.2 原子性

为上一章的无状态的 Servlet 添加一个字段(状态)如何?假设你想要添加一个字段用来表示请求访问的次数,最明显的的办法是添加一个 long 类型的字段并且在每次请求过来的时候都在代码里增加,代码如下:

不幸的是 UnsafeCountingFactorizer 类并不是线程安全的,虽然在单线程环境下工作正常。就像书上前边第6页的UnsafeSequence类,这个类也容易受到多线程影响导致某次更新失效。当执行++count 的时候,虽然由于语法比较紧凑,看上去是一个单独的操作,但是这个操作并不是原子操作,事实上它由三个不连续的快速完成的操作组成:获取当前值,当前值加1,回写新值。这就是结果值由前一步结果推演而来的实例,是读取-更新-写入三步操作

本书第6页的图1.1就展示了如果两个线程在没有同步机制控制的情况下,同时增加counter的错误结果。如果初始状态下count值为9,在某个时刻(多个线程都没有做出改变之前)多个线程都读取count的值,得到9,然后加1,回写,count 的值最终为10。这并不是我们想要的结果。在这种情况下增加的操作就丢失了,counter现在就永久的减少了一次增加统计。

你可能想就算漏加了几个1对于一个基于 Web 的服务程序来说也是一种可以接受的精度损失,有时候也确实如此,但是如果count 值被用来产生唯一标识符或者是序列号的话,就会造成严重数据完整性问题。当不走运的执行时间序列出现导致错误结果出现的可能性,在并发编程中它被称为竞争条件。

2.2.1. 竞争条件 

UnsafeCountingFactorizer 类中存在几个竞争条件,导致计算结果不准确。当计算正确性依赖于多线程执行的相对时间和交叉状态的话,竞争条件就产生了;换句话说,获取正确结果要靠运气。最常出现的竞争条件是“检查再执行”这种操作,常常导致基于陈旧的观察数据去决定下一步做什么。

在日常生活中,我们常常碰到竞争条件。比如你和朋友约好了下午在学校大道的星巴克咖啡店见面。但是当你达到学校大道的时候,你发现有两家星巴克咖啡店,你不知道应该到哪一家咖啡店去见你的朋友。在12点10分的时候,你在星巴克A店没看到你朋友,于是你去星巴克B店去找,但他也没在那。这样就有几种可能性:第一种可能性:你的朋友迟到了,不在任何一家咖啡店;第二种可能性:在你离开A店之后,你的朋友到达了A店或者是你的朋友到达了B店没找到你,所以在去A店的路上。来看看最糟糕的情况,比如在 12 点 15 分的时候,你们都到过了A店和B店,你们都怀疑自己被对方放了鸽子,那怎么办?再回到另一家咖啡店寻找?这样周而复始你们要来来回回几次?除非你们商量好了不找到对方誓不罢休,能够在挫败泄气的心理状况下就像打了咖啡因一样在学校大道走来走去走一天。

“我会掐着路走去看朋友是不是在另一个星巴克店”这种方法的问题在于当你沿着路走的时候,你的朋友已经开始移动。你在星巴克A看了一圈,观察认为“这小子不在这边”,然后又继续去找他。然后你在星巴克B又做了相同的事,但是和你在A做这件事的时候,显然不是同时的。你总要花费几分钟在路上走,恰恰就在这几分钟,系统的状态就发生了改变。

这个例子演示了一种竞争条件,期望结果(=遇到你的朋友)依赖于事件(当你们俩每个人到达星巴克或者另一个星巴克的时候,你会在那里等多久)发生的相对时机。“对方不在A店”这个观察结果在你从前门离开A店那一刻起就立刻就失效了,因为在那一刻你的朋友可能从后门进来了但是你却不知道。使用可能是陈旧的信息去做决定或者执行计算,这种就是大多数竞争条件的特点。这种竞争条件被称为“检查再执行”:你先观察一个事物结果定为true(文件X不存在),然后依赖于这个条件进行下一步操作(创建X),但事实上,在你的检查与运行之间这段时间里,你观察的结果可能已经失效了(在同时有人已经创建好了X文件),这就会引发问题(非期望的异常、覆盖数据、文件冲突)。

 

2.2.2. 例子:延迟初始化中的竞争条件 

一个通用的说法是“检查再执行”,就是延迟初始化。延迟初始化的目的是要在真正需要一个对象值的时候再对其进行初始化,并 且保证它只被初始化一次。下面代码演示了延迟初始化,getInstance 方法中首先检查ExpensiveObject对象是否已经被初始化,如果已经被初始化就将实例返回,否则创建一个新的实例,别把引用给instance变量,然后将该实例返回,这样以后方法再被执行的时候就能不用再执行那些开销大的代码了。

LazyInitRace 类中有竞争条件的存在,这将会破坏这个类的正确性。假设线程A和线程B同时执行getInstance方法,线程A看到 instance的值为null,然后为其初始化一个新的ExpensiveObject 对象,线程B也要检查instance的值是否为null,当然这个时候instance是否是null从时间执行顺序上看是无法预测的,这都依赖于调度执行的时序安排、A线程要花多久来初始化这个对象并赋值给instance变量。如果B检查的时候instance是null的,那么两个线程就会在调用getInstance方法获得两个不同的结果,即使这个方法本来的目的是返回相同的instance实例。

在UnsafeCountingFactorizer类中的计数操作是另一种竞争条件的案例。像增加计数这种读取-修改-写入操作,定义为一种的对象状态的转化,这种转化是就对象前一个状态而言的。在增加计数的时候,你一定要知道它前一个状态的值并确保在你修改的过程中没有其他人进行修改或者使用。

像大多数并发错误一样,竞争条件也并不总是会显现出错误的那种情况:点背的时候才会发现你写的这玩意有bug。但是竞争条件所造成的问题是很严重的问题。如果LazyInitRace 被用来做注册这种应用业务的初始化动作,多个调用返回不同的实例会造成注册的丢失、或者多个行为得到的注册对象的视图数据不一致。如果LazyInitRace 被用来在一个持久性框架中创建实体的标识,会导致两个不同的对象会以相同的ID作为结果,这就违反了标识完整性约束。

2.2.3. 复合操作

LazyInitRace 类和 UnsafeCountingRactorizer 类都包含了一系列需要保证原子性行为的操作,或者说是在同一情况下相对于其他操作来讲不可分割的一系列操作。为了避免竞争条件,必须找到一种方法,使得我们在对变量修改期间,其他线程无法使用该对象,这样就能够保证其他线程只能在我们开始操作或者结束操作进行观察和修改,而不是操作的过程中。

如果从执行A操作的线程角度来看,当另一个线程执行B操作时,B中的操作都已执行或者没有一个执行,则操作A和B相对于彼此是原子的。一个原子性操作是指不干涉其他操作的原子操作,包括在对一些状态进行操作时他自己的一些操作相对于本身也要保证原子性。

如果 UnsafeSequence 中 return value++语句是原子的,竞争条件就不会出现,每一个执行的增加数目的操作,都能保证每个调用获得想要的数字增加结果。为了确保线程安全,检查再执行(例如延迟初始化)和读取-修改-写入(例如数字增加)类型的操作都必须是原子的,我们将它们称为复合操作:为了保证线程安全,一系列操作都要原子性的执行。下一段中我们将讨论锁,它是 Java 中用来实现原子性操作的内建机制。现在我们来修复 UnsafeCountingRactorizer 的问题,见如下代码:

java.util.concurrent.atomic包内包括了原子性的变量类,它们的作用是保证数字或者对象引用发生转变时保证原子性。通过用AtomicLong对象代替了long类型,我们能够保证对count的所有操作都是原子的。由于在这里该servlet的状态就是count的状态体现出来的,这样的话,该servlet又是线程安全的了。

我们现在能为提供功能的servet加入一个counter,并且能够通过使用一个已经存在的线程安全类AtomicLong来管理counter的状态,维护线程安全。当一个单独的状态元素添加到无状态类中,如果能够保证这个状态完全被一个线程安全对象所管理,就能够保证得到的类依然是线程安全的。但是,就像我们下节会看到的从一个状态变成多个状态并不像无状态变成一个状态那么简单。

使用已有的线程安全对象AtomicLong这种来管理你的类的状态是非常实用的。现有的线程安全对象可能具备的状态和状态转换比其他的任意状态变量更简单,使得维护和核实线程安全变得更为容易。

2.3. 锁

如上一节所示,为一个无状态类添加一个状态变量,只要这个状态变量使用线程安全类来管理,那么这个类仍是线程安全的。但是如果我们想为一个无状态类添加更多的状态变量,只要添加的都是用线程安全类管理的状态变量就可以了吗?

假设两个连续的客户请求都是因数分解同一个数字,我们就想要在UnsafeCachingFactorizer类中缓存最近一次的计算结果用来优化性能。为了达到这个目标,我们需要记住两件事:上一次被因数分解的数字和它的因数。

我们在线程安全的要求下使用AtomicLong来管理counter的状态,或许我们也可以使用它的堂兄弟-AtomicReference(是用来保证线程安全的,就像AtomicLong保证long类型,这个是保证Object类型的)来管理上一次的数字和他的因数?下面的代码就是一次尝试:

不幸的是这种方法并不好使。即使这个原子性的引用单独的看是线程安全的,但是UnsafeCachingFactorizer 还是存在竞争条件,会产生错误的结果。

线程安全的定义是需要在某些时候保证“不变关系”不受多线程的执行顺序或者交叉执行过程所产生的操作的影响。在这个类里其中要保证的“不变关系”是缓存在lastFactors中的因数与lastNumber缓存的值的关系,这些因数的计算结果是要和lastNumber缓存的值保持一致的。当多个变量都参与到这个“不变关系”中,他们就不再是独立的:一个变量的值会约束另一个变量的值。因此,当更新一个的时候,你一定要用一个原子性的操作来更新其他的。

在某些不幸运的时刻,UnsafeCachingFactorizer会违背这种不变关系。虽然使用了原子引用这个类,我们还是无法同时更新 lastNumber 和 lastFactors 两个变量的值,即便是每一个调用set的过程都是原子性的。这是因为在调用两次的之间有一个缺陷出现的窗口,就是当一个被修改,另一个还没有被修改的时候。这个时刻其他的线程就会发现不变关系(也叫约束条件)就已经无法保持了。同样的,这两个值也不能被同时获取:在A线程获得两个变量值得中间过程中,线程B就已经改变了他们,这就造成线程A所观察到的不变关系已经失效了。

为了保证状态一致性,要在一个单独的原子操作中(不受其他线程侵入)更新所有的相关联状态变量。

2.3.1 内部锁

Java提供了内置的锁机制来保证原子性的强制执行:synchronized 块(在第三章有关于锁机制的缺点的批判和其他同步机制--能见度的论述)。Synchronized 块由两个部分组成,第一个部分是作为锁的对象的引用,第二个部分是被锁保护的一个代码块。synchronized 修饰的方法是 synchronized 块的一个变种,它把整个方法体都作为一个 synchronized 块,将调用该方法的对象作为锁(synchronized 修饰的静态static方法将类Class对象作为锁)。

每个 Java 对象都可以作为同步代码块的锁,这些内置的锁被称为内部锁或监视锁。在线程进入 synchronized 块之前自动获取锁,在线程离开 synchronized 块之后自动释放锁--不管是正常离开 synchronized 块还是抛出异常导致离开 synchronized 块。获得内部锁的唯一方法就是进入synchronized 修饰的代码块或者是由该锁保护的方法。

内部锁是一种互斥锁(排他锁),这意味着在任意时刻最多只能有一个线程可以获取这个锁。如果一个内部锁已经被线程B获取,线程A想要获取这个内部锁就必须等待,直到线程B释放该内部锁。如果线程B一直不释放,那么线程A就一直等着吧。

由于一次只允许有一个线程执行某个synchronized块中的代码,这就保证了用同一个锁的synchronized块中的代码可作为一个原子操作执行--不受其他线程影响。在并发的环境中,我们对原子性的定义是:就像一套事务性的应用一样,一组语句作为一个单独的、不可分割的单元去执行。不可能出现在某个线程正在执行 synchronized 块中的某条代码的时候,另一个线程进入这个 synchronized 块的情况。

有了这种同步机制,我们的因数分解 Servlet 就容易实现线程安全了。下面的代码将 service 方法修饰为 synchronized 的,这样,同时最多只有一个线程能够执行 service 方法中的代码。然而,这样做有点过了,因为它这么用会阻止多个用户同时使用这个因数分解servlet,这会降低Servlet的响应能力。但这是性能问题,而不是并发问题,对于这一点以后在2.5节再做讨论。

2.3.2 重入

当一个线程请求一个被其他线程获取的锁的时候,这个线程会阻塞。但是由于Java中的内部锁是可重入的,因此当一个线程请求一个已经被其获取的内部锁的时候,这个请求会成功。重入在这里意味着锁是由每个线程获取的而不是每次调用都要获取,是通过把每个锁和一个获得count计数、一个拥有它的线程关联起来实现的。当count是0的时候这个锁就被认为是没有被占有的;当一个线程获得了之前未被占有的锁的时候,JVM会记录下他的拥有者(线程是哪个)并把count记为1;当相同的线程又一次要获得这个锁,count就增加1;当拥有这个锁的线程退出一个synchronized 代码块的时候,count就减1;当count减少到0的时候,这个锁就被释放掉了。

可重入性促进了锁行为机制的封装,因此简化了面向对象并发代码的开发。下面代码中的子类重写了父类中的一个 synchronized 方法,并在自己的方法中调用了父类中对应的方法,如果没有可重入锁, 这样代码将会造成死锁。由于 Widget 类和 LoggingWidget 类中的 doSomething 方法都是 synchronized 的(这里原文的意思是锁是同一个是Widget对象,但是很多人包括我在内都认为锁应该是子类对象LoggingWidget),如果内部锁不是可重入的,那么super.doSomething()语句将永远无法获取到锁,因为这个锁被认为已经被获取了,然后super.doSomething()就只能永远等着这个它永远获得不到的锁(实际上他已经获得了,重入就是你有了再碰到就相当于获得了)。重入把我们从这种死锁的情况下拯救出来。

2.4 用锁来保证状态的一致性

使用锁来确保多线程对保护块中代码的顺序排队访问,通过使用这种方式我们可以达成协议,保证了对象共享变量的排他性访问。把这些协议贯彻到底能够确保状态的一致性。

对于共享对象中状态的复合操作,例如 count++或者延迟初始化操作,都必须是原子操作,这样才能避免竞争条件。虽然在整个复合操作期间通过拥有锁能够保证原子性操作,但是仅仅将复合操作用 synchronized 块包起来还是不够的。如果我们需要协调控制对变量的访问,那么在任何访问共享这个变量的地方都需要使用synchronized 块。进一步说,无论何时变量被访问都要用相同的锁来控制。

人们常犯的一个错误是认为只有在对共享变量进行写操作的时候才需要使用 synchronized 块,事实并非如此,我们将在 3.1 节详细讨论这个问题。

对于任意一个、可被多线程访问的易变状态变量,所有的访问 (包括读和写)操作都必须由同一个锁来保护。只有在这种情况下,我们才说那个变量被锁保护了。 

在图2.6中,lastNumber和lastFactors都被servlet对象的内置锁保护,注解@GuardedBy都标记出来了。

对象的内部锁和对象的状态之间并没有内在的关系。使用对象的内部锁来保护代码块只是为了方便并不是必须的,许多类觉得这种锁的惯例相当的有效就都这么用了。获得一个和对象相关联的锁,并不会阻止其他的线程访问这个对象,唯一能阻止访问的,是其他线程没有获得这个需要获得的相同的锁。事实上,每个对象都有一个内置锁,这样很方便,不需要你明确地去创建一个锁对象了。你可以决定创建锁的协定或者是同步规范来保证对共享状态的安全访问,并在你的程序中贯彻落实它们。

每一个被共享、容易被改变的变量都应该被一个唯一明确的锁保护,这样对于维护人员才是较为清晰地,能够知道到底是哪个锁。

实现线程安全类的一个常用方法是将所有对容易被改变的状态封装到一个对象中,保证这个对象在收到并发访问时,所有代码操作都是用该对象的内部锁进行同步的。这种方案被许多线程安全类使用,像Vector和其他synchronized的集合。在这种情况下,一个对象的状态中的所有变量都被对象的内部锁保护起来。然而,这种方法没什么特别的,编译器和运行状态下都不会强制你用这种方法。而且这种方案也会在你添加新的方法或者修改代码的时候,忘记添加这样的保护措施。

只有被多线程访问、容易被改变的数据才需要用锁进行保护,并不是所有数据都需要。在章节一中,我们描述了如何添加一个像TimeTask这样的简单异步事件,这会在整个程序中引起对线程安全的需求,特别是当你的程序封装的很差的时候。考虑到单线程程序运行了很大的数据量,而且他的数据又不用共享,所以就不需要用同步机制了。现在假设你要添加一个周期性创建快照的功能,用来保证当程序崩溃或者不得不停止的时候,不需要再从头开始启动。你可能会选择用TimeTask来每十分钟执行一次,把程序状态保存到一个文件中。

由于TimeTask会被另一个由Time管理的线程所调用,快照里的任何一个数据现在都会被两个线程访问:main线程和Timer线程。这就意味着不仅TimeTask在访问这些程序状态时需要使用同步,程序中其他和状态接触的代码也需要用同步机制。在整个程序中原来不需要同步的地方也都得改成使用同步了。

当一个变量被锁保护--意味着每一个对该变量的访问都需要拥有锁--你必须要确保在一个时刻只能有一个线程访问这个变量。当一个类里面有多个状态变量,他们之间存在互相约束,就会产生另一个需求:每一个参与到约束中的变量都需要同一个锁来保护。这样才能保证你在访问或者更新他们的时候是在一个原子操作中,保护了这份约束关系。SynchronizedFactorizer示范了这个规则:缓存的数字和缓存的它的分解因数都被servlet对象的内部锁所保护。

对于每一个涉及到多个变量参与的约束,其内的所有变量都要被同一个锁保护。

既然使用同步机制可以防止竞争条件,那么为什么不干脆将所有类中的方法都用 synchronized 关键字修饰呢?事实上,这种无差别使用同步的方法要么会过度同步,要么会同步不足。例如 Vector 类中所有方法都是用synchronized关键字修饰的,但是却不足以保证Vector上的复合操作是原子性的:

if (!vector.contains(element))

 vector.add(element);

这是个如果是空就放入的逻辑,存在竞争条件,即使contains方法和add方法都是原子性的。虽然synchronized方法能够使单个操作具有原子性,但是当多个原子操作组合在一起形成复合操作的时候,还是需要额外的锁机制。(Section4.4展示了向线程安全对象中安全地添加额外原子性操作的一些技巧)此外,到处使用synchronized关键字来修饰方法可能会造成活跃性和性能问题,在SynchronizedFactorizer我们就可以看到他的影响。

 

2.5 活跃性和性能

在 UnsafeCachingFactorizer 类中我们为了提高性能引入了因数分解缓存机制,但是缓冲机制需要用共享状态来实现,这就需要同步机制来维持对象状态不受意外影响。在SynchronizedFactorizer类中我们为整个service方法添加了synchronized修饰符,结果导致了性能低下。通过在整个service上使用同步,SynchronizedFactorizer保证每个状态变量都被servlet对象的内部锁来保护。这种粗粒度的解决方法虽然简单且保证线程安全,但是要付出很高的代价。

因为service被synchronized修饰,所以一次只能有一个线程去执行。SynchronizedFactorizer 类的做法违反了 Servlet 框架的使用目的:能够并发响应多个用户请求。这样的做法显然会造成负载很高时,用户请求时间过长甚至失败。如果servlet在分解一个很大的数字,那在它完成这个请求、能够开始分解下一个新的数字之前,其他客户都得都必须等待。即使系统有多个CPU,处理器在高负载的情况下也保持闲置的状态。在一些情况中,即使是很快就能跑完的请求,像请求分解那些已经缓存的数字,都会因为必须等待之前运行的较长时间的请求完成,而导致了很长的运行时间才能完成任务。

图2.1演示了当多用户请求到达的时候,SynchronizedFactorizer 是如何处理的:它将多个请求排队,然后逐个处理。我们可以把这个web应用描述成糟糕的并发示例:同时处理的请求数不是受计算资源的限制而是受到应用程序结构的限制。幸运的是,如果缩小 synchronized块的范围,就可以在维持线程安全性的前提下大大提高程序的并发响应能力。但你也应该小心,不能将synchronized块缩减得太小,不应该把被视为一个原子操作的一整段代码分散到多个 synchronized块中。对于那些不影响共享状态的耗时操作从 synchronized 块中分离出来是一个好主意,这样当耗时操作运行的时候,就不会阻止其他线程访问共享变量了。

表2.8里面的CachedFactorizer 类将 service 中的同步块分为两个部分。第一个 synchronized 块用于“检查后执行”序列,检查你是否可以从缓存获得结果。第二个 synchronized 块用于更新缓存的数字和缓存的因数。作为奖励,我们在类里面添加了“缓存命中”计数器,并在首个synchronized 代码块中更新它。因为这个计数也是由共享易变的变量组成的,所以在所有访问它的地方我们都要使用同步。Synchronized 块之外的代码只操作局部变量,这些局部变量只对当前线程有效,不是共享状态的一部分,因此不需要同步。

CachedFactorizer 类不再使用 AtomicLong 来声明hits计数变量,而是重新使用 long 类型。当然这里使用 AtomicLong 也是可以的,但是并不能够比使用 long 能提供更多的好处。既然我们已经使用 synchronized 块了,就没必要再使用 AtomicLong 了。在单个变量的情况下,原子性的变量对于原子操作的控制是非常有效的,但是由于我们已经使用了synchronized 块来构建原子性操作,要是使用两种同步机制会让人感到混乱而且对安全和性能也没什么益处。

CachedFactorizer 类的结构既能够提供并发安全性,又能最大限度地提高响应速度。获得锁和释放锁都会有一些开销,所以被synchronized块分的太小并不是很好,尽管并不会对原子性造成破坏。CachedFactorizer 类会在每一个线程访问状态变量和复合操作进行期间拥有锁,在执行可能的耗时分解因数操作前释放锁给其他线程使用。这样的话就能在没有过度影响并发的情况下保证线程安全,每个synchronized块都是“足够小”的。

决定每个synchronized块的大小需要权衡各个因素,包括安全(这是绝对不能违反的)、简易性、性能等。尽管CachedFactorizer 简要展示了一个很好的设计,但是有时简易性和性能两个因素相对于彼此并不是总是拥有平等的权重。当然一个较为合理的平衡点还是能够被找到的。

简易性和性能之间总有你消我长的关系,当你实现一个同步策略时,你要优先保证不违背安全原则,抵抗住诱惑--过早地通过牺牲简易性来追逐性能的提升,你要一步步来完后优化。

无论何时使用锁,你应该清楚 synchronized 块中的代码是做什么的,有没有可能执行耗时操作。不管你是要进行计算密集型操作,还是要执行一个潜在的阻塞式操作,都会导致你占有锁过久,这就会产生风险,引入活跃性或者性能问题。

要避免在一些操作中占有锁--耗时的计算密集型操作或者那些不能很快完成的操作:比如网络I/O和控制台I/O上。

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值