Java Concurrency In Practice
第一章 介绍
-
线程的优势:
-
充分利用多处理器
-
简化模型
-
简化异步事件的处理
-
提供用户界面的响应(时间)
-
-
线程的风险:
-
安全的风险(不好的事情会发生),提高错误出现的几率
-
活性的风险(好的事情不会发生),如某些代码不会执行,出现死锁、活锁以及饥饿
-
性能的风险,不好的多线程编程可能会危害性能
-
第二章 线程安全
-
编写线程安全的代码,实质是管理对状态的访问,尤其是那些共享、可变的状态。对象的状态包括任何能影响它外部可见行为的数据。
-
当有过个线程能访问状态变量时,而且他们当中能对变量进行修改,则需要对他们进行同步管理。
-
在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对象。
-
线程封闭的三种实现方式:
-
Ad-hoc线程限制:是指维护线程限制性的任务全部落在实现上,而不需要经过设计。如使用volatile修饰单写多读的共享变量。这种方式是非常容易出错的。
-
栈限制:在线程中定义本地变量(对象),此时必须保证该对象不能被其他线程访问。
-
threadLocal:把一个全局共享的变量设置为threadlocal,这样每个线程都会保存一个该变量的副本,而不会相互冲突。使用threalocal还可以频繁执行的操作每次都重新分配临时对象(相对于栈限制)。
-
-
不可变性:创建后状态不能被修改的对象叫做不可变对象。不可变对象永远是线程安全的。只有满足如下条件,一个对象才是不可变的:
-
它的状态不能在创建后再被修改;
-
所有域都是final类型;并且(final域可能是可变的,因为它可以获得一个可变对象的引用)
-
它被正确创建(创建期间没有发生this引用的逸出)。
-
-
“将所有的域声明为final型,除非它们是可变的”,是一条良好的时间,可以减少对象的复杂度。
-
不可变对象可以在没有额外同步的情况下,安全的用于任意线程;甚至发布它们时亦不需要同步。
-
为了安全的发布对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确创建的对象可以通过下列条件安全地发布:
-
通过静态初始化器初始化对象的引用;
-
将它的引用存储到volatile域或AtomicReference;
-
将它的引用存储到正确创建的对象的final域中;
-
或者将它的引用存储到由锁正确保护的域中。
-
-
线程安全库中的容器提供了如下的线程安全保证:
-
直入Hashtable、synchronizedMap、ConcurrentMap中的主键以及键值,会安全的发布到可以从Map获得他们的任一线程中,无论是直接获得还是通过迭代器(iterator)获得;
-
置入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或者synchronizedSet中的元素,会安全的发布到可以从容器中获得它的任意线程中;
-
置入BlockingQueue或者ConcurrentLinkedQueue的元素,会安全的发布到可以从队列中获得它的任意线程中。
-
-
一个对象在技术上不是不可变的,但是它的状态不会在发布后被修改,这样的对象称作有效不可变对象。任何线程都可以在没有额外的同步下安全的使用一个安全发布的有效不可变对象。
-
可变对象安全发布仅仅可以保证“发布当时”状态的可见性。发布对象的必要条件依赖与对象的可变性:
-
不可变对象可以通过任意机制发布;
-
有效不可变对象必须要安全的发布;
-
可变对象必须要安全发布,同时不需要线程安全或者是被锁保护。
-
-
在并发程序中,使用和共享对象的一些最有效的策略如下:
-
线程限制:一个线程限制的对象,通过限制在线程中,而被线程独占,且只能被堵占有它的线程修改。
-
共享只读(share read-only):一个共享的只读对象,在没有额外同步的情况下,可以被多个线程并发的访问,但是任何线程都不能修改它,共享只读对象包括不可变对象和有效不可变对象。
-
共享线程安全(shared thread-safe):一个线程安全的对象在内部进行同步,所以其他线程无须额外同步,就可以通过公共接口随意的访问它。
-
被守护的(Guarded):一个被守护的对象只能通过特定的锁来访问。被守护的对象包括那些被线程安全对象封装的对象,和已知被特定的锁保护起来的已发布对象。
-
-
可见性是指在某个线程中修改了变量,其他线程可以“发觉”——读到;发布是指在某个线程中定义了变量,可以被其他线程“发觉”。
第四章 组合对象
-
设计线程安全类的过程应该包括下面3个基本要素:
-
确定对象状态是由哪些变量构成的;
-
确定限制状态变量的不变约束;
-
制定一个管理并发访问对象状态的策略。
-
-
对象的同步策略:定义了对象如何协调对其状态的访问,并且不会违反它的不变约束或后验条件。应该将类的同步策略写入文档。
-
对象与变量拥有一个状态空间:即它们可能处于的状态范围。不可变对象是一种极限情况,它只可能处于唯一的状态。类的不变约束与方法的后验条件约束了对象合法的状态和合法状态转换。不理解对象的不变约束和后验条件,你就不能保证线程安全性。要约束状态变量的有效值或者状态转换,就需要原子性与封装性。
-
若一个操作存在基于状态的先验条件,则把它称为是状态依赖的(state-dependent)。在单线程化的程序中,操作如果无法满足先验条件,必然失败;但在多线程中,可以选择:持续等待,直到先验条件为真,再继续处理操作。这可以使用java的内置高效机制wait和notify。
-
一个线程不安全的对象也可以应用于多线程,因为它可以被其他安全的对象封装。这称为实例限制。将数据封装在对象内部,把对数据的访问限制在对象的方法上,更易确保线程在访问数据时总能获得正确的锁。限制对象时,要防止对象逸出它的期望范围,即防止外部不通过方法直接访问对象。
-
限制性使构造线程安全的类变得更容易,因为类的状态被限制后,分析它的线程安全性时,就不必检查完整的程序。
-
使用实例限制最好的例子是Java监视器模式。遵循Java监视器模式的对象封装了所有的可变状态,并由对象自己的内部锁保护。Vector和Hashtable都使用了Java监视器模式。
-
线程安全委托:类自己不解决线程安全的问题,让类中的变量来解决,这种现象叫做线程安全委托。如果一个类由多个彼此独立的线程安全的状态变量组成,并且类的操作不包含任何无效状态转换时,可以将线程安全委托给这些状态变量。
-
如果一个状态变量是线程安全的,没有任何不便约束限制它的值,并且没有任何状态转换限制它的操作,那么它可以被安全发布。
-
向已有的线程安全类添加功能有三种方式:
-
向原始类中加入方法,需要原始代码支持和理解同步策略
-
扩展类,需要了解类的状态,一般同步方式是使用synchronized()就行
-
扩展类的功能,要确保类的内部锁和我们加的锁是同一个锁,一般是使用synchronized(object),object为原始类。
-
还有一种更健壮的方式——组合。其实就是使用另一个类封装原始类,而且新类中引入一个新的锁层(包括原有的方法和新方法),新的锁和原始类的锁不需要有任何关系。虽然这种方式会稍微的影响性能,但它安全。
-
-
在维护线程安全性的过程中,文档是最强大的工具之一。为类的用户编写类的线程安全性担保文档;为类的维护者编写类的同步策略文档。技巧:使用@GuardedBy标签。
第五章 构造块
-
同步容器类包括两部分:一个是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。双端队列适用窃取工作模式:每个消费者都有一个自己的双端队列,如果一个消费者完成了自己双端队列的全部工作,它可以从其他消费者的双端队列末尾窃取工作。
-
当你调用方法抛出InterruptedException时,你有两种基本的选择:1.传递InterruptedException 2.恢复中断
-
同步器(synchronizer)是一个对象,它根据本身的状态调节线程的控制流。同步器包括:阻塞队列、信号量(semaphore)、barrier、latch。
-
Latch是一种同步器,它可以延迟线程的进度直到线程到达终止(terminal)状态。CountDownLatch是一个灵活的lacth实现。
-
FutureTask可以作为latch,FutureTask的计算是通过Callable实现的,Callable等价与一个有返回值的Runnable,并且有3个状态等待、运行和完成。Future.get()的返回结果依赖任务的状态:如果任务完成,则立即得到返回结果;否则,会被阻塞直到任务变为完成状态,然后返回结果或者抛出异常。执行任务的代码的异常被封装在ExecutionException中,有三种情况:Callable抛出的受检查的异常、一个RuntimeException或者一个Error。
-
计数信号量(Counting semaphore)用来控制能够同时访问某特定资源的活动数目,或者同时执行某一给定操作的数量。
-
Barrier类似于latch,它们都能够阻塞一组线程,直到某些事件发生。但不同的是,barrier等待的是线程,所有线程必须都到达栅栏点(barrier point),才能继续处理,latch等待的是事件。cyclicBarrier实现了Barrier,允许一个给定数目的成员多次集中在一个栅栏点。
第一部分的总结
-
可变状态是麻烦的。所有并发问题都归结为如何协调访问并发状态。可变状态越少,保证线程安全就越容易。
-
尽量将域声明为final类型,除非它们需要为可变的。
-
不可变对象天生是线程安全的。不可变对象极大的减轻了并发编程的压力,他们简单而且安全,可以在没有锁或者defensivecopying的情况下自由的共享。
-
封装降低了管理的复杂度。把数据封装在对象中,可以更容易的保持它的不变性;把同步封装在对象中,可以更容易的遵守同步策略。
-
用锁来保护每一个可变变量。
-
用同一个锁保护不可变约束中的所有的变量。
-
在复合操作期间加锁。
-
在多线程的情况下,不使用同步机制访问可变变量存在风险。
-
不要依赖于可以不需要同步的小聪明。
-
在设计过程中就考虑线程安全,或者在文档中明确的说明它不是线程安全的。
-
文档化你的同步策略。
第六章 任务执行
-
大多数并发应用程序是围绕执行任务进行管理的。设计任务时,要为任务设计一个清晰的任务边界,并配合一个明确的任务执行策略。任务最好是独立的,因为这会提高并发度。大多数服务器应用程序都选择了下面这个自然的任务边界:单个客户请求。
-
任务时逻辑上的工作单元,线程是使任务异步执行的机制。
-
应用程序内部的任务调度,存在多种可能的调度策略:
-
其中,最简单的策略是在单一的线程中顺序的执行任务。但它的吞吐量和响应性很差,一般只在特殊情况下使用:任务的数量很少但生命周期很长时,或者服务器只服务于唯一的用户时,服务器在同一时间内只需同时处理一个请求。
-
每任务一个线程(thread-per-task)。在中等强度的负载下,“每任务一个线程”的方法是对顺序化执行的良好改进。但它存在一些实际的缺陷,因为它会无限制的创建线程,创建/关闭线程是需要开销的,同时线程还会消耗系统资源,而且会影响稳定性。所以应该限制可创建线程的数目。
-
使用线程池——Executor框架。如同有界队列,Executor可以防止应用程序过载而耗尽资源,而且Executor是基于生产者-消费者模式的,可以分离任务提交和任务执行。如果要在你的程序中实现一个生产者-消费者的设计,使用Executor通常是最简单的方式。
-
-
使用Executor的一个优点是:要改变程序的运行,只要改变Executor的实现就行,也就是任务的执行,不需要动任务的提交,而且Executor的实现是放在一个地方的,但任务的提交则是扩散到整个程序中。
-
执行策略是资源管理工具,最佳策略取决于可用的计算资源和你对服务质量的需求。一个执行策略指明了任务执行的“what,where,when,how”几个因素,具体包括:
-
任务在什么(what)线程中执行?
-
任务以什么(what)顺序执行(FIFO,LIFO,优先级)?
-
可以有多少个(how many)任务并发执行?
-
可以有多少个(how many)任务进入等待执行队列?
-
如果系统过载,需要放弃一个任务,应该挑选哪一个(which)任务?另外,如何(how)通知应用程序知道这一切呢?
-
在一个任务的执行前与结束后,应该做什么(what)处理?
-
-
Executor的生命周期
Executor有三种状态:运行、关闭、终止。创建后的初始状态是运行状态,shutdown()方法会启动一个平缓的关闭过程,shutdownNow()方法会启动一个强制的关闭过程。在关闭后提交到Executor中的任务,会被被拒执行处理器(RejectedExecutionHandler)处理(可能只是简单的放弃)。一旦所有的任务全部完成后,Executor回转入终止状态,可以调用awaitTermination等待,或者isTerminated判断。
-
可以使用scheduledThreadPoolExecutor代替Timer使用,Timer存在一些缺陷:Timer只创建唯一的线程来执行所有timer任务;Timer抛出的未检查的异常会终止timer线程,而且Timer也不会再重新恢复线程的执行了。
-
Executor框架让制定一个执行策略变得简单,不过想要使用Executor,你还必须能够将你的任务描述为Runnable。
-
Runnable、Callable、Future比较
Runnable是执行任务的抽象,但它的run方法不能返回一个值或者跑出受检查的异常;Callable是更佳的抽象,它的run方法有返回值,并能抛出异常;而Future提供了相关的方法来获得任务的结果、取消任务以及检验任务是否已经完成还是被取消。
Executor的所有submit方法都返回一个Future,用它可以重新获得任务执行的结果,或者取消任务。除此之外,在Java 6中,ExecutorService的所有实现都可以重写newTaskFor方法,把Callable封装成Future。
-
将程序的任务量分配到不同的任务中:当存在大量的相互独立、同类的能够并发处理的任务时,性能才能真正的提升;否则,性能提升的相当少,甚至降低性能。
-
当有一批任务需要Executor处理时,使用completionService更方便,而且还可以使用take方法,获取完成的任务(可以没完成一个取一个,提高并发)。如果不需要边完成边去结果的话,处理批任务还可以使用Executor.InvokeAll方法。
-
总结
围绕任务的执行来构造应用程序,可以简化开发,便于同步。Executor框架有助于分离任务的提交和任务的执行策略,同时还支持很多不同类型的执行策略。每当你要为执行任务而创建线程时,可以考虑使用Executor。为了最大化效益,在把应用程序分解为不同的任务时,你必须确定一个合乎情理的任务边界。在一些应用程序中,存在明显的工作良好的任务边界,然而还有一些程序,你需要作进一步的分析,以揭示更多可行的并发。
第七章 取消和关闭
-
任务取消:当外部代码能够在活动自然完成之前,把它更改为完成状态,那么这个活动被称为可取消的。活动取消的原因:用户请求取消、限时活动、应用程序事件、错误、关闭。
-
Java没有提供任何机制,来安全的强迫线程停止手头的工作。它提供了中断——一个协作机制,是一个线程能够要求另一个线程停止当前的工作。任务和服务可以这样编码:当要求它们停止时,它们首先清除当前进程中的工作,然后再终止。因而需要一个取消策略。
-
取消策略,取消的how、when、what:其他代码如何请求取消该任务,任务在什么时候检查取消的请求是否到达,响应取消请求的任务中应有的行为。
-
在API和语言规范中,并没有把中断与任何取消的语意绑定起来,但是,实际上,使用中断来处理取消之外的任何事情都是不明智的,并且很难支撑起更大的应用。
-
调用interrupt并不意味着必然停止目标线程正在进行的工作;它仅仅传递了请求中断的消息。我们对中断本身最好的理解应该是:它并不会真正中断一个正在运行的线程;它仅仅发出中断请求,线程自己会在下一个方便的时刻中断(取消点)。
-
中断通常是实现取消最明智的选择。
-
因为每一个线程都有其自己的中断策略,所以你不应该中断线程,除非你知道中断对这个线程意味着什么。
-
调用可中断的阻塞函数时,如Thread.sleep、BlockingQueue.put,有两种处理InterruptedException的实用策略:
-
传递异常(很可能发生在清除特定任务后),使你的方法也成为可中断的阻塞方法;
-
或者恢复中断状态,从而上层调用栈中的代码能够对其进行处理。
-
-
只有实现了线程中断策略的代码才可以接受中断请求。一般性的任务和程序库代码不应该接受中断请求。
-
当Future.get抛出InterruptedException或TimeoutException时,如果你知道不再需要结果时,就可以调用Future.cancel来取消任务了。
-
被不可中断活动阻塞的线程,我们可以用类似于中断的技术停止它们,但这更需要明确线程阻塞的原因。
-
对于拥有线程的服务,只要服务的生存时间大于创建线程的方法的生存时间,就需要提供生命周期方法。
-
生产者-消费者服务关闭的方法:
-
自己提供生命周期方法,生产者原子的添加工作,比较难。
-
使用ExecutorService类:封装ExecutorService,在内部代码中调用ExecutorService的生命周期方法——shutdown、shutdownNow。在非生产者-消费者中也适用。
-
使用毒丸:一个可识别的对象,置于队列中,意味着“当你得到它时,停止一切工作”。要停止服务时,中断生产者——生产者的中断处理中向每一个消费者添加一个毒丸,消费者碰到毒丸,停止工作。
-
-
如果一个方法需要处理一批任务,并在所有任务结束前不会返回,那么他可以通过使用私有的Executor来简化服务的生命周期管理,其中Executor的生命限定在该方法中。
-
shutdownNow的局限性:它试图取消正在进行的任务,并返回那些等待执行的任务的清单,但是我们没法找出那些已经开始执行、却没有结束的任务,这需要自己处理。
-
在一个长时间运行的应用程序中,所有的程序都要给未捕获异常设置一个处理器,这个处理器至少要将异常信息记入日志中。
-
线程分为两种:普通线程和守护线程。两者的区别是:当一个线程退出时,所有仍然存在的守护线程都会被抛弃——不会执行finally块,也不会释放栈——JVM直接退出。
-
管理资源避免使用finalizer。在大多数情况下,使用finally块和显式close方法结合来管理资源,会比使用finalizer起到更好的作用。
-
总结
任务、线程、服务以及应用程序在生命周期结束时的问题,可能会导致向它们引入复杂的设计和实现。Java没有提供具有明显优势的机制来取消活动或者终结线程。它提供了协作的中断机制,能够用来帮助取消,但是这将取决你如何构建取消的协议,并是否能一致的使用该协议。使用FutureTask和Executor框架可以简化构建可取消的任务和服务。
第八章 应用线程池
-
一些任务具有这样的特征:需要或者排斥某种特定的执行策略。对其他任务具有依赖性的任务,就会要求线程池足够大,来保证任务不必排队或者不被拒绝;采用线程限制的任务需要顺序的执行。把这些需求都写入文档,这样将来的维护者就不会使用一个与原来相悖的执行策略,而破坏安全性或活性。
-
在线程池中,如果一个任务依赖其他任务的执行,而线程池不是足够大,这时就可能出现线程饥饿和死锁。
-
无论何时,你提交一个非独立的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进行封装。
-
当每个迭代彼此独立,并且完成循环体中每个迭代的工作,意义都足够重大,足以弥补管理一个新任务的开销时,这个顺序循环是适合并行化的。
-
总结:
对于并发执行的任务,Executor框架是强大且灵活的。它提供了大量可调节的选项,比如创建和关闭线程的策略,处理队列任务的策略,处理过剩任务的策略,并且提供了几个钩子函数用于扩展它的行为。然而,和大多数强大的框架一样,草率的将一些设定组合在一起,并不能很好的工作;一些类型的任务需要特定的执行策略,而一些调节参数组合在一起可能产生意外的结果。
第九章 GUI应用程序
-
几乎所有的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能够进行额外的优化,大大减少或消除了开销。关注那些真正发生了锁竞争的区域中性能的优化。
-
串行化会损害可扩展性,上下文切换会损害性能,因而竞争性的锁会同时导致这两种损害。
-
在并发程序中,对可扩展性最大的威胁是独占的资源锁(并发中的串行部分)。
-
有三种方式来减少锁的竞争:
-
减少持有锁的时间;
-
减少请求锁的频率;
-
或者用协调机制取代独占锁,从而允许更强的并发性。
-
具体的方法有:缩小锁的范围(“快进快出”)、减少锁的粒度(通过锁分拆和锁分离来实现,就是采用多个相互独立的锁代替一个锁,但复杂度提高了,而且容易出现死锁)、避免热点域
-
分拆锁——把锁分为两个相互独立的锁;分离锁——把锁分成多个相互独立的锁。分离锁的一个缺点是:对整个容器加排他锁时,更加困难,更加昂贵了,需要对所有分离出的锁加锁。
-
多线程中存在两种竞争:对锁的竞争和对锁守护数据的竞争。锁分拆和锁分离减少了锁的竞争,而数据的竞争则需要避免使用热点域。
-
可以使用并发容器、读-写锁、不可变对象以及原子变量来代替独占锁。
-
在并发中,不应该使用“对象池”:如同线程池一样,对象会被循环的使用。它会提供性能,减少了对象的销毁和创建,但在多线程中,使用对象池需要很好的在线程间协调,这会带来同步。分配对象通常比同步要便宜。
-
减少上下文切换:把专业的事情由专业的线程来完成,如日志记录由专门的后台线程完成,减少了I/O竞争,减少了上下文切换。
-
总结:因为使用线程最主要的目的是利用多处理器资源,在并发程序性能的讨论中,我们通常更多的关注吞吐量和可扩张性,而没有强调原始的服务时间。Amdahl定律告诉我们,程序的可扩张性是由必须连续执行的代码比例决定的。因为Java程序中串行化首要的来源是独占的资源锁,所以可扩展性通常可以通过以下这些方式提升:减少用于获取锁的时间,减少锁的粒度,减少锁的占有时间,或者用非独占或非阻塞锁来取代独占锁。
第十二章 测试并发程序
-
为并发程序创建测试,所要面临的主要挑战在于:那些潜在的故障并不具有确定性,而是随机的发生;能够揭示这种失败的测试,与普通的顺序测试相比,一定要有更广泛的覆盖度和更长的运行时间。
-
并发类的测试基本分为两类:对安全性与活性的测试。与活性相关的测试是性能测试,性能测试可以通过很多方式来测量,其中包括:吞吐量、响应时间和可扩展性。
-
添加了调试和测试代码后,会屏蔽某些bug。
-
Thread.getState不能用在并发测试中。
-
为并发类创建有效的安全测试,其挑战在于:如何在程序出现问题并导致某些属性极度可能失败时,简单地识别出这些受检查的属性来,同时不要人为地让查找错误的代码限制住程序的并发性,最好能做到在检查测试的属性时,不需要任何的同步。
-
使用CountDownLatch和CyclicBarrier可以是线程同时开始启动。
-
测试应该在多处理器系统上运行,以提高潜在交替运行的多样性。但是,CPU数量的增多未必会使测试更加高效。为了能够最大程度的检测到时序敏感数据的竞争,应该让活动的线程数多于CPU数,这样在任何给定的时间里,都有一些线程在运行,一些替换出执行队列,这样可以减少线程间交替行为的可预见性。
-
产生更多的交替操作,这样有助于发现低概率的潜在错误。两个技巧来产生更多的交替操作:一是线程的数目多于CPU的数目;二是在访问共享状态的操作期间,使用Thread.yield激发更多的上下文转换。
-
并发测试:首先是构建测试框架,在单线程的情况下对程序进行某些测试,如构造函数的测试、程序的基本功能测试;接着在多线程的情况下,测试程序的正确性,包括安全性测试和对资源管理的测试;最后是性能测试,主要是吞吐量测试和响应时间测试。
-
避免性能测试的陷阱
-
垃圾回收:垃圾回收的时序是不可预知的,任何时候垃圾回收器都有可能运行。有两种策略可以避免垃圾回收对你测试结果带来的误差。一是确保测试运行的整个期间,垃圾回收根本不会执行(通过调用JVM时使用-verbose:gc可以做到);二是确保执行测试期间垃圾回收器运行多次,这样测试程序能够充分反映出运行期间的分配与垃圾回收的开销。
-
动态编译:Java是动态编译语言,当一个类被首次加载后,JVM会以解释字节码的方式执行。当方法运行的足够频繁,就会转为编译执行。有两种方法可以避免动态编译对测试的影响。一种就是让你的程序长时间运行,这样编译过程和解释执行仅仅占了总体时间的很小一部分。另一种方法是让代码先进行不做测试的“热身”运动,使他得以充分执行,这样在开始计时前,代码就被完全编译了。
-
-
编写有效地性能测试,就需要哄骗优化器不要把你的beachmark当做死代码而优化掉。这需要把每一个计算的结果都应用到你的程序中——以一种不需要同步或大量计算的方式。
-
测试时质量审查(quality assurance,QA)的一种方法,还有其他QA方法:
-
代码审查:请代码作者以外的人仔细的审查代码;
-
静态分析工具:不需要执行代码,就可以对代码进行分析。代表工具是开源的FindBugs。
-
面向方面(aspect-oriented)的测试技术
-
统计与剖析工具:大多数商业统计工具对线程有一些支持。
-
-
总结
测试并发程序的正确性是一项极大的挑战,因为并发程序很多可能的失败模式都是低可能性的事件,它们很容易受到时序、加载和其他一些难以再现的条件的影响。更进一步而言,在测试基础架构时,还会引入额外的同步或者分时的约束,这些会屏蔽被测代码中的并发问题,测试并发程序的性能同样是一项不小的挑战;比起使用像C这种静态编译语言编写的程序,Java程序格外的难以测试,因为动态编译、垃圾回收以及自动的优化,都会影响对时间的测量。
为了尽可能发现潜在的bug,避免在生产环境中才发现它们,应该在运用传统的测试技术(谨慎地避免这里讨论过的各种缺陷)的同时,结合代码审查和自动化分析工具。每种技术都会发现其他技术可能忽略掉的问题。
第十三章 显式锁
-
Lock提供了无条件的、可轮询的、定时的、可中断的锁获取方式,所有的加锁和解锁的方法都是显式的。
-
性能是一个不断变化的目标;昨天的beachmark显示X比Y更快,这可能已经过时了。
-
ReentrantLock提供了公平锁和非公平锁两种公平保证。非公平锁性能比公平锁性能优,因为:挂起的线程重新开始,与它真正开始运行,两者之间会产生严重的延迟。默认为非公平锁。
-
在内部锁不能够满足使用时,ReentrantLock才被作为更高级的工具。当你需要以下高级特性时,才应该使用:可定时的、可轮询的与可中断的锁获取操作,公平队列,或者非块结构的锁,否则,请使用synchronized。
-
ReentrantReadWriteLock实现了可重入、可降级、不可升级、两种公平性的读-写锁。
-
总结
显示的Lock与内部锁相比提供了一些扩展的性能,包括处理不可用的锁时更好的灵活性,以及对队列行为更好的控制。但是ReentrantLock不能完全替代synchronized,只有当你需要synchronized没能提供的特性时才应该使用。
读-写锁允许多个读者并发访问被守护的对象,当访问多为读取数据结构的时候,它具有改进可扩展性的潜力。
第十四章 构建自定义的同步工具
-
管理状态依赖性,处理先验条件失败的方法:
-
最简单的是——将先验条件失败传给调用者,让调用者处理
-
利用“轮询加休眠”实现拙劣的阻塞,封装先验条件的处理
-
使用条件队列,跟“轮询加休眠”功能一样,但能够及时的醒来。
-
-
条件谓词是先验条件的第一站,它在一个操作与状态之间建立起依赖关系。如在有限缓存中,只有缓存不为空时take才能执行,否则它必须等待,所以就take而言,它的条件谓词是“缓存不空”。
-
每次调用wait都会隐式地与特定的条件谓词相关联。当调用特定条件谓词的wait时、调用者必须已经持有了与条件队列相关的锁,这个锁必须同时还保护着组成条件谓词的状态变量。
-
当使用条件等待时(Object.wait或者Condition.wait):
-
永远设置一个条件谓词——一些对象状态的测试,线程执行前必须满足它;
-
永远在调用wait前测试条件谓词,并且从wait中返回后再次测试;
-
永远在循环中调用wait;
-
确保构成条件谓词的状态变量被锁保护,而这个锁正是与条件队列相关联的;
-
当调用wait、notify或者notifyAll时,要持有与条件队列相关联的锁;并且,
-
在检查条件谓词之后、开始执行被保护的逻辑之前,不要释放锁。
-
-
当一个线程等待的特定条件已经为真,但是进入等待前检查条件谓词却返回了假,我们称这样就出现了一个丢失的信号。即条件为真时,没有唤醒在等待的线程。
-
无论何时,当你在等待一个条件,一定要确保有人会在条件谓词变为真时通知你。
-
等待唤醒时,注意notify和notifyAll的区别。
-
一个依赖于状态的类,要么完全将它的等待和通知协议暴露(并文档化)给子类,要么完全阻止子类参与其中。
-
危险警告:wait、notify和notifyAll在Condtion对象中的对等体是await、signal和signalAll,但是,Condition继承于Object,这意味着它也有wait和notify方法,一定要确保使用了正确的版本——await和signal!。
-
Java.util.concurrent中很多可阻塞的类,比如ReentrantLock、Semaphore、ReentrantReadWriteLock、CountDownLatch、SynchronousQueue和FutureTask,全部是用AbstractQueueSynchronizer构建的。
-
总结
如果你需要实现一个依赖于状态的类——如果不能满足依赖状态的前提条件,类的方法必须阻塞——最佳的策略通常是将它构建于现有的类库之上,比如Semaphore、BlockingQueue或者CountDownLatch。但是有时现有类库不能提供足够的功能,在这种情况下,你可以使用内部条件队列、显示Condtion对象或者AbstractQueueSynchronizer,来构建属于自己的Synchronizer。内部条件队列与内部锁紧密的绑定到了一起。显式的Condition是与显式的Lock绑定在一起的,而且它们比内部条件队列还多提供了一个可扩展的特征集。
第十五章 原子变量与非阻塞同步机制
-
锁的劣势:1.锁的调度是需要开销的(性能)2锁的安全性问题,如死锁、优先级反转、护航等问题。
-
原子变量类共有12个,分成4组:计量器、域更新器(field updater)、数组以及复合操作。计量器:AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference、域更新器:AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdate。数组:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。复合操作:AtomicMarkReference、AtomicStampedReference。
-
总结:
非阻塞算法通过使用低层级并发原语,比如比较并交换(CAS),取代了锁。原子变量类向用户提供了这些低层级原语,也能够当做“更佳的volatile变量”使用,同时提供了整数类和对象引用的原子化更新操作。
非阻塞算法在设计和实现中很苦难,但是在典型条件下能够提供更好的可扩展性,并能更好的预防活跃度失败。从JVM的一个版本到下一个版本间并发性能的提升很大程度上来源于非阻塞算法的使用,包括在JVM内部以及平台类库。
第十六章 Java存储模型
-
处理器架构提供了不同级别的缓存一致性,但为了提高性能,处理器会牺牲一致性的保证。为了告诉应用程序可以从它的存储系统中获得何种担保,定义了一些特殊的指令——存储关卡或栅栏。幸运的是,在多线程共享数据时,正确的使用同步就可以阻止编译器的某些优化。
-
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的底层细节的情况下,也能确保线程安全性。