编写线程安全的代码,实质是管理对状态的访问,尤其是那些共享、可变的状态。对象的状态包括任何能影响它外部可见行为的数据。
当有多个线程能访问状态变量时,而且他们当中能对变量进行修改,则需要对他们进行同步管理。
在Java中实现同步的方式有:使用synchronized关键字,使用volatile变量,使用锁,使用原子变量。
一开始就将类设计为线程安全的,比之后修复它更简单。
好的封装措施可以更简单的使我们的程序线程安全,同时有助于维护。因为封装后,外面的代码无法访问它的状态变量,我们只需要保证该对象本身是线程安全的就行。这对大型项目尤其重要。
不能为了些许的性能提升而损害代码的线程安全。因为这更得不偿失。
当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步及调用方代码不必作其他的协调时,这个类的行为仍然是正确的,那么称这个类是线程安全的。
线程安全的类封装了任何必要的同步,因此客户不需要自己提供。
一个类是无状态的,是指它既没有自己的状态域,也没有引用其他类的域。无状态对象永远是线程安全的。
竞争条件:当计算的正确性依赖于“幸运”的时序,会产生竞争条件。
数据竞争:访问共享数据时没有采用同步措施,也就是多个线程会“不会控制”的使用数据。
惰性初始化的目的是延迟对象的初始化,直到程序真正使用它,同时确保它只初始化一次。
假设操作A和B,如果从执行A的线程的角度看,当其他线程执行B时,要么B全部执行完成,要么一点都没有执行,这样A和B互为原子操作。一个原子操作是指:该操作对于所有的操作,包括它自己,都满足前面描述的状态。
为了保护状态的一致性,要在单一的原子操作中更新相互关联的状态变量。
Synchronized方法包括两部分:一个对象的引用,充当的是锁的角色;该锁保护的代码段。Synchronized关键字充当锁的对象就是方法本身,也就是this关键字。
可重入锁的请求是基于“每个线程”,而不是“每次调用”。
一种常见的锁规则是:在对象内部封装所有的可变状态,通过对象的内部锁来同步任何访问可变状态的代码路径,保护它在并发访问中的安全。
对于每一个涉及多个变量的不变约束,需要同一个锁保护其所有的变量。决定同步代码段大小的因素有:安全性、简单性和性能。
Synchronized的两个作用:一是保护临界区,二是内部可见性。我们不仅希望能够避免一个线程修改其他线程正在使用的对象的状态,而且希望确保当一个线程修改了对象的状态后,其他线程能够真正看到改变。
为了确保跨线程写入的内存可见性,你必须使用同步机制。
在没有同步的情况下,编译器、处理器,运行时安排操作的执行顺序可能完全出人意料(重排序)。在没有进行适当同步的多线程程序中,尝试推断那些“必然”发生在内存中的动作时,你总是会判断错误。
在多线程中,没有使用同步机制的“读”操作可能会引发一种错误:读到过期的数据。因而在多线程中,只要有“写”共享变量,读写共享变量都要使用同步机制。
JVM允许将64位的读或写划分为两个32位的操作,因为在多程序中使用共享的、可变的long和double变量时,必须将它们声明为volatile类型,或者用锁保护起来。
锁不仅仅是关于同步与互斥的,也是关于内存可见的。一个线程在同步块之中或之前所做的每一件事,当其他线程处于同步块时都是可见的。故某些操作不一定要放到同步块中,之前也行。
Volatile可以解决可见性,而且如同同步块一样,某个线程在写volatile变量前的操作,在其它线程读volatile变量后,也都变成可见的了。相当于“栅栏”,栅栏前和后的操作只会分别重排序,而不会一起重排序。然而,我们不应该过度依赖volatile的栅栏作用,因为这比使用锁的代码更脆弱,更难以理解。正确使用volatile的方式包括:用于确保它们所引用的对象状态的可见性,或者用于表示重要的生命周期事件(比如初始化或关闭)的发生。
加锁可以保证原子性和可见性,volatile只能保证可见性。
发布一个对象的意思是指使它能够被当前范围之外的代码所使用,比如将它的引用存储到其他代码可以访问的地方,在一个非私有的方法中返回这个对象,或者传递它到其它类的方法中。发布了一个不该发布的对象或没准备好的对象(的现象)称为逸出。
在构造函数内部发布的对象,只是一个未完成构造的对象。不要让this引用在构造函数中逸出。
线程封闭是指把变量限制在单线程中,仅仅在单线程中被访问,这样就不需要任何同步。线程封闭实例:Swing的可视化组件和数据模型,JDBC connection对象。
所有的域声明为final型,除非它们是可变的”是良好的实践,可以减少对象的复杂度。
不可变对象可以在没有额外同步的情况下,安全的用于任意线程;甚至发布它们时亦不需要同步。
一个对象在技术上不是不可变的,但是它的状态不会在发布后被修改,这样的对象称作有效不可变对象。任何线程都可以在没有额外的同步下安全的使用一个安全发布的有效不可变对象。
对象的同步策略:定义了对象如何协调对其状态的访问,并且不会违反它的不变约束或后验条件。应该将类的同步策略写入文档。
对象与变量拥有一个状态空间:即它们可能处于的状态范围。不可变对象是一种极限情况,它只可能处于唯一的状态。类的不变约束与方法的后验条件约束了对象合法的状态和合法状态转换。不理解对象的不变约束和后验条件,你就不能保证线程安全性。要约束状态变量的有效值或者状态转换,就需要原子性与封装性。
若一个操作存在基于状态的先验条件,则把它称为是状态依赖的(state-dependent)。在单线程化的程序中,操作如果无法满足先验条件,必然失败;但在多线程中,可以选择:持续等待,直到先验条件为真,再继续处理操作。这可以使用java的内置高效机制wait和notify。
一个线程不安全的对象也可以应用于多线程,因为它可以被其他安全的对象封装。这称为实例限制。将数据封装在对象内部,把对数据的访问限制在对象的方法上,更易确保线程在访问数据时总能获得正确的锁。限制对象时,要防止对象逸出它的期望范围,即防止外部不通过方法直接访问对象。
限制性使构造线程安全的类变得更容易,因为类的状态被限制后,分析它的线程安全性时,就不必检查完整的程序。
使用实例限制最好的例子是Java监视器模式。遵循Java监视器模式的对象封装了所有的可变状态,并由对象自己的内部锁保护。Vector和Hashtable都使用了Java监视器模式。
线程安全委托:类自己不解决线程安全的问题,让类中的变量来解决,这种现象叫做线程安全委托。如果一个类由多个彼此独立的线程安全的状态变量组成,并且类的操作不包含任何无效状态转换时,可以将线程安全委托给这些状态变量。
如果一个状态变量是线程安全的,没有任何不便约束限制它的值,并且没有任何状态转换限制它的操作,那么它可以被安全发布。
同步容器类包括两部分:一个是Vector和Hashtable,它们是早期JDK的一部分;另一个是它们的同系容器,在JDK1.2中才被加入的同步包装(wrapper)类。这些类是由Collections.synchronizedXxx工厂方法创建的,是通过实例限制方法实现的。
同步容器都是线程安全的。但是对于复合操作,有时你可能需要使用额外的同步机制进行保护。通常对容器的复合操作包括:迭代、导航、缺少即加入。复合操作:不是直接在容器上操作,而是还要依赖另一个操作。
对vector进行迭代的标准方式是使用Iterator,但该迭代器并没有加锁。并且它是“及时失败”的——当它察觉迭代器在迭代后被修改(但本身通过Iterator.remove操作不会),会抛出一个未检测的ConcurrentModificationException。在迭代期间,对容器加锁的一个替代办法是复杂容器,因为此时是线程限制的。
容器的hashCode、equals、containsAll、removeAll、retainAll以及把容器作为参数的构造函数,都会对容器进行迭代。
Java 5.0提供了几种并发容器来改善同步容器。ConcurrentHashMap是同步hash-based Map的并发实现;当多数操作为读取操作时,CopyOnWriteArrayList是同步List的并发实现;ConcurrentMap接口加入了对常见复合操作的支持,如“缺少即加入”。
用同步容器代替并发容器,会带来极小的风险,但会显著的提高可扩展性。
ConcurrentHashMap是并发哈希表,它不是使用了一个公共锁同步每一个方法,而是使用了一个更加细化的锁机制,名叫分离锁,允许任意数量的读线程和有限数目的写线程。而且在迭代期间具有弱一致性,而非“及时失败”。但它没有Map中的独占加锁功能。
CopyOnWriteArrayList是同步List的并发实现。Copy-on-write(写入时复制),在每次修改时,会创建并重新发布一个新的容器拷贝,来实现可变性。迭代时保留一个回退数组的引用,当容器被修改时,引用不变,数组变。
Java 5.0添加了两个新的容器类型:Queue和BlockingQueue。Queue的操作不会阻塞,BlockingQueue的操作会阻塞。BlockingQueue支持生产者-消费者设计模式。在你的设计初期就使用阻塞队列建立对资源的管理——提早做这件事情会比日后再修复容易得多。
有界队列是强大的资源管理工具,用来建立可靠的应用程序:他们遏制那些可以产生过多工作量、具有威胁的活动,从而让你的程序在面对超负荷工作时更加健壮。
Java 6同样新添了两个容器类型:Deque和BlockingDeque。Deque是一个双端队列,允许高效地在头和尾分别进行插入和移除。实现它们的是ArrayDeque和LinkedBlockingDeque。双端队列适用窃取工作模式:每个消费者都有一个自己的双端队列,如果一个消费者完成了自己双端队列的全部工作,它可以从其他消费者的双端队列末尾窃取工作。
同步器(synchronizer)是一个对象,它根据本身的状态调节线程的控制流。同步器包括:阻塞队列、信号量(semaphore)、barrier、latch。
Latch是一种同步器,它可以延迟线程的进度直到线程到达终止(terminal)状态。CountDownLatch是一个灵活的lacth实现。
FutureTask可以作为latch,FutureTask的计算是通过Callable实现的,Callable等价与一个有返回值的Runnable,并且有3个状态等待、运行和完成。Future.get()的返回结果依赖任务的状态:如果任务完成,则立即得到返回结果;否则,会被阻塞直到任务变为完成状态,然后返回结果或者抛出异常。执行任务的代码的异常被封装在ExecutionException中,有三种情况:Callable抛出的受检查的异常、一个RuntimeException或者一个Error。
计数信号量(Counting semaphore)用来控制能够同时访问某特定资源的活动数目,或者同时执行某一给定操作的数量。
可变状态是麻烦的。所有并发问题都归结为如何协调访问并发状态。可变状态越少,保证线程安全就越容易。
尽量将域声明为final类型,除非它们需要为可变的。
不可变对象天生是线程安全的。不可变对象极大的减轻了并发编程的压力,他们简单而且安全,可以在没有锁或者defensivecopying的情况下自由的共享。
封装降低了管理的复杂度。把数据封装在对象中,可以更容易的保持它的不变性;把同步封装在对象中,可以更容易的遵守同步策略。
用锁来保护每一个可变变量。
用同一个锁保护不可变约束中的所有的变量。
在复合操作期间加锁。
在多线程的情况下,不使用同步机制访问可变变量存在风险。
不要依赖于可以不需要同步的小聪明。
在设计过程中就考虑线程安全,或者在文档中明确的说明它不是线程安全的。 文档化你的同步策略。
大多数并发应用程序是围绕执行任务进行管理的。设计任务时,要为任务设计一个清晰的任务边界,并配合一个明确的任务执行策略。任务最好是独立的,因为这会提高并发度。大多数服务器应用程序都选择了下面这个自然的任务边界:单个客户请求。
任务时逻辑上的工作单元,线程是使任务异步执行的机制。
使用Executor的一个优点是:要改变程序的运行,只要改变Executor的实现就行,也就是任务的执行,不需要动任务的提交,而且Executor的实现是放在一个地方的,但任务的提交则是扩散到整个程序中。
Executor的生命周期:Executor有三种状态:运行、关闭、终止。创建后的初始状态是运行状态,shutdown()方法会启动一个平缓的关闭过程,shutdownNow()方法会启动一个强制的关闭过程。在关闭后提交到Executor中的任务,会被被拒执行处理器(RejectedExecutionHandler)处理(可能只是简单的放弃)。一旦所有的任务全部完成后,Executor回转入终止状态,可以调用awaitTermination等待,或者isTerminated判断。
可以使用scheduledThreadPoolExecutor代替Timer使用,Timer存在一些缺陷:Timer只创建唯一的线程来执行所有timer任务;Timer抛出的未检查的异常会终止timer线程,而且Timer也不会再重新恢复线程的执行了。
Executor框架让制定一个执行策略变得简单,不过想要使用Executor,你还必须能够将你的任务描述为Runnable。
将程序的任务量分配到不同的任务中:当存在大量的相互独立、同类的能够并发处理的任务时,性能才能真正的提升;否则,性能提升的相当少,甚至降低性能。
当有一批任务需要Executor处理时,使用completionService更方便,而且还可以使用take方法,获取完成的任务(可以没完成一个取一个,提高并发)。如果不需要边完成边去结果的话,处理批任务还可以使用Executor.InvokeAll方法。
任务取消:当外部代码能够在活动自然完成之前,把它更改为完成状态,那么这个活动被称为可取消的。活动取消的原因:用户请求取消、限时活动、应用程序事件、错误、关闭。
Java没有提供任何机制,来安全的强迫线程停止手头的工作。它提供了中断——一个协作机制,是一个线程能够要求另一个线程停止当前的工作。任务和服务可以这样编码:当要求它们停止时,它们首先清除当前进程中的工作,然后再终止。因而需要一个取消策略。
取消策略,取消的how、when、what:其他代码如何请求取消该任务,任务在什么时候检查取消的请求是否到达,响应取消请求的任务中应有的行为。
断本身最好的理解应该是:它并不会真正中断一个正在运行的线程;它仅仅发出中断请求,线程自己会在下一个方便的时刻中断(取消点)。
中断通常是实现取消最明智的选择。
因为每一个线程都有其自己的中断策略,所以你不应该中断线程,除非你知道中断对这个线程意味着什么。
只有实现了线程中断策略的代码才可以接受中断请求。一般性的任务和程序库代码不应该接受中断请求。
当Future.get抛出InterruptedException或TimeoutException时,如果你知道不再需要结果时,就可以调用Future.cancel来取消任务了。
被不可中断活动阻塞的线程,我们可以用类似于中断的技术停止它们,但这更需要明确线程阻塞的原因。
对于拥有线程的服务,只要服务的生存时间大于创建线程的方法的生存时间,就需要提供生命周期方法。
如果一个方法需要处理一批任务,并在所有任务结束前不会返回,那么他可以通过使用私有的Executor来简化服务的生命周期管理,其中Executor的生命限定在该方法中。
shutdownNow的局限性:它试图取消正在进行的任务,并返回那些等待执行的任务的清单,但是我们没法找出那些已经开始执行、却没有结束的任务,这需要自己处理。
在一个长时间运行的应用程序中,所有的程序都要给未捕获异常设置一个处理器,这个处理器至少要将异常信息记入日志中。
线程分为两种:普通线程和守护线程。两者的区别是:当一个线程退出时,所有仍然存在的守护线程都会被抛弃——不会执行finally块,也不会释放栈——JVM直接退出。
管理资源避免使用finalizer。在大多数情况下,使用finally块和显式close方法结合来管理资源,会比使用finalizer起到更好的作用。
一些任务具有这样的特征:需要或者排斥某种特定的执行策略。对其他任务具有依赖性的任务,就会要求线程池足够大,来保证任务不必排队或者不被拒绝;采用线程限制的任务需要顺序的执行。把这些需求都写入文档,这样将来的维护者就不会使用一个与原来相悖的执行策略,而破坏安全性或活性。
在线程池中,如果一个任务依赖其他任务的执行,而线程池不是足够大,这时就可能出现线程饥饿和死锁。
无论何时,你提交一个非独立的Executor任务,要明确出现线程饥饿和死锁的可能性,并且在代码或者配置文件以及其他可以配置Executor的地方,任务有关池的大小和配置约束都要写入文档。
耗时任务会造成线程池堵塞,还会拖长服务时间,即使小任务也不能幸免。有一项技术可以用来缓解好使操作带来的影响,这就是限定任务等待资源的时间,而不是无限制地等待下去。
为了正确地定制线程池的长度,你需要理解你的计算环境、资源预算和任务的自身特性。如果你有不同类型的任务,它们拥有差别很大的行为,那么请考虑使用多个线程池,这样每个线程池可以根据不同任务的工作负载进行调节。对于计算密集型的任务,一个有Ncpu个处理器的系统通常通过使用一个Ncpu+1个线程的线程池来获得最优的利用率。你可以使用Runtime来获得CPU的数目:int N_CPUS = Runtime.getRuntime().availableProcessors()。
可以使用ThreadPoolExecutor来创建你所需要的线程池。
ThreadPoolExecutor允许你提供一个BlockingQueue来持有等待执行的任务。任务排队有三种基本方法:无限队列、有限队列和同步队列。newCachedThreadPool工厂提供了比newFixedThreadPool更好的队列等待性能,它是Executor的一个很好的默认选择、出于资源管理的目的,当你需要限制任务的数量,newFixedThreadPool就是很好的选择。
在ThreadPoolExecutor中,当一个有限队列被等待执行的任务充满后,饱和策略开始起作用。JDK提供了几种不同的RejectdeExecutionHandler实现,每一种都实现了不同饱和策略:AbortPolicy、CallerRunsPolicy、DiscardPolicy和DiscardOldestPolicy。默认的abort策略会引起execute抛出未检查的RejectExecutionException,调用者可以捕获这个异常,然后编写能满足自己需求的处理代码;discard策略会默认放弃这个任务;discard-oldest策略会选择丢弃最老的任务;caller-run策略会把任务推回到调用者那里,以此来减缓新任务流——调用者执行该任务,所以在一段时间内不会提交任何任务,这就给了工作者线程时间来追赶进度。
线程池需要创建一个线程,都要通过一个线程工厂(thread factory)来完成。很多情况下,都需要定制自己的线程,这时就需要实现自己的newThread方法。
构造完ThreadPoolExecutor后,可以使用某些方法定制它(ExecutorService也可以先转化为ThreadPoolExecutor再定制),但newSingThreadExecutor无法定制。而且如果你不希望别人修改你的配置,可以使用Executors中的unconfigurableExecutorService进行封装。 当每个迭代彼此独立,并且完成循环体中每个迭代的工作,意义都足够重大,足以弥补管理一个新任务的开销时,这个顺序循环是适合并行化的。
11.总结:对于并发执行的任务,Executor框架是强大且灵活的。它提供了大量可调节的选项,比如创建和关闭线程的策略,处理队列任务的策略,处理过剩任务的策略,并且提供了几个钩子函数用于扩展它的行为。然而,和大多数强大的框架一样,草率的将一些设定组合在一起,并不能很好的工作;一些类型的任务需要特定的执行策略,而一些调节参数组合在一起可能产生意外的结果。
几乎所有的GUI工具集都实现为单线程化子系统,意味着所有GUI的活动都被限制在一个单独的线程中,这其中就包括了Swing和SWT。
所有的Swing组件和数据模型都被限制于事件线程中,所有任务访问它们的代码必须在事件线程中运行。Swing的单线程规则:Swing的组件和模型只能在事件分派线程中被创建、修改和请求。
在GUI程序中,只要任务是短期的,而且只访问GUI对象(或者被其它线程限制以及与线程安全的应用程序对象),那么你几乎可以完成忽略线程的问题,在事件线程中做任何事,一定不会出问题的。
有时GUI程序会运行一些耗时任务,这时我们不能直接让它运行在事务线程中,一面失去响应。这时我们可以创建自己的Executor来执行耗时的任务。而且使用Future表现一个耗时任务,可以极大地简化耗时任务的取消。
如果一个数据模型必须被多个线程共享,而且实现一个线程安全模型的尝试却由于阻塞、一致性、或者复杂度等原因而失败,这时可以考虑运用分拆模型设计。
线程限制不仅仅限制在GUI系统;无论何时,它都可以用作实现单线程化子系统的便利工具。
总结:GUI框架几乎都是作为单线程化子系统实现的,所有与表现相关的代码都作为任务在一个事件线程中运行。因为只有唯一一个线程,耗时任务会损害响应性,所以它们应该在后台线程中运行。
如果所有线程以通用的固定的顺序获得锁,程序就不会出现锁顺序死锁(由于锁顺序而出现死锁)问题了。
在持有锁的时候调用外部方法是在挑战活性问题。外部方法可能会获得其他锁(产生死锁的风险),或者遭遇严重超时的阻塞,当你持有锁的时候会延迟其他试图获得该锁的线程。
在持有锁的时候调用一个外部方法很难进行分析,因此是危险的。当调用的方法不需要持有锁时,这被称为开放调用,依赖于开放调用的类会具有更好的行为。
在程序中尽量使用开放调用,依赖于开放调用的程序,相比于那些在持有锁的时候还调用外部方法的程序,更容易进行死锁自由度(deadlock-freedom)的分析。
在使用细粒度锁的程序中,检测代码无死锁的策略分为两部分:首先识别什么地方会获得多个锁(使这个集合尽量小),接着对这些实例进行全局分析,以确保在你的整个程序中加锁的顺序是一致的。
使用显式Lock类中的定时trylock特性,来代替使用内部锁机制,可以检测死锁和从死锁中恢复。
JVM使用线程转储来识别死锁。线程转储包括每个运行中线程的栈追踪信息。线程转储也包括锁的信息。在生成线程转储之前,JVM通过“正在等待(is-waiting-for)”图寻找死锁。
抵制使用线程优先级的诱惑,因为这会增加平台依赖行,并且可能引起活性问题。大多数并发应用程序可以对所有线程使用默认的优先级。
活性危险包括死锁、饥饿、弱响应性、丢失信号和活锁等。
总结:活性失败是非常严重的问题,因为除了中止应用横须,没有任何机制可以恢复这种失败。最常见的活性失败是锁顺序死锁。应该在设计时就避免锁顺序死锁:确保线程在获得多个锁时,使用一致的顺序。最好的解决方法是在程序中使用开放调用,这会大大减少持有多个锁的情况,并且使这种情况更好明显。
改善程序的性能的前提是保证程序的正确性,而且只有需要程序运行的更快时才改进。
使用多线程会引入一些性能的开销:线程的创建和销毁、与协调线程相关的开销(加锁、信号、内存同步)、增加的上下文切换以及调度的开销。
可扩展性指的是:当增加计算资源的时候(比如增加额外CPU数量、内存、存储器、I/O带宽),吞吐量和生产量能够相应的得以改进。
避免不成熟的优化,首先使程序正确,然后再加快——如果它运行的还不够快。
对性能的追求很可能是并发bug唯一最大的来源。因为最求性能时,很可能会损害其他方面,如安全性、可扩展性。
所有的并发程序都有一些串行源;如果你认为你的程序没有,那么去仔细检查吧。
工具perfbar可以用来评估性能,追踪性能瓶颈。
Unix系统的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数和内核占用的时间等信息。
不要过分担心非竞争的同步带来的开销。基础的机制已经足够快了,在这个基础上,JVM能够进行额外的优化,大大减少或消除了开销。关注那些真正发生了锁竞争的区域中性能的优化。
串行化会损害可扩展性,上下文切换会损害性能,因而竞争性的锁会同时导致这两种损害。
在并发程序中,对可扩展性最大的威胁是独占的资源锁(并发中的串行部分)。
分拆锁——把锁分为两个相互独立的锁;分离锁——把锁分成多个相互独立的锁。分离锁的一个缺点是:对整个容器加排他锁时,更加困难,更加昂贵了,需要对所有分离出的锁加锁。
多线程中存在两种竞争:对锁的竞争和对锁守护数据的竞争。锁分拆和锁分离减少了锁的竞争,而数据的竞争则需要避免使用热点域。
可以使用并发容器、读-写锁、不可变对象以及原子变量来代替独占锁。
在并发中,不应该使用“对象池”:如同线程池一样,对象会被循环的使用。它会提供性能,减少了对象的销毁和创建,但在多线程中,使用对象池需要很好的在线程间协调,这会带来同步。分配对象通常比同步要便宜。
18: 总结:因为使用线程最主要的目的是利用多处理器资源,在并发程序性能的讨论中,我们通常更多的关注吞吐量和可扩张性,而没有强调原始的服务时间。Amdahl定律告诉我们,程序的可扩张性是由必须连续执行的代码比例决定的。因为Java程序中串行化首要的来源是独占的资源锁,所以可扩展性通常可以通过以下这些方式提升:减少用于获取锁的时间,减少锁的粒度,减少锁的占有时间,或者用非独占或非阻塞锁来取代独占锁。
为并发程序创建测试,所要面临的主要挑战在于:那些潜在的故障并不具有确定性,而是随机的发生;能够揭示这种失败的测试,与普通的顺序测试相比,一定要有更广泛的覆盖度和更长的运行时间。
并发类的测试基本分为两类:对安全性与活性的测试。与活性相关的测试是性能测试,性能测试可以通过很多方式来测量,其中包括:吞吐量、响应时间和可扩展性。
添加了调试和测试代码后,会屏蔽某些bug。
Thread.getState不能用在并发测试中。
为并发类创建有效的安全测试,其挑战在于:如何在程序出现问题并导致某些属性极度可能失败时,简单地识别出这些受检查的属性来,同时不要人为地让查找错误的代码限制住程序的并发性,最好能做到在检查测试的属性时,不需要任何的同步。
使用CountDownLatch和CyclicBarrier可以是线程同时开始启动。
测试应该在多处理器系统上运行,以提高潜在交替运行的多样性。但是,CPU数量的增多未必会使测试更加高效。为了能够最大程度的检测到时序敏感数据的竞争,应该让活动的线程数多于CPU数,这样在任何给定的时间里,都有一些线程在运行,一些替换出执行队列,这样可以减少线程间交替行为的可预见性。
并发测试:首先是构建测试框架,在单线程的情况下对程序进行某些测试,如构造函数的测试、程序的基本功能测试;接着在多线程的情况下,测试程序的正确性,包括安全性测试和对资源管理的测试;最后是性能测试,主要是吞吐量测试和响应时间测试。
编写有效地性能测试,就需要哄骗优化器不要把你的beachmark当做死代码而优化掉。这需要把每一个计算的结果都应用到你的程序中——以一种不需要同步或大量计算的方式。
Lock提供了无条件的、可轮询的、定时的、可中断的锁获取方式,所有的加锁和解锁的方法都是显式的。
性能是一个不断变化的目标;昨天的beachmark显示X比Y更快,这可能已经过时了。
ReentrantLock提供了公平锁和非公平锁两种公平保证。非公平锁性能比公平锁性能优,因为:挂起的线程重新开始,与它真正开始运行,两者之间会产生严重的延迟。默认为非公平锁。
在内部锁不能够满足使用时,ReentrantLock才被作为更高级的工具。当你需要以下高级特性时,才应该使用:可定时的、可轮询的与可中断的锁获取操作,公平队列,或者非块结构的锁,否则,请使用synchronized。
ReentrantReadWriteLock实现了可重入、可降级、不可升级、两种公平性的读-写锁。
总结:显示的Lock与内部锁相比提供了一些扩展的性能,包括处理不可用的锁时更好的灵活性,以及对队列行为更好的控制。但是ReentrantLock不能完全替代synchronized,只有当你需要synchronized没能提供的特性时才应该使用。读-写锁允许多个读者并发访问被守护的对象,当访问多为读取数据结构的时候,它具有改进可扩展性的潜力。
条件谓词是先验条件的第一站,它在一个操作与状态之间建立起依赖关系。如在有限缓存中,只有缓存不为空时take才能执行,否则它必须等待,所以就take而言,它的条件谓词是“缓存不空”。
每次调用wait都会隐式地与特定的条件谓词相关联。当调用特定条件谓词的wait时、调用者必须已经持有了与条件队列相关的锁,这个锁必须同时还保护着组成条件谓词的状态变量。
当一个线程等待的特定条件已经为真,但是进入等待前检查条件谓词却返回了假,我们称这样就出现了一个丢失的信号。即条件为真时,没有唤醒在等待的线程。
无论何时,当你在等待一个条件,一定要确保有人会在条件谓词变为真时通知你。
等待唤醒时,注意notify和notifyAll的区别。
一个依赖于状态的类,要么完全将它的等待和通知协议暴露(并文档化)给子类,要么完全阻止子类参与其中。
危险警告:wait、notify和notifyAll在Condtion对象中的对等体是await、signal和signalAll,但是,Condition继承于Object,这意味着它也有wait和notify方法,一定要确保使用了正确的版本——await和signal!。
原子变量类共有12个,分成4组:计量器、域更新器(field updater)、数组以及复合操作。计量器:AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference、域更新器:AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdate。数组:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。复合操作:AtomicMarkReference、AtomicStampedReference。
处理器架构提供了不同级别的缓存一致性,但为了提高性能,处理器会牺牲一致性的保证。为了告诉应用程序可以从它的存储系统中获得何种担保,定义了一些特殊的指令——存储关卡或栅栏。幸运的是,在多线程共享数据时,正确的使用同步就可以阻止编译器的某些优化。
Java存储模型的定义是通过动作的形式进行描述的,所谓动作,包括变量的读和写、监视器加锁、线程的启动和拼接。JVM为所有程序内部的动作定义了一个偏序关系,叫做happens-before。要想保证执行动作B的线程看到A的结果(无论A和B是否发生在同一个线程中),A和B之间必须满足happens-before关系。
同步动作——锁的获取与释放,以及volatile变量的读取与写入——却是满足全序关系。
除了不可变对象以外,使用被另一个线程初始化的对象,是不安全的,除非对象的发布是happens-before于对象的消费线程使用它。
初始化安全可以保证,对于正确创建的对象,无论它是如何发布的,所有线程都将看到构造函数设置的final域的值,更进一步,一个正确创建的对象中,任何可以通过其final域触及到的变量(比如final数组中的元素,或者一个final域引用的HashMap里面的内容),也可以保证对其他线程都是可见的。
初始化安全性保证只有以通过final域触及的值,在构造函数完成时才是可见的,对于通过非final域触及的值,或者创建完成后可能改变的值,必须使用同步来确保可见性。
总结:Java存储模型明确的规定了在什么时机下,操作存储器的线程的动作可以保证被另外的动作看到。规范还规定了要保证操作是按照一种偏序关系进行排序。这种关系称为happens-before,它是规定在独立存储器和同步操作的级别之上的。如果缺少充足的同步,线程在访问共享数据时就会发生无法预期的事情。然而同步动作,可以不考虑happens-before的底层细节的情况下,也能确保线程安全性。