1 线程安全(thread safety)
构建并发程序也要正确使用线程和锁。编写线程安全的代码,本质上就是管理对状态的访问,而且通常都是共享的、可变的状态。
通俗的说,一个对象的状态就是它的数据,存储在状态变量中,比如实例域或静态域。对象的状态还包括了其他附属对象的域,如HashMap的状态一部分存储到对象本身中,但同时也存储到很多Map.Entry对象中。
共享:指一个变量可以被多个线程访问。
可变:指变量的值在其生命周期内可以改变。
无论何时,只要有多余一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。
Java中首要的同步机制是synchronized关键值,它提供了独占锁,除此之外,术语“同步”还包括volatile变量、显示锁和原子变量的使用。
1.1 什么事线程安全性
一个类是线程安全的,是指在被多个线程访问时,类可以进行持续的正确的行为。
1.2 原子性
原子操作:可以作为一个单独的、不可分割的操作去执行。
竞争条件:当计算的正确性依赖于运行时中相关的时序或者多线程的交替时,会产生竞争条件。最常见的一种竞争条件是“检查再运行”。
竞争条件的原因:为获取期望的结果,需要依赖相关的事件发生。
检查再运行:使用潜在的过期观察值来做决策或执行计算。
检查在运行的常见用法是惰性初始化。惰性初始化的目的是延迟对象的初始化,直到程序真正使用它,同事确保它只初始化一次。例如:
LazyInitRace中的竞争条件会破坏其正确性。比如,线程A和B同时执行getInstance方法,A看到instance是null,并实例化一个新的ExpensiveObject。同时B也在检查instance是否为null。此时此刻的instance是否为null,这依赖于时序,这是无法预期的。它包括调度的无偿性,以及A初始化ExpensiveObject并设置instance域的耗时。如果B检查到instance为Null,两个调用者会得到不同的结果,然而我们期望getInstance总是返回相同的实例。
Java.util.concurrent.atomic包中包括了原子变量类,这些类用来实现数字和对象引用的原子状态转换。把long类型的计数器替换为AtomicLong类型的,我们可以确保所有访问计数器状态的操作都是原子的。
1.3 锁
在该例子中,我们不能保证会同时更新lastNunber和lastFactors。当某个线程只修改了一个变量而另一个还没有开始修改时,其他线程将看到servlet违反了不便约束,这样会形成一个程序漏洞。
1.3.1 内部锁
Java提供了强制原子性的内置锁机制:synchronized块。一个synchronized块分两部分:锁对象的引用以及这个锁保护的代码块。
Synchronized方法是对跨越了整个方法体的synchronized块的简单描述,至于synchronized方法的锁,就是该方法所在的对象本身。静态的synchronized方法从Class对象上获取锁。
每个java对象都可以隐式地扮演一个用于同步的锁的角色。这些内置的锁被称为内部所(intrinsic locks)或监视器锁(moniter locks)。
执行线程进入synchronized块之前会自动获得锁,而无论通过正常控制路径退出,还是从块中抛出异常,线程都在放弃对synchronized块的控制时自动释放锁。获得内部锁的唯一途径是:进入这个内部锁保护的同步块或方法。
内部锁在java中扮演了互斥锁的角色,意味着至多只有一个线程可以拥有锁。
1.3.2 重进入
当一个线程请求其他线程已经占有的锁时,请求线程被阻塞。然而内部锁时可重进入的,因此线程在试图获得它自己占有的锁时,请求会成功。重进入意味着锁的请求时基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的。
重进入的实现是通过为锁关联一个请求计数和一个占有它的线程。当计数为0时,认为锁时未被占有的。线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数置为1。如果同一个线程再一次请求这个锁,计数将递增;每次占用线程退出同步块,计数器值将递减,知道计数器达到0时,锁被释放。
1.4 用锁来保护状态
一种常见的错误观念认为只有写入共享变量时才需要同步,其实并非如此。
锁保护的:对于每个可被多个线程访问的可变状态变量,如果所有访问它的线程在执行时都占有同一个锁,这种情况下,我们称这个变量是由锁保护的。
对象的内部锁与它的状态之间没有内在的关系。尽管大多数类普遍使用这样一种非常有效的锁机制:用对象的内部锁来保护所有的域,然而这并不是必须的。即使获得了与对象关联的锁,也不能阻止其他线程访问这个对象。获得对象的锁后,唯一可以做的事情是阻止其他线程再获得相同的锁。
每个共享的可变变量都需要由唯一一个确定的锁保护,而维护者应该清楚这个锁。
一种常见的锁规则是在对象内部封装所有的可变状态,通过对象的内部锁来同步任何访问可变状态的代码路径,保护它在并发访问中的安全。例如Vector和其它同步容器(collection)类。
如同Vector这样,仅仅同步它每一个方法,并不足以确保在Vector上执行的复合操作是原子的。
虽然contains和add都是原子的,但尝试“缺少即加入”操作的过程中任然存在竞争条件。虽然同步方法确保了不可分割的原子性,但是把多个操作整合到一个复合操作时,还是需要额外的锁。同时,同步每个方法还会导致活跃度或性能问题。
1.5 活跃度和性能
这些请求排队等候并依次被处理,我们把这种web应用的运行方式描述为弱并发。
通过缩小synchronized块的范围来维护线程安全性,我们很容易提升servlet的并发性。尽量从synchronized块中分离耗时的且不影响共享状态的操作。
请求与释放锁的操作需要开销,所以将synchronized块分解得过于琐碎事不合理的。
决定synchronized块的大小需要权衡各种设计要求,包括安全性、简单性和性能。有时简单性和性能会彼此冲突。
2 共享对象
存在一中误解:认为synchronized仅仅用于原子操作或者划定“临界区”。同步同样还具有另外一个重要、微妙的方面:内存可见性。我们不仅希望能够避免一个线程修改其他线程正在使用的对象的状态,而且希望确保当一个线程修改了对象状态后,其他的线程能够真正看到改变。
2.1 可见性
Novisibility可能会一直保持循环,因为对于读线程来说,ready的值可能永远保持不可见。甚至奇怪的现象是,Novisibility可能会打印0。因为早在对number赋值之前,主线程就已经写入ready并使之对读取线程可见,这是一种“重排序”现象。
重排序:java虚拟机能够充分利用现代硬件的多核处理器的性能。例如在没有同步的情况下,java存储模型允许编译器重排序操作。在寄存器中缓存数值,还允许CPU重排序,并在处理器特有的缓存中缓存数值。
在没有同步的情况下,编译器、处理器、运行时安排操作的执行顺序可能完全出人意料。
2.1.1 过期数据
仅仅是同步的setter是不够的,调用get的线程任然能够看见过期值。
在没有同步的情况下读取数据类似与数据库中使用read_uncommit隔离级别。
2.1.2 非原子的64位操作
Java存储模型要求获取和存储操作都为原子的,但是对于非volatile的long和double变量,JVM允许将64位的读或写划分为两个32位的操作。如果读和写发生在不同的线程,这种情况读取一个非volatile类型long就可能会出现得到一个值的高32位和另一个值的低32位。
2.1.3 锁和可见性
内置锁可以用来确保一个线程以某种可预见的方式看到另一个线程的影响。
当B执行到与A相同的锁监视的同步块时,A在同步块之中或之前所做的事情对B是可见的。如果没有同步,就没有这样的保证。
访问一个共享变量时,多线程由同一个锁进行同步时因为:第一,保证一个线程对数值进行的写入,其他线程都可见。第二,如果一个线程在没有恰当使用锁的情况下读取了变量,那么这个变量很可能是一个过期的数据。
2.1.4 Volatile变量
Volatile变量:它确保一个变量的更新以可预见的方式告知其他的线程。当一个域声明为volatile类型后,编译器与运行时会监视这个变量:它是共享的,而且对它的操作不会与其它的内存操作一起被重排序。Volatile变量不会缓存 在寄存器或者缓存在对其他处理器隐藏的地方。所以,读取一个volatile类型的变量时,总会返回由某一线程所写入的最新值。
访问volatile变量的操作不会加锁,也就不会引起线程的阻塞,这使得volatile变量相对于synchronized而言,只是轻量级的同步机制。
Volatile变量对可见性的影响所产生的价值远远高于变量本身。线程A向volatile变量写入值,随后线程B读取该变量,所有A执行写操作前可见的变量的值,在B读取了volatile变量后,成为对B也是可见的。从内存可见性的角度看,写入volatile变量就像退出同步块,读取volatile变量就像进入同步块。
只有满足了下面所有的标准后,你才能使用volatile变量:
Ø 写入变量时并不依赖变量的当前值;或者能够确认只有单一的线程修改变量的值;
Ø 变量不需要与其他的状态变量共同参与不变约束;
Ø 而且,访问变量时,没有其他的原因需要加锁;
2.2 发布和逸出
发布(publish):一个对象的意识是使它能够被当前范围之外的代码所使用。比如将一个引用存储到其他代码可以访问的地方,在一个非私有的方法中返回这个引用,也可以把它传递到其他类的方法中。在很多情况下,我们需要确保对象及它们的内部状态不被暴露(publish)。
如果发布对象时,它还没有完成构造,同样危及线程安全。一个对象在尚未准备好时就将他发布,称为逸出。
最常见的发布对象的方式是将对象的引用存储到公共静态域中。任何类和线程都能看见这个域。
发布一个对象,同样也发布了该对象所有非私有域所引用的对象。且在一个已经发布的对象中,哪些非私有域的引用链和方法调用链中的可获得对象也都会被发布。
最后一种发布对象和他的内部状态的机制是发布一个内部实例类。
2.2.1 安全构建的实战
ThisEscape演示了一种重要的逸出特例,this引用在构造时逸出。发布的内部EventListener实例是一个封装的ThisEscape中的实例。但是这个对象只有通过构造器函数返回后,才处于可预言的、稳定的状态,所以从构造器函数内部发布的对象,只是一个未完成构造的对象。
一个导致this引用在构造期间逸出的常见错误,是在构造函数中启动一个线程。当对象在构造函数中创建一个线程时,无论是显式还是隐式,this引用几乎总是被新线程共享。
2.3 线程封闭
访问共享的、可变的数据要求使用同步。一个可以避免同步的方式是不共享数据。如果数据仅在单线程中被访问,就不需要任何同步。线程封闭计数是实现线程安全的最简单的方式之一。
2.3.1 Ad-hoc线程限制
Ad-hoc线程限制:是指维护线程限制性的任务全部落在实现上的这种情况。
2.3.2 栈限制
栈限制是线程限制的一种特列,在栈限制中,只能通过本地变量才可以触及对象。本地变量是对象更容易被限制在线程本地种。本地变量本身就被限制在执行线程中。它们存在于执行线程栈。其他线程无法访问这个栈。
2.3.3 ThreadLocal
一种维护线程限制的更加规范的方式是使用ThreadLocal,它允许将每个线程与持有数值的对象关联在一起。ThreadLocal提供了get和set访问器,为每个使用它的线程维护一份单独的拷贝。所以get总是返回当前执行线程通过set设置的最新值。
线程本地变量通常用于防止在基于可变的单元或全局变量的设计中,出现共享。
线程首次调用ThreadLocal.get方法时,会请求initialValue提供一个初始值。
2.4 不可变性
为了满足同步的需要,另外一种方法是使用不可变对象。创建后状态不能被修改的对象叫做不可变对象。不可变对象天生是线程安全的。
不可变对象是简单的。它们只有一种状态,构造器函数谨慎地控制着这个状态。
不可变性并不简单地等于将对象中的所有域都声明为final 类型,所有域都是final类型的对象任然可以是可变的,因为final域可以获得一个到可变对象的引用。
在不可变对象的内部,同样可以使用可变性对象来管理它们的状态。
2.4.1 final 域
Final域使得确保初始化安全性成为可能,初始化安全性让不可变性对象不需要同步就能自由地被访问和共享。
即使对象是可变的,将一些域声明为final类型任然有助于简化对其状态的判断。因为限制了对象的可见性,也就约束了其成为可能的状态集。
2.4.2 使用volatile发布不可变对象
使用volatile变量也是不能保证线程安全性的,但是有时不可变对象也可以提供一种弱形式的原子性。
使用不可变对象,一旦一个线程获得了它的引用,永远不必担心其他线程会修改它的状态。如果更新变量,会创建新的容器对象。不过在此之前任何线程都还和原来的容器打交道,任然看到它处于一致的状态。
VolatileCachedFactorizer利用OneValueCache存储缓存的数字及其因素。当一个线程设置volatile类型的cache域引用到一个新的OneValueCache后,新数据会立即对其他线程可见。
与cache域相关的操作不会相互干扰,因为OneValueCache是不可变的,而且每次只有一条相应的代码访问路径访问它。
2.5 安全发布
2.5.1 当好对象变坏时
你无法信赖局部创建对象,一个监视着处于不一致状态对象的线程,会看到尽管该对象自发布之后从未修改过,但是它的状态还是会发生突变。
因为没有同步来确保Holder对其他线程可见,所以我们称Holder是“非正确发布的”。没有正确发布的对象会导致两种错误:首先,发布线程以外的任何线程都可以看到Holder域的过期值,因而看到的是一个null引用或者旧值,即使此刻Holder已经被赋予新值。其次,线程看到的Holder引用时最新的,然而Holder状态却是过期的。
2.5.2 不可变对象与初始化安全性
出于不可变对象的重要性,Java存储模型为共享不可变对象提供了特殊的初始化安全性的保证。正如我们所见,对象的引用对其他线程可见,并不意味对象的状态一定对消费线程可见。为了保证对象状态有一个一致性视图,我们需要同步。
即使发布对象引用时没有使用同步,不可变对象任然可以被安全地访问。为了获得这种初始化安全性的保证,应该满足所有不变性的条件:不可修改的状态,所有域都是final类型的以及正确的构造。
2.5.3 安全发布的模式
如果一个对象不是不可变的,它就必须被安全的发布,通常发布线程与消费线程都必须同步化。
线程安全容器的同步,意味着将这些对象置入这些容器的操作遵守了前述的最后一条要求。
Ø 置入hashtable、synchronizedMap、ConcurrentMap中的主键以及键值,会安全地发布到可以从Map获得它们的任意线程中,无论是直接还是通过迭代器获得。
Ø 置入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或者synchronizedSet中的元素,安全地发布到可以从容器中获得它的任意线程中
Ø 置入BlockingQueue或者ConcurrentLinkedQueue的元素,会安全地发布到可以从队列中获得它的任意线程中。
Ø
类库中的其他交互机制如Future和Exchanger同样创建了安全发布。
通常以最简单和最安全的方式发布一个被静态创建的对象,就是使用静态初始化器。
静态初始化器由JVM在类的初始化阶段执行,由于JVM内在的同步,该机制确保了以这种方式初始化的对象可以被安全地发布。
2.5.4高效不可变对象
有些对象在发布后就不会被修改,其他线程想要在没有额外同步的情况下安全地访问它们,此时安全发布至关重要。所有的安全发布机制都能保证,只要一个对象“发布当时”的状态对所有访问线程都可见,那么到它的引用也都是可见的。如果“发布当时”的状态不会再改变,那么确保任意访问是安全的,就变得很重要。
有效不可变对象:一个对象在技术上不是不可变的,但是它的状态不会再发布后被修改。
2.5.5 可变对象
2.5.6 安全地共享对象
3 组合对象
3.1 设计线程安全的类
尽管讲所有的状态都存储在公共静态域中,任然能写出线程安全的程序,但是比起那些经过适当封装的类来说,我们难以验证这种程序的线程安全性,也很难再修改它们的同时,保证不破坏它的线程安全性。
如果一个对象的域引用了其他对象,那么它的状态也同时包含了被引用对象的域。例如,LinkedList的状态包括了存储在链表中的节点对象的状态。
同步策略:定义了对象如何协调对其状态的访问,并且不会违反它的不变约束或后验条件。它规定了如何把不可变性、线程限制和锁结合起来,从而维护线程的安全性,还指明了那些锁保护那些变量。
3.1.1 收集同步需求
维护类的线程安全性意味着确保在并发访问的情况下,保护它的不变约束。这需要对其状态进行判断。对象与变量拥有一个状态空间:它们可能处于的状态范围。尽量使用final类型的域,就可以简化我们对对象的可能状态进行分析。
很多类通过不可变约束来判定某种状态是合法的还是非法的。
操作的后验条件会指出某种状态转换时非法的。
3.1.2 状态依赖的操作
若一个操作存在基于状态的先验条件,则把它称为是状态依赖的。
在Java中,等待特定条件成立的内置高效机制----wait和notify。与内部锁紧密地绑定在一起。
3.2 实例限制
通过使用实例限制,封装简化了类的线程安全化工作。当一个对象被另一个对象封装时,所有访问被封装对象的代码路径就是全部可知的,这相比让对象可被整个系统访问来说,更容易对代码路径进行分析。
平台类库中有很多线程限制的实例,包括一些类,它的存在就是为了把非线程安全的类转化为线程安全的。ArrayList和HashMap这样的基本容器类就是非线程安全的,但类库提供了包装器工厂方法,使这些非线程安全的类可以安全地用于多线程环境中。这些工程方法利用装饰器模式,使用一个同步的包装器对象包装容器,包装器将相关接口的每个实现为同步方法,将请求转发到下层容器对象上。
3.2.1 Java监视器模式
线程限制原则的直接推论之一是Java监视器模式。遵循Java监视器模式的对象封装了所有的可变状态,并由对象自己的内部锁保护。
使用私有锁对象,而不是对象的内部锁,有很多好处。私有的锁对象可以封装锁,这样客户代码无法得到它。然而可公共访问的锁允许客户代码涉足它的同步策略。
3.3 委托线程安全
基于“委托”的代码返回一个不可变的,但却是“现场”的location视图。这意味着线程A调用getLocations时,线程B修改了一些Point的Location,这些变化会反映到返回给线程A的Map值中。
4 构建块
4.1 同步容器
同步容器类包括两部分,一个是Vector和Hashtable,另外是它们的同系容器:由Collections.synchronizedXXX工厂方法创建的。
4.1.1 同步容器中出现的问题
同步容器都是线程安全的。但是对于复合操作,有时需要使用额外的客户端加锁进行保护。复合操作包括:迭代、导航、以及条件运算,比如缺少及加入。
在一个同步的容器中,这些复合操作即使没有客户端加锁的保护,技术上也是安全的,但是当其他线程能并发修改容器的时候,就不会按照你期望的方式工作了。
好在同步容器遵守一个支持客户端加锁的同步策略,因此只要我么知道应该使用哪一个锁,就有可能针对其他的容器操作创建新的原子操作。同步容器类通过对它的对象自身进行加锁,保护它的每一个方法。
4.1.2 迭代器和ConcurrentModificationException
在设计同步容器返回的迭代器时,并没有考虑到并发修改的问题,它们是“及时失败”的,即意思是当它们察觉容器在迭代开始后被修改,会抛出一个未检查的ConcurrentModificationException异常。
有一些原因造成我们不愿意在迭代期间对容器加锁。在迭代期间,对容器加锁的一个替代办法是复制容器。因为复制是线程限制的,没有其他的线程能够在迭代期间对其进行修改。
复制容器会有明显的性能开销。
4.1.3 隐藏迭代器
在一个可能发生迭代的共享容器中,各处都需要锁。
容器的hashCode和equals方法也会间接地调用迭代,比如当容器本身作为一个元素时,或者作为另一个容器的key时。类似地,containsAll、removeAll和retainAll方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些对迭代的间接调用都会引起ConcurrentModificationException。
4.2 并发容器
Java5.0通过提供几种并发的容器类来改进同步容器。同步容器通过对容器的所有状态进行串行访问,从而实现了它们的线程安全。然而代价是消弱了并发性,当多个线程共同竞争容器及的锁时,吞吐量就会降低。
并发容器是为多线程并发访问而设计的。ConcurrentHashMap来替代同步的哈希Map实现。新的ConcurrentMap接口加入了对常见复合操作的支持,如“缺少即加入”、替换和条件删除。
当多数操作作为读取操作时,CopyOnWriteArrayList是List相应的同步实现。
Java5.0 同样添加了两个新的容器类型:Queue和BlockingQueue。Queue用来临时保存正在等待进一步处理的一系列元素。JDK提供了几种实现:包括传统FIFO队列,ConcurrentLinkedQueue;一个(非并发)具有优先级顺序的队列PriorityQueue。Queue操作不会阻塞;如果队列是空,那么从队列中获取元素的操作返回Null。
BlockingQueue扩展了Queue,增加了可阻塞的插入和获取操作。如果对列是空的,一个获取操作会一直阻塞知道队列中存在可用元素。
4.2.1 ConcurrentHashMap
同步容器类在每个操作的执行期间都持有一个锁。有一些操作,比如HashMap.get或者List.contains,可能会涉及到比预想更多的工作量:为寻找一个特定对象而边防整个哈希容器或清单,必须调用大量候选对象的equals。
ConcurrentHashMap和HashMap一样是一个哈希表,但是它使用完全不同的策略,可以提供更好的并发性和可伸缩性。ConcurrentHashMap使用一个更加细化的锁机制(分离锁)。这个机制允许更深层次的共享访问。任意数量的读写线程可以并发访问Map,读者和写者也可以并发访问Map,并且有限数量的写线程还可以并发修改Map。为并发访问带来了更高的吞吐量。
ConcurrentHashMap返回的迭代器具有弱一致性。而非“及时失败”的。弱一致性的迭代器可以容许并发修改,当迭代器被创建时,它会遍历已有的元素,并且可以(但是不保证)感应到在迭代器被创建后对容器的修改。
对整个Map的操作方法如size和isEmpty,它们的语义在反映容器并发特性上被轻微地弱化了。因为size的结果对于在计算的时候可能已经过期,它仅仅是一个估算值,所以允许size返回一个近似值而不是一个精确值。
4.2.2 Map附加的原子操作
因为ConcurrentHashMap不能够在独占访问中被加锁,故不能使用客户端加锁来创建新的原子操作。
如对Vector做的“缺少即加入”操作。不过一些常见的复合操作,如“缺少即加入”,“相等便移除”和“相等便替换”都已经被实现为原子操作。
4.2.3 CopyOnWriteArrayList
CopyOnWriteArrayList是同步List的一个并发替代品,通常情况下提供了更好的并发性,并避免了在迭代期间对容器加锁和复制。
“写入时复制(copy-on-write)”容器的线程安全性来源于这样一个事实:只要有效的不可变对象被正确发布,那么访问它将不再需要更多的同步。在每次需要修改时,它们会创建并重新发布一个新的容器拷贝,以此来实现可变性。
“写入时复制(Copy-on-write)”容器的迭代器保留一个底层基础数组的引用,这个数组作为迭代器的起点,永远不会被修改,对它的同步只不过是为了确保数组内容的可见性。
在每次容器改变时复制基础数组需要一定的开销,特别是当容器比较大的时候。当对容器的迭代操作频率远远高于对容器修改的频率时,使用“写入时复制”容器是个合理的选择。
4.3 阻塞队列和生产者-消费者模式
4.3.1 连续的线程限制
在java.util.concurrent中实现的阻塞队列,全部都包含充分的内部同步,从而能安全地将对象从生产者线程发布至消费者线程。
对于可变对象,生产者-消费者设计和阻塞队列一起,为生产者和消费者之间移交对象所有权提供了连续的线程限制。一个线程约束的对象完全由单一线程所有,但是所有权可以通过安全的发布被“转移”,这样其他线程中只有唯一一个能够得到访问这个对象的权限,并且保证移交之后原线程的所有者不会再触及它,这样使得对象完全受限于新线程。这个新的主人可以任意修改,因为它具有独占访问权。
4.3.2 双端队列和窃取工作
Java6增加了两个容器类型,Deque和BlockingDeque,它们分别扩展了Queue和BlockingQueue。Deque是一个双端队列,允许高效在头和尾分别进行插入和移除。实现它们的是ArrayDeque和LinkedBlockingDeque。
一个消费者生产者设计中,所有的消费者共享一个工作队列;在窃取工作的设计中,每个消费者都有自己的双端队列。如果一个消费者完成了自己双端队列中的全部工作,它可以偷取其他消费者的双端队列中的末尾任务。
4.4 阻塞和可中断的方法
BlockingQueue的put和take方法会抛出一个受检查的InterruptedException,这与类库中其他的一些方法是相同的,比如Thread.sleep。当一个方法能够抛出InterruptedException时,是在告诉你这个方法是一个可阻塞方法,如果它被中断,将可以提前结束阻塞状态。
Thread提供了interrupt方法,用来中断一个线程,或者查询某线程是否已经被中断。每个线程都有一个boolean类型的属性,它代表了线程的中断状态。
中断是一种协作机制。一个线程不能够迫使其他线程停止正在做的事情,或者去做其他事情。当线程A中断B时,A仅仅要求B在达成某个方便停止的关键点时,停止正在做的事情。
当你在代码中调用了一个会抛出InterruptedException的方法时,你自己的方法也就成为了一个阻塞方法,要为响应中断做好准备。有两种选择:
传递InterruptedException:如果能侥幸避开异常的话,最明智的策略把InterruptedException传递给你的调用者。要么不捕获InterruptedException,要么先捕获,然后对其中特定活动进行简洁地清理,然后再抛出。
恢复中断:有时候你不能抛出InterruptedException,比如你的代码是Runnable的一部分。在这种情况下,你必须捕获InterruptedException。并且在当前线程中调用interrupt从中断中恢复,这样,调用栈中更高层的代码可以发现中断已经发生。
4.5 Synchronizer
阻塞队列在容器中是独一无二的:它们不仅作为对象的容器,而且能够协调生产者线程和消费者线程之间的控制流。因为take和put方法保持阻塞状态知道队列进入了期望的状态。
Synchronizer是一个对象,它根据本身的状态调节线程的控制流。阻塞队列可以扮演一个Synchronizer的角色。其他类型的Synchronizer包括信号量(semaphore)、关卡以及闭锁(latch)。
所有的Synchronizer都享有类似的结构特性,它们封装状态,而这些状态决定着线程执行到某一点时是通过还是被迫等待;它们还提供操控状态的方法,以及高效等待Synchronizer进入期望状态的方法。
4.5.1 闭锁
闭锁:是一种Synchronizer,它可以延迟线程的进度直到线程终止状态。
一个闭锁工作起来就像一道大门,直到闭锁到达终点状态之前,门一直是关闭的,没有线程能够通过,在终点状态到来的时候,门开了,允许所有线程都通过。一旦闭锁到达了终点状态,它就不能再改变状态了,所以它会永远保持敞开状态。
闭锁可以用来确保特定活动直到其他的活动完成后才发生:
Ø 确保一个计算不会执行,直到需要的资源被初始化。
Ø 确保一个服务不会开始,直到它以依赖的其他服务都已经开始。
Ø 等待,直到活动的所有服务都为继续处理做好充分准备。
CountDownLatch是一个灵活的闭锁实现,允许一个或多个线程等待一个事件的发生。闭锁的状态包括一个计算器,初始化一个正数,用来表现需要等待的时间数。countDown方法对计数器做减操作,表示一个事件已经发生了,而awati方法等待计数器达到零,此时所有需要等待的时间都已经发生。
4.5.2 FutureTask
FutureTask同样可以作为闭锁。FutureTask的计算是通过Callable实现的,它等价于一个可携带结果的Callable,并且有三个状态:等待、运行和完成。
Future.get的行为依赖于任务的状态。如果它已经完成,,get可以立刻得到返回的结果,否则会被阻塞直到任务转入完成状态,然后返回结果或者抛出异常。
Executor框架利用FutureTask来完成异步任务,并可以用来进行任何潜在的耗时计算,而且可以在真正计算结果之前就启动它们开始计算。
4.5.3 信号量
计算信号量(Counting semaphone)用来控制能够同时访问特定资源的活动的数量,或者同时执行某一给定操作的数量。计算信号量可以实现资源池或者给一个容器限定边界。
一个semaphone管理一个有效的许可集。许可的初始量通过构造器传递给semaphone。活动能够获得许可(只要还有剩余许可),并在使用之后释放许可。如果已经没有可用的许可,那么acquire会被阻塞,直到有可用的为止。Release方法向信号量返回一个许可。
计算信号量的一种退化形式是二元信号量:一个计数初始值为1的semaphone。二元信号量可用作互斥锁(mutex),它有不可重入的语义。
信号量可以用来实现资源池,比如数据连接池。
4.5.4 关卡
关卡类似与闭锁,它们都能够阻塞一组线程,直到某事件发生。其中关卡与闭锁的关键不同在于,所有线程必须同时到达关卡点,才能继续处理。闭锁等待的是事件,而关卡等待的是其他线程。
关于实现的协议,就像一些家庭成员指定商场中的集合地点:“我们每个人6:00在麦当劳见,到了以后我们再做决定接下来做什么”。
CyclicBarrier允许一个给定数量的成员多次集中在一个关卡点。这在并行迭代算法中非常有用,这个算法会把一个问题拆分成一系列相互独立的子问题。当线程到达关卡点时,调用await,await会被阻塞,直到所有线程都到达关卡点。如果所有线程都到达了关卡点,关卡点被成功突破,这样所有线程都被释放,关卡会重置以备下一次使用。CyclicBarrier允许向构造器传递一个关卡行为,它是runnable,当成功通过关卡的时候,会在子线程中执行,但是在阻塞线程被释放之前是不能执行的。
Exchanger是关卡的另一种形式,它是一种两步关卡,在关卡点会交换数据。当两方进行的活动部对称时,exchanger是非常有用的。
4.5.6 建立高效、可伸缩的高速缓存
5 执行任务
5.1 Executor框架
作为Executor框架的一部分,java.util.concurrent提供了一个灵活的线程池实现。
Executor只是个简单的接口,但它却为一个灵活而强大的框架创造了基础。这个框架可以用于异步任务执行,而且支持很多不同类型的任务执行策略。它为任务提交和任务执行
之间的解耦提供了标准的方法,为使用Runnable描述任务提供了通用的方式。Executor的实现还提供了对生命周期的支持以及钩子函数,可以添加诸如统计收集应用程序管理机制和监视器等扩展。
Executor基于生产者-消费者模式。
5.1.1 使用Executor实现的Web Server
在TaskExecutionWebServer中,我们通过使用Executor将处理请求任务的提交与它的执行体进行解耦。只要替换一个不同的Executor实现,就可以改变服务器的行为。
5.1.2 执行策略
将任务的提交与任务的执行进行解耦,其价值在于让你可以简单地为一个类给定的任务制定执行策略。一个执行策略指明了任务执行的几个因素:
Ø 任务在什么(what)线程中执行
Ø 任务以什么(what)顺序执行(FIFO,LIFO,优先级)?
Ø 可以有多少(how many)个任务并发执行?
Ø 可以有多少(how many)个任务进入等待执行队列?
Ø 如果系统过载,需要放弃一个任务,应该挑选哪一个(which)任务?另外如何(how)通知应用程序知道这一切呢?
Ø 在一个任务的执行前与结束后,应该做什么(what)处理?
执行策略是自愿管理工具。最佳策略取决于可用的计算资源和你对服务质量的需求。通过限制并发任务的数量,你能确保应用程序不会由于自愿耗尽而失败,大量的任务也不会再争夺稀缺资源出现性能问题。
5.1.3 线程池
线程池管理一个工作者线程的同构池。线程池是与工作队列紧密绑定的。工作队列,其作用是持有所有等待执行的任务。
工作者线程:它从工作队列获取下一个任务,执行它,然后回来继续等待另一个线程。
在线程池中执行线程,这种方法有很多“每任务没线程”无法比拟的优势。重用存在的线程,而不是创建新的线程,这可以在处理多请求时抵消线程创建、消亡产生的开销。另外,在请求到达时,工作者线程通常已经存在,用于创建线程的等待时间不会延迟任务的执行,提高了响应性。
Executors中的静态工厂方法创建一个线程池:
Ø newFixedThreadPool
Ø newCachedThreadPool
Ø newSingleThreadExecutor
Ø newScheduledThreadPool
使用Executor为你打开了一扇通往无线可能的大门,你有各种机会去做调优、管理、监视、记录日志、错误报告和其它可能的事情。
5.1.4 Executor的生命周期
Executor实现通常只是为执行任务而创建线程,但是JVM会在所有线程全部终止后才退出,因此,如果无法正确关闭Executor,将会阻止JVM的结束。
因为Executor是异步地执行任务,所以在任何时间里,所有之前提交的任务的状态都不能立即可见。这些任务,有些可能已经完成,有些可能正在运行,有些可能在队列汇总等待执行。
平缓的关闭:已经启动的任务全部完成而且没有在接到任何新的工作。
唐突的关闭:拔掉机房的电源
ExecutorService接口扩展了Executor接口,并且添加了一些用于生命周期管理的方法。
ExecutorService暗示了生命周期有三种状态:运行、关闭和终止。
Shutdown方法会启动一个平缓的关闭过程;停止接受新的任务,同时等待已经提交的任务完成,包括尚未开始执行的任务。shutdownNow方法会启动一个强制的关闭过程,尝试取消所有运行中的任务和排在队列中尚未开始的任务。
关闭后提交到ExecutorService中的任务,会被拒绝执行处理器处理。拒绝执行处理器(它是ExecutorService的一种实现,ThreadPoolExecutor提供的)可能只是简单地放弃任务。一旦所有的任务全部完成后,ExecutorService会转入终止状态。
5.1.5 延迟的,并具周期性的任务
Timer工具管理任务的延迟以及周期性执行。但是,Timer存在一些缺陷,你应该考虑使用ScheduledThreadPolExecutor作为替代。
Timer只创建唯一的线程来执行所有timer任务。如果一个timer任务的执行很耗时,会导致其他TimerTask的时效准确性。
DelayQueue是BlockingQueue的实现,为ScheduledThreadPoolExecutor提供了调度功能。DelayQueue管理着一个包含Delayed对象的容器。每个Delayed对象都与一个延迟时间相关联:只有在元素过期后,DelayQueue才让你能执行take操作获取元素。从DelayQueue中返回的对象将依据它们所延迟的时间进行排序。
5.2 寻找可强化的并行性
5.2.1 可携带结果的任务:Callable和Future
Executor框架使用Runnable作为其任务的基本表达形式。Runnable只是个相当有限的抽象。
Callable是更佳的抽象:它在主进入点call等待返回值,并未可能抛出的一次预先做好了准备。Executors包含了一些工具方法,可以把其他类型的任务封装成一个Callable,比如Runnable
一个Executor执行的任务的生命周期分为四个阶段:创建、提交、开始和完成。由于任务的执行可能会花很长时间,我们也希望可以取消一个任务。在Executor框架中,总可以取消已经提交但尚未开始的任务,但是对于已经开始的任务,只有它们响应中断,才可以取消。
任务的状态决定了get方法的行为。如果任务已经完成,get会立即返回或者抛出一个异常,如果没有完成,get会阻塞直到他完成。
ExecutorService中的所有submit方法都返回一个Future,你可以将一个Runnable或Callable提交给executor,然后得到一个Futrue,用它来重新获得任务执行的结果,或者取消任务。也可以显示地为给定Runnable或Callable实例化一个FutureTask。
构建并发程序也要正确使用线程和锁。编写线程安全的代码,本质上就是管理对状态的访问,而且通常都是共享的、可变的状态。
通俗的说,一个对象的状态就是它的数据,存储在状态变量中,比如实例域或静态域。对象的状态还包括了其他附属对象的域,如HashMap的状态一部分存储到对象本身中,但同时也存储到很多Map.Entry对象中。
共享:指一个变量可以被多个线程访问。
可变:指变量的值在其生命周期内可以改变。
无论何时,只要有多余一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。
Java中首要的同步机制是synchronized关键值,它提供了独占锁,除此之外,术语“同步”还包括volatile变量、显示锁和原子变量的使用。
1.1 什么事线程安全性
一个类是线程安全的,是指在被多个线程访问时,类可以进行持续的正确的行为。
1.2 原子性
原子操作:可以作为一个单独的、不可分割的操作去执行。
竞争条件:当计算的正确性依赖于运行时中相关的时序或者多线程的交替时,会产生竞争条件。最常见的一种竞争条件是“检查再运行”。
竞争条件的原因:为获取期望的结果,需要依赖相关的事件发生。
检查再运行:使用潜在的过期观察值来做决策或执行计算。
检查在运行的常见用法是惰性初始化。惰性初始化的目的是延迟对象的初始化,直到程序真正使用它,同事确保它只初始化一次。例如:
LazyInitRace中的竞争条件会破坏其正确性。比如,线程A和B同时执行getInstance方法,A看到instance是null,并实例化一个新的ExpensiveObject。同时B也在检查instance是否为null。此时此刻的instance是否为null,这依赖于时序,这是无法预期的。它包括调度的无偿性,以及A初始化ExpensiveObject并设置instance域的耗时。如果B检查到instance为Null,两个调用者会得到不同的结果,然而我们期望getInstance总是返回相同的实例。
Java.util.concurrent.atomic包中包括了原子变量类,这些类用来实现数字和对象引用的原子状态转换。把long类型的计数器替换为AtomicLong类型的,我们可以确保所有访问计数器状态的操作都是原子的。
1.3 锁
在该例子中,我们不能保证会同时更新lastNunber和lastFactors。当某个线程只修改了一个变量而另一个还没有开始修改时,其他线程将看到servlet违反了不便约束,这样会形成一个程序漏洞。
1.3.1 内部锁
Java提供了强制原子性的内置锁机制:synchronized块。一个synchronized块分两部分:锁对象的引用以及这个锁保护的代码块。
Synchronized方法是对跨越了整个方法体的synchronized块的简单描述,至于synchronized方法的锁,就是该方法所在的对象本身。静态的synchronized方法从Class对象上获取锁。
每个java对象都可以隐式地扮演一个用于同步的锁的角色。这些内置的锁被称为内部所(intrinsic locks)或监视器锁(moniter locks)。
执行线程进入synchronized块之前会自动获得锁,而无论通过正常控制路径退出,还是从块中抛出异常,线程都在放弃对synchronized块的控制时自动释放锁。获得内部锁的唯一途径是:进入这个内部锁保护的同步块或方法。
内部锁在java中扮演了互斥锁的角色,意味着至多只有一个线程可以拥有锁。
1.3.2 重进入
当一个线程请求其他线程已经占有的锁时,请求线程被阻塞。然而内部锁时可重进入的,因此线程在试图获得它自己占有的锁时,请求会成功。重进入意味着锁的请求时基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的。
重进入的实现是通过为锁关联一个请求计数和一个占有它的线程。当计数为0时,认为锁时未被占有的。线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数置为1。如果同一个线程再一次请求这个锁,计数将递增;每次占用线程退出同步块,计数器值将递减,知道计数器达到0时,锁被释放。
1.4 用锁来保护状态
一种常见的错误观念认为只有写入共享变量时才需要同步,其实并非如此。
锁保护的:对于每个可被多个线程访问的可变状态变量,如果所有访问它的线程在执行时都占有同一个锁,这种情况下,我们称这个变量是由锁保护的。
对象的内部锁与它的状态之间没有内在的关系。尽管大多数类普遍使用这样一种非常有效的锁机制:用对象的内部锁来保护所有的域,然而这并不是必须的。即使获得了与对象关联的锁,也不能阻止其他线程访问这个对象。获得对象的锁后,唯一可以做的事情是阻止其他线程再获得相同的锁。
每个共享的可变变量都需要由唯一一个确定的锁保护,而维护者应该清楚这个锁。
一种常见的锁规则是在对象内部封装所有的可变状态,通过对象的内部锁来同步任何访问可变状态的代码路径,保护它在并发访问中的安全。例如Vector和其它同步容器(collection)类。
如同Vector这样,仅仅同步它每一个方法,并不足以确保在Vector上执行的复合操作是原子的。
虽然contains和add都是原子的,但尝试“缺少即加入”操作的过程中任然存在竞争条件。虽然同步方法确保了不可分割的原子性,但是把多个操作整合到一个复合操作时,还是需要额外的锁。同时,同步每个方法还会导致活跃度或性能问题。
1.5 活跃度和性能
这些请求排队等候并依次被处理,我们把这种web应用的运行方式描述为弱并发。
通过缩小synchronized块的范围来维护线程安全性,我们很容易提升servlet的并发性。尽量从synchronized块中分离耗时的且不影响共享状态的操作。
请求与释放锁的操作需要开销,所以将synchronized块分解得过于琐碎事不合理的。
决定synchronized块的大小需要权衡各种设计要求,包括安全性、简单性和性能。有时简单性和性能会彼此冲突。
2 共享对象
存在一中误解:认为synchronized仅仅用于原子操作或者划定“临界区”。同步同样还具有另外一个重要、微妙的方面:内存可见性。我们不仅希望能够避免一个线程修改其他线程正在使用的对象的状态,而且希望确保当一个线程修改了对象状态后,其他的线程能够真正看到改变。
2.1 可见性
Novisibility可能会一直保持循环,因为对于读线程来说,ready的值可能永远保持不可见。甚至奇怪的现象是,Novisibility可能会打印0。因为早在对number赋值之前,主线程就已经写入ready并使之对读取线程可见,这是一种“重排序”现象。
重排序:java虚拟机能够充分利用现代硬件的多核处理器的性能。例如在没有同步的情况下,java存储模型允许编译器重排序操作。在寄存器中缓存数值,还允许CPU重排序,并在处理器特有的缓存中缓存数值。
在没有同步的情况下,编译器、处理器、运行时安排操作的执行顺序可能完全出人意料。
2.1.1 过期数据
仅仅是同步的setter是不够的,调用get的线程任然能够看见过期值。
在没有同步的情况下读取数据类似与数据库中使用read_uncommit隔离级别。
2.1.2 非原子的64位操作
Java存储模型要求获取和存储操作都为原子的,但是对于非volatile的long和double变量,JVM允许将64位的读或写划分为两个32位的操作。如果读和写发生在不同的线程,这种情况读取一个非volatile类型long就可能会出现得到一个值的高32位和另一个值的低32位。
2.1.3 锁和可见性
内置锁可以用来确保一个线程以某种可预见的方式看到另一个线程的影响。
当B执行到与A相同的锁监视的同步块时,A在同步块之中或之前所做的事情对B是可见的。如果没有同步,就没有这样的保证。
访问一个共享变量时,多线程由同一个锁进行同步时因为:第一,保证一个线程对数值进行的写入,其他线程都可见。第二,如果一个线程在没有恰当使用锁的情况下读取了变量,那么这个变量很可能是一个过期的数据。
2.1.4 Volatile变量
Volatile变量:它确保一个变量的更新以可预见的方式告知其他的线程。当一个域声明为volatile类型后,编译器与运行时会监视这个变量:它是共享的,而且对它的操作不会与其它的内存操作一起被重排序。Volatile变量不会缓存 在寄存器或者缓存在对其他处理器隐藏的地方。所以,读取一个volatile类型的变量时,总会返回由某一线程所写入的最新值。
访问volatile变量的操作不会加锁,也就不会引起线程的阻塞,这使得volatile变量相对于synchronized而言,只是轻量级的同步机制。
Volatile变量对可见性的影响所产生的价值远远高于变量本身。线程A向volatile变量写入值,随后线程B读取该变量,所有A执行写操作前可见的变量的值,在B读取了volatile变量后,成为对B也是可见的。从内存可见性的角度看,写入volatile变量就像退出同步块,读取volatile变量就像进入同步块。
只有满足了下面所有的标准后,你才能使用volatile变量:
Ø 写入变量时并不依赖变量的当前值;或者能够确认只有单一的线程修改变量的值;
Ø 变量不需要与其他的状态变量共同参与不变约束;
Ø 而且,访问变量时,没有其他的原因需要加锁;
2.2 发布和逸出
发布(publish):一个对象的意识是使它能够被当前范围之外的代码所使用。比如将一个引用存储到其他代码可以访问的地方,在一个非私有的方法中返回这个引用,也可以把它传递到其他类的方法中。在很多情况下,我们需要确保对象及它们的内部状态不被暴露(publish)。
如果发布对象时,它还没有完成构造,同样危及线程安全。一个对象在尚未准备好时就将他发布,称为逸出。
最常见的发布对象的方式是将对象的引用存储到公共静态域中。任何类和线程都能看见这个域。
发布一个对象,同样也发布了该对象所有非私有域所引用的对象。且在一个已经发布的对象中,哪些非私有域的引用链和方法调用链中的可获得对象也都会被发布。
最后一种发布对象和他的内部状态的机制是发布一个内部实例类。
2.2.1 安全构建的实战
ThisEscape演示了一种重要的逸出特例,this引用在构造时逸出。发布的内部EventListener实例是一个封装的ThisEscape中的实例。但是这个对象只有通过构造器函数返回后,才处于可预言的、稳定的状态,所以从构造器函数内部发布的对象,只是一个未完成构造的对象。
一个导致this引用在构造期间逸出的常见错误,是在构造函数中启动一个线程。当对象在构造函数中创建一个线程时,无论是显式还是隐式,this引用几乎总是被新线程共享。
2.3 线程封闭
访问共享的、可变的数据要求使用同步。一个可以避免同步的方式是不共享数据。如果数据仅在单线程中被访问,就不需要任何同步。线程封闭计数是实现线程安全的最简单的方式之一。
2.3.1 Ad-hoc线程限制
Ad-hoc线程限制:是指维护线程限制性的任务全部落在实现上的这种情况。
2.3.2 栈限制
栈限制是线程限制的一种特列,在栈限制中,只能通过本地变量才可以触及对象。本地变量是对象更容易被限制在线程本地种。本地变量本身就被限制在执行线程中。它们存在于执行线程栈。其他线程无法访问这个栈。
2.3.3 ThreadLocal
一种维护线程限制的更加规范的方式是使用ThreadLocal,它允许将每个线程与持有数值的对象关联在一起。ThreadLocal提供了get和set访问器,为每个使用它的线程维护一份单独的拷贝。所以get总是返回当前执行线程通过set设置的最新值。
线程本地变量通常用于防止在基于可变的单元或全局变量的设计中,出现共享。
线程首次调用ThreadLocal.get方法时,会请求initialValue提供一个初始值。
2.4 不可变性
为了满足同步的需要,另外一种方法是使用不可变对象。创建后状态不能被修改的对象叫做不可变对象。不可变对象天生是线程安全的。
不可变对象是简单的。它们只有一种状态,构造器函数谨慎地控制着这个状态。
不可变性并不简单地等于将对象中的所有域都声明为final 类型,所有域都是final类型的对象任然可以是可变的,因为final域可以获得一个到可变对象的引用。
在不可变对象的内部,同样可以使用可变性对象来管理它们的状态。
2.4.1 final 域
Final域使得确保初始化安全性成为可能,初始化安全性让不可变性对象不需要同步就能自由地被访问和共享。
即使对象是可变的,将一些域声明为final类型任然有助于简化对其状态的判断。因为限制了对象的可见性,也就约束了其成为可能的状态集。
2.4.2 使用volatile发布不可变对象
使用volatile变量也是不能保证线程安全性的,但是有时不可变对象也可以提供一种弱形式的原子性。
使用不可变对象,一旦一个线程获得了它的引用,永远不必担心其他线程会修改它的状态。如果更新变量,会创建新的容器对象。不过在此之前任何线程都还和原来的容器打交道,任然看到它处于一致的状态。
VolatileCachedFactorizer利用OneValueCache存储缓存的数字及其因素。当一个线程设置volatile类型的cache域引用到一个新的OneValueCache后,新数据会立即对其他线程可见。
与cache域相关的操作不会相互干扰,因为OneValueCache是不可变的,而且每次只有一条相应的代码访问路径访问它。
2.5 安全发布
2.5.1 当好对象变坏时
你无法信赖局部创建对象,一个监视着处于不一致状态对象的线程,会看到尽管该对象自发布之后从未修改过,但是它的状态还是会发生突变。
因为没有同步来确保Holder对其他线程可见,所以我们称Holder是“非正确发布的”。没有正确发布的对象会导致两种错误:首先,发布线程以外的任何线程都可以看到Holder域的过期值,因而看到的是一个null引用或者旧值,即使此刻Holder已经被赋予新值。其次,线程看到的Holder引用时最新的,然而Holder状态却是过期的。
2.5.2 不可变对象与初始化安全性
出于不可变对象的重要性,Java存储模型为共享不可变对象提供了特殊的初始化安全性的保证。正如我们所见,对象的引用对其他线程可见,并不意味对象的状态一定对消费线程可见。为了保证对象状态有一个一致性视图,我们需要同步。
即使发布对象引用时没有使用同步,不可变对象任然可以被安全地访问。为了获得这种初始化安全性的保证,应该满足所有不变性的条件:不可修改的状态,所有域都是final类型的以及正确的构造。
2.5.3 安全发布的模式
如果一个对象不是不可变的,它就必须被安全的发布,通常发布线程与消费线程都必须同步化。
线程安全容器的同步,意味着将这些对象置入这些容器的操作遵守了前述的最后一条要求。
Ø 置入hashtable、synchronizedMap、ConcurrentMap中的主键以及键值,会安全地发布到可以从Map获得它们的任意线程中,无论是直接还是通过迭代器获得。
Ø 置入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或者synchronizedSet中的元素,安全地发布到可以从容器中获得它的任意线程中
Ø 置入BlockingQueue或者ConcurrentLinkedQueue的元素,会安全地发布到可以从队列中获得它的任意线程中。
Ø
类库中的其他交互机制如Future和Exchanger同样创建了安全发布。
通常以最简单和最安全的方式发布一个被静态创建的对象,就是使用静态初始化器。
静态初始化器由JVM在类的初始化阶段执行,由于JVM内在的同步,该机制确保了以这种方式初始化的对象可以被安全地发布。
2.5.4高效不可变对象
有些对象在发布后就不会被修改,其他线程想要在没有额外同步的情况下安全地访问它们,此时安全发布至关重要。所有的安全发布机制都能保证,只要一个对象“发布当时”的状态对所有访问线程都可见,那么到它的引用也都是可见的。如果“发布当时”的状态不会再改变,那么确保任意访问是安全的,就变得很重要。
有效不可变对象:一个对象在技术上不是不可变的,但是它的状态不会再发布后被修改。
2.5.5 可变对象
2.5.6 安全地共享对象
3 组合对象
3.1 设计线程安全的类
尽管讲所有的状态都存储在公共静态域中,任然能写出线程安全的程序,但是比起那些经过适当封装的类来说,我们难以验证这种程序的线程安全性,也很难再修改它们的同时,保证不破坏它的线程安全性。
如果一个对象的域引用了其他对象,那么它的状态也同时包含了被引用对象的域。例如,LinkedList的状态包括了存储在链表中的节点对象的状态。
同步策略:定义了对象如何协调对其状态的访问,并且不会违反它的不变约束或后验条件。它规定了如何把不可变性、线程限制和锁结合起来,从而维护线程的安全性,还指明了那些锁保护那些变量。
3.1.1 收集同步需求
维护类的线程安全性意味着确保在并发访问的情况下,保护它的不变约束。这需要对其状态进行判断。对象与变量拥有一个状态空间:它们可能处于的状态范围。尽量使用final类型的域,就可以简化我们对对象的可能状态进行分析。
很多类通过不可变约束来判定某种状态是合法的还是非法的。
操作的后验条件会指出某种状态转换时非法的。
3.1.2 状态依赖的操作
若一个操作存在基于状态的先验条件,则把它称为是状态依赖的。
在Java中,等待特定条件成立的内置高效机制----wait和notify。与内部锁紧密地绑定在一起。
3.2 实例限制
通过使用实例限制,封装简化了类的线程安全化工作。当一个对象被另一个对象封装时,所有访问被封装对象的代码路径就是全部可知的,这相比让对象可被整个系统访问来说,更容易对代码路径进行分析。
平台类库中有很多线程限制的实例,包括一些类,它的存在就是为了把非线程安全的类转化为线程安全的。ArrayList和HashMap这样的基本容器类就是非线程安全的,但类库提供了包装器工厂方法,使这些非线程安全的类可以安全地用于多线程环境中。这些工程方法利用装饰器模式,使用一个同步的包装器对象包装容器,包装器将相关接口的每个实现为同步方法,将请求转发到下层容器对象上。
3.2.1 Java监视器模式
线程限制原则的直接推论之一是Java监视器模式。遵循Java监视器模式的对象封装了所有的可变状态,并由对象自己的内部锁保护。
使用私有锁对象,而不是对象的内部锁,有很多好处。私有的锁对象可以封装锁,这样客户代码无法得到它。然而可公共访问的锁允许客户代码涉足它的同步策略。
3.3 委托线程安全
基于“委托”的代码返回一个不可变的,但却是“现场”的location视图。这意味着线程A调用getLocations时,线程B修改了一些Point的Location,这些变化会反映到返回给线程A的Map值中。
4 构建块
4.1 同步容器
同步容器类包括两部分,一个是Vector和Hashtable,另外是它们的同系容器:由Collections.synchronizedXXX工厂方法创建的。
4.1.1 同步容器中出现的问题
同步容器都是线程安全的。但是对于复合操作,有时需要使用额外的客户端加锁进行保护。复合操作包括:迭代、导航、以及条件运算,比如缺少及加入。
在一个同步的容器中,这些复合操作即使没有客户端加锁的保护,技术上也是安全的,但是当其他线程能并发修改容器的时候,就不会按照你期望的方式工作了。
好在同步容器遵守一个支持客户端加锁的同步策略,因此只要我么知道应该使用哪一个锁,就有可能针对其他的容器操作创建新的原子操作。同步容器类通过对它的对象自身进行加锁,保护它的每一个方法。
4.1.2 迭代器和ConcurrentModificationException
在设计同步容器返回的迭代器时,并没有考虑到并发修改的问题,它们是“及时失败”的,即意思是当它们察觉容器在迭代开始后被修改,会抛出一个未检查的ConcurrentModificationException异常。
有一些原因造成我们不愿意在迭代期间对容器加锁。在迭代期间,对容器加锁的一个替代办法是复制容器。因为复制是线程限制的,没有其他的线程能够在迭代期间对其进行修改。
复制容器会有明显的性能开销。
4.1.3 隐藏迭代器
在一个可能发生迭代的共享容器中,各处都需要锁。
容器的hashCode和equals方法也会间接地调用迭代,比如当容器本身作为一个元素时,或者作为另一个容器的key时。类似地,containsAll、removeAll和retainAll方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些对迭代的间接调用都会引起ConcurrentModificationException。
4.2 并发容器
Java5.0通过提供几种并发的容器类来改进同步容器。同步容器通过对容器的所有状态进行串行访问,从而实现了它们的线程安全。然而代价是消弱了并发性,当多个线程共同竞争容器及的锁时,吞吐量就会降低。
并发容器是为多线程并发访问而设计的。ConcurrentHashMap来替代同步的哈希Map实现。新的ConcurrentMap接口加入了对常见复合操作的支持,如“缺少即加入”、替换和条件删除。
当多数操作作为读取操作时,CopyOnWriteArrayList是List相应的同步实现。
Java5.0 同样添加了两个新的容器类型:Queue和BlockingQueue。Queue用来临时保存正在等待进一步处理的一系列元素。JDK提供了几种实现:包括传统FIFO队列,ConcurrentLinkedQueue;一个(非并发)具有优先级顺序的队列PriorityQueue。Queue操作不会阻塞;如果队列是空,那么从队列中获取元素的操作返回Null。
BlockingQueue扩展了Queue,增加了可阻塞的插入和获取操作。如果对列是空的,一个获取操作会一直阻塞知道队列中存在可用元素。
4.2.1 ConcurrentHashMap
同步容器类在每个操作的执行期间都持有一个锁。有一些操作,比如HashMap.get或者List.contains,可能会涉及到比预想更多的工作量:为寻找一个特定对象而边防整个哈希容器或清单,必须调用大量候选对象的equals。
ConcurrentHashMap和HashMap一样是一个哈希表,但是它使用完全不同的策略,可以提供更好的并发性和可伸缩性。ConcurrentHashMap使用一个更加细化的锁机制(分离锁)。这个机制允许更深层次的共享访问。任意数量的读写线程可以并发访问Map,读者和写者也可以并发访问Map,并且有限数量的写线程还可以并发修改Map。为并发访问带来了更高的吞吐量。
ConcurrentHashMap返回的迭代器具有弱一致性。而非“及时失败”的。弱一致性的迭代器可以容许并发修改,当迭代器被创建时,它会遍历已有的元素,并且可以(但是不保证)感应到在迭代器被创建后对容器的修改。
对整个Map的操作方法如size和isEmpty,它们的语义在反映容器并发特性上被轻微地弱化了。因为size的结果对于在计算的时候可能已经过期,它仅仅是一个估算值,所以允许size返回一个近似值而不是一个精确值。
4.2.2 Map附加的原子操作
因为ConcurrentHashMap不能够在独占访问中被加锁,故不能使用客户端加锁来创建新的原子操作。
如对Vector做的“缺少即加入”操作。不过一些常见的复合操作,如“缺少即加入”,“相等便移除”和“相等便替换”都已经被实现为原子操作。
4.2.3 CopyOnWriteArrayList
CopyOnWriteArrayList是同步List的一个并发替代品,通常情况下提供了更好的并发性,并避免了在迭代期间对容器加锁和复制。
“写入时复制(copy-on-write)”容器的线程安全性来源于这样一个事实:只要有效的不可变对象被正确发布,那么访问它将不再需要更多的同步。在每次需要修改时,它们会创建并重新发布一个新的容器拷贝,以此来实现可变性。
“写入时复制(Copy-on-write)”容器的迭代器保留一个底层基础数组的引用,这个数组作为迭代器的起点,永远不会被修改,对它的同步只不过是为了确保数组内容的可见性。
在每次容器改变时复制基础数组需要一定的开销,特别是当容器比较大的时候。当对容器的迭代操作频率远远高于对容器修改的频率时,使用“写入时复制”容器是个合理的选择。
4.3 阻塞队列和生产者-消费者模式
4.3.1 连续的线程限制
在java.util.concurrent中实现的阻塞队列,全部都包含充分的内部同步,从而能安全地将对象从生产者线程发布至消费者线程。
对于可变对象,生产者-消费者设计和阻塞队列一起,为生产者和消费者之间移交对象所有权提供了连续的线程限制。一个线程约束的对象完全由单一线程所有,但是所有权可以通过安全的发布被“转移”,这样其他线程中只有唯一一个能够得到访问这个对象的权限,并且保证移交之后原线程的所有者不会再触及它,这样使得对象完全受限于新线程。这个新的主人可以任意修改,因为它具有独占访问权。
4.3.2 双端队列和窃取工作
Java6增加了两个容器类型,Deque和BlockingDeque,它们分别扩展了Queue和BlockingQueue。Deque是一个双端队列,允许高效在头和尾分别进行插入和移除。实现它们的是ArrayDeque和LinkedBlockingDeque。
一个消费者生产者设计中,所有的消费者共享一个工作队列;在窃取工作的设计中,每个消费者都有自己的双端队列。如果一个消费者完成了自己双端队列中的全部工作,它可以偷取其他消费者的双端队列中的末尾任务。
4.4 阻塞和可中断的方法
BlockingQueue的put和take方法会抛出一个受检查的InterruptedException,这与类库中其他的一些方法是相同的,比如Thread.sleep。当一个方法能够抛出InterruptedException时,是在告诉你这个方法是一个可阻塞方法,如果它被中断,将可以提前结束阻塞状态。
Thread提供了interrupt方法,用来中断一个线程,或者查询某线程是否已经被中断。每个线程都有一个boolean类型的属性,它代表了线程的中断状态。
中断是一种协作机制。一个线程不能够迫使其他线程停止正在做的事情,或者去做其他事情。当线程A中断B时,A仅仅要求B在达成某个方便停止的关键点时,停止正在做的事情。
当你在代码中调用了一个会抛出InterruptedException的方法时,你自己的方法也就成为了一个阻塞方法,要为响应中断做好准备。有两种选择:
传递InterruptedException:如果能侥幸避开异常的话,最明智的策略把InterruptedException传递给你的调用者。要么不捕获InterruptedException,要么先捕获,然后对其中特定活动进行简洁地清理,然后再抛出。
恢复中断:有时候你不能抛出InterruptedException,比如你的代码是Runnable的一部分。在这种情况下,你必须捕获InterruptedException。并且在当前线程中调用interrupt从中断中恢复,这样,调用栈中更高层的代码可以发现中断已经发生。
4.5 Synchronizer
阻塞队列在容器中是独一无二的:它们不仅作为对象的容器,而且能够协调生产者线程和消费者线程之间的控制流。因为take和put方法保持阻塞状态知道队列进入了期望的状态。
Synchronizer是一个对象,它根据本身的状态调节线程的控制流。阻塞队列可以扮演一个Synchronizer的角色。其他类型的Synchronizer包括信号量(semaphore)、关卡以及闭锁(latch)。
所有的Synchronizer都享有类似的结构特性,它们封装状态,而这些状态决定着线程执行到某一点时是通过还是被迫等待;它们还提供操控状态的方法,以及高效等待Synchronizer进入期望状态的方法。
4.5.1 闭锁
闭锁:是一种Synchronizer,它可以延迟线程的进度直到线程终止状态。
一个闭锁工作起来就像一道大门,直到闭锁到达终点状态之前,门一直是关闭的,没有线程能够通过,在终点状态到来的时候,门开了,允许所有线程都通过。一旦闭锁到达了终点状态,它就不能再改变状态了,所以它会永远保持敞开状态。
闭锁可以用来确保特定活动直到其他的活动完成后才发生:
Ø 确保一个计算不会执行,直到需要的资源被初始化。
Ø 确保一个服务不会开始,直到它以依赖的其他服务都已经开始。
Ø 等待,直到活动的所有服务都为继续处理做好充分准备。
CountDownLatch是一个灵活的闭锁实现,允许一个或多个线程等待一个事件的发生。闭锁的状态包括一个计算器,初始化一个正数,用来表现需要等待的时间数。countDown方法对计数器做减操作,表示一个事件已经发生了,而awati方法等待计数器达到零,此时所有需要等待的时间都已经发生。
4.5.2 FutureTask
FutureTask同样可以作为闭锁。FutureTask的计算是通过Callable实现的,它等价于一个可携带结果的Callable,并且有三个状态:等待、运行和完成。
Future.get的行为依赖于任务的状态。如果它已经完成,,get可以立刻得到返回的结果,否则会被阻塞直到任务转入完成状态,然后返回结果或者抛出异常。
Executor框架利用FutureTask来完成异步任务,并可以用来进行任何潜在的耗时计算,而且可以在真正计算结果之前就启动它们开始计算。
4.5.3 信号量
计算信号量(Counting semaphone)用来控制能够同时访问特定资源的活动的数量,或者同时执行某一给定操作的数量。计算信号量可以实现资源池或者给一个容器限定边界。
一个semaphone管理一个有效的许可集。许可的初始量通过构造器传递给semaphone。活动能够获得许可(只要还有剩余许可),并在使用之后释放许可。如果已经没有可用的许可,那么acquire会被阻塞,直到有可用的为止。Release方法向信号量返回一个许可。
计算信号量的一种退化形式是二元信号量:一个计数初始值为1的semaphone。二元信号量可用作互斥锁(mutex),它有不可重入的语义。
信号量可以用来实现资源池,比如数据连接池。
4.5.4 关卡
关卡类似与闭锁,它们都能够阻塞一组线程,直到某事件发生。其中关卡与闭锁的关键不同在于,所有线程必须同时到达关卡点,才能继续处理。闭锁等待的是事件,而关卡等待的是其他线程。
关于实现的协议,就像一些家庭成员指定商场中的集合地点:“我们每个人6:00在麦当劳见,到了以后我们再做决定接下来做什么”。
CyclicBarrier允许一个给定数量的成员多次集中在一个关卡点。这在并行迭代算法中非常有用,这个算法会把一个问题拆分成一系列相互独立的子问题。当线程到达关卡点时,调用await,await会被阻塞,直到所有线程都到达关卡点。如果所有线程都到达了关卡点,关卡点被成功突破,这样所有线程都被释放,关卡会重置以备下一次使用。CyclicBarrier允许向构造器传递一个关卡行为,它是runnable,当成功通过关卡的时候,会在子线程中执行,但是在阻塞线程被释放之前是不能执行的。
Exchanger是关卡的另一种形式,它是一种两步关卡,在关卡点会交换数据。当两方进行的活动部对称时,exchanger是非常有用的。
4.5.6 建立高效、可伸缩的高速缓存
5 执行任务
5.1 Executor框架
作为Executor框架的一部分,java.util.concurrent提供了一个灵活的线程池实现。
Executor只是个简单的接口,但它却为一个灵活而强大的框架创造了基础。这个框架可以用于异步任务执行,而且支持很多不同类型的任务执行策略。它为任务提交和任务执行
之间的解耦提供了标准的方法,为使用Runnable描述任务提供了通用的方式。Executor的实现还提供了对生命周期的支持以及钩子函数,可以添加诸如统计收集应用程序管理机制和监视器等扩展。
Executor基于生产者-消费者模式。
5.1.1 使用Executor实现的Web Server
在TaskExecutionWebServer中,我们通过使用Executor将处理请求任务的提交与它的执行体进行解耦。只要替换一个不同的Executor实现,就可以改变服务器的行为。
5.1.2 执行策略
将任务的提交与任务的执行进行解耦,其价值在于让你可以简单地为一个类给定的任务制定执行策略。一个执行策略指明了任务执行的几个因素:
Ø 任务在什么(what)线程中执行
Ø 任务以什么(what)顺序执行(FIFO,LIFO,优先级)?
Ø 可以有多少(how many)个任务并发执行?
Ø 可以有多少(how many)个任务进入等待执行队列?
Ø 如果系统过载,需要放弃一个任务,应该挑选哪一个(which)任务?另外如何(how)通知应用程序知道这一切呢?
Ø 在一个任务的执行前与结束后,应该做什么(what)处理?
执行策略是自愿管理工具。最佳策略取决于可用的计算资源和你对服务质量的需求。通过限制并发任务的数量,你能确保应用程序不会由于自愿耗尽而失败,大量的任务也不会再争夺稀缺资源出现性能问题。
5.1.3 线程池
线程池管理一个工作者线程的同构池。线程池是与工作队列紧密绑定的。工作队列,其作用是持有所有等待执行的任务。
工作者线程:它从工作队列获取下一个任务,执行它,然后回来继续等待另一个线程。
在线程池中执行线程,这种方法有很多“每任务没线程”无法比拟的优势。重用存在的线程,而不是创建新的线程,这可以在处理多请求时抵消线程创建、消亡产生的开销。另外,在请求到达时,工作者线程通常已经存在,用于创建线程的等待时间不会延迟任务的执行,提高了响应性。
Executors中的静态工厂方法创建一个线程池:
Ø newFixedThreadPool
Ø newCachedThreadPool
Ø newSingleThreadExecutor
Ø newScheduledThreadPool
使用Executor为你打开了一扇通往无线可能的大门,你有各种机会去做调优、管理、监视、记录日志、错误报告和其它可能的事情。
5.1.4 Executor的生命周期
Executor实现通常只是为执行任务而创建线程,但是JVM会在所有线程全部终止后才退出,因此,如果无法正确关闭Executor,将会阻止JVM的结束。
因为Executor是异步地执行任务,所以在任何时间里,所有之前提交的任务的状态都不能立即可见。这些任务,有些可能已经完成,有些可能正在运行,有些可能在队列汇总等待执行。
平缓的关闭:已经启动的任务全部完成而且没有在接到任何新的工作。
唐突的关闭:拔掉机房的电源
ExecutorService接口扩展了Executor接口,并且添加了一些用于生命周期管理的方法。
ExecutorService暗示了生命周期有三种状态:运行、关闭和终止。
Shutdown方法会启动一个平缓的关闭过程;停止接受新的任务,同时等待已经提交的任务完成,包括尚未开始执行的任务。shutdownNow方法会启动一个强制的关闭过程,尝试取消所有运行中的任务和排在队列中尚未开始的任务。
关闭后提交到ExecutorService中的任务,会被拒绝执行处理器处理。拒绝执行处理器(它是ExecutorService的一种实现,ThreadPoolExecutor提供的)可能只是简单地放弃任务。一旦所有的任务全部完成后,ExecutorService会转入终止状态。
5.1.5 延迟的,并具周期性的任务
Timer工具管理任务的延迟以及周期性执行。但是,Timer存在一些缺陷,你应该考虑使用ScheduledThreadPolExecutor作为替代。
Timer只创建唯一的线程来执行所有timer任务。如果一个timer任务的执行很耗时,会导致其他TimerTask的时效准确性。
DelayQueue是BlockingQueue的实现,为ScheduledThreadPoolExecutor提供了调度功能。DelayQueue管理着一个包含Delayed对象的容器。每个Delayed对象都与一个延迟时间相关联:只有在元素过期后,DelayQueue才让你能执行take操作获取元素。从DelayQueue中返回的对象将依据它们所延迟的时间进行排序。
5.2 寻找可强化的并行性
5.2.1 可携带结果的任务:Callable和Future
Executor框架使用Runnable作为其任务的基本表达形式。Runnable只是个相当有限的抽象。
Callable是更佳的抽象:它在主进入点call等待返回值,并未可能抛出的一次预先做好了准备。Executors包含了一些工具方法,可以把其他类型的任务封装成一个Callable,比如Runnable
一个Executor执行的任务的生命周期分为四个阶段:创建、提交、开始和完成。由于任务的执行可能会花很长时间,我们也希望可以取消一个任务。在Executor框架中,总可以取消已经提交但尚未开始的任务,但是对于已经开始的任务,只有它们响应中断,才可以取消。
任务的状态决定了get方法的行为。如果任务已经完成,get会立即返回或者抛出一个异常,如果没有完成,get会阻塞直到他完成。
ExecutorService中的所有submit方法都返回一个Future,你可以将一个Runnable或Callable提交给executor,然后得到一个Futrue,用它来重新获得任务执行的结果,或者取消任务。也可以显示地为给定Runnable或Callable实例化一个FutureTask。