《Java并发编程实战》学习笔记
第2章 线程安全性
正确性:
某个类的行为与其规范完全一致。
2.1线程安全:
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类就能表现出正确的行为,那么就称这个类是线程安全的。
无状态对象:
既不包含任何域,也不包含任何其他类中域的引用的对象。
无状态对象一定是线程安全的。
竞态条件:
当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。
本质是基于一种可能失效的观察结果来做出判断或者执行某个计算。
最常见的竞态条件类型就是“先检查后执行(Check-then-Act)”。
“读取-修改-写入”操作也是一种竞态条件。
2.2原子性:
假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。
原子操作:
对于访问同一个状态的所有操作(包括操作本身)来说,这个操作是一个以原子方式执行的操作。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
2.3内置锁:
每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock)。
内置锁是一种互斥锁,最多只有一个线程持有这个锁。
同步代码块(Synchronized Block)包括两部分:
一个作为锁的对象引用,一个作为由这个锁保护的代码块。
关键字Synchronized修饰方法就是一种同步代码块,锁就是方法调用所在的对象,静态的Synchronized方法以Class对象作为锁。
重入:
因为内置锁是可重入的,所以如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。
重入意味着获取锁的操作粒度是“线程”,而不是“调用”。
实现方式:为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁被认为没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数值减1。
重入提升了加锁行为的封装性,因此简化了面向对象并发代码的执行。
2.4用锁来保护状态:
锁能够使其被保护的对象以串行方式来执行。
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
对象的内置锁与其状态之间没有内在的联系,虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。
一种常见的加锁约定:将所有可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得该对象上不会发生并发访问。
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
2.5活跃性与性能:
将同步代码块分解得过细并不好,因为获取与释放锁操作需要开销。
当执行时间较长的计算或者可能无法快速完成的操作时(如I/O),一定不要持有锁。
同时使用两种不同的同步机制会带来混乱,在性能或安全性上也没有任何好处。(如内置锁synchronized和Atomic原子变量) 。
第3章 对象的共享
同步,并不仅仅只包括原子性这一项内容,还有另外一个非常重要的方面:内存可见性(Memory Visibility)。
3.1重排序:
比如两步赋值操作,赋值的顺序可能会跟看到的顺序相反。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
失效数据:
在没有同步的情况下,线程去读取变量时,可能会得到一个已经失效的值。更糟糕的是,失效值可能不会同时出现:一个线程可能获得某个变量的最新值,而获得另一个变量的失效值。
最低安全性:
当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性(out-of-thin-airsafety)。
最低安全性适用于绝大多数变量,当时存在一个例外:非volatile类型的64位数值变量。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。
加锁和可见性:
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
Volatile变量:
这是一种稍微弱一点的同步机制,主要就是用于将变量的更新操作通知到其它线程。
加锁机制既可以保证可见性又可以保证原子性,而volatile变量只能确保可见性。
可见性:在读取volatile类型的变量时总会返回最新写入的数据。
禁止指令重排序:不会将该变量上的操作与其它内存操作一起重排序。
仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。
典型用法:检查某个状态标记以判断是否退出循环。
1 volatile boolean flag ; 2 while(!flag){ 3 dosomething(); 4 }
volatile的语义不足以确保count++的原子性。
当且仅当满足以下所有条件时,才应该使用volatile变量:
对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
该变量不会与其他状态变量一起纳入不变性条件中。
在访问变量时不需要加锁。
3.2发布:
是对象能够在当前作用域之外的代码中使用。可以是以下几种情况:
①将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看到该对象;
②发布某个对象的时候,在该对象的非私有域中引用的所有对象都会被发布;
③发布一个内部的类实例,内部类实例关联一个外部类引用。
逸出:
某个不应该发布的对象被公布的时候。某个对象逸出后,你必须假设有某个类或线程可能会误用该对象,所以要封装。
不要在构造过程中使this引用逸出。
常见错误:在构造函数中启动一个线程。
3.3线程封闭:
当访问共享的可变数据时,通常需要使用同步。一种避免同步的方式就是不共享数据。
仅在单线程内访问数据,就不需要同步,这种技术被称为线程封闭(Thread Confinement)。
典型应用:①Swing的可视化组件和数据模型对象都不是线程安全的,Swing通过将它们封闭到Swing的实际分发线程中来实现线程安全;②JDBC的Connection对象。
线程封闭技术:
①Ad-hoc线程封闭:维护线程封闭性的职责完全由程序实现来承担。
在volatile变量上存在一个特殊的线程封闭:能确保只有单个线程对共享的volatile变量执行写入操作(其他线程有读取volatile变量),那么就可以安全地在这些共享的volatile变量上执行“读取-修改-写入”的操作,而其他读取volatile变量的线程也能看到最新的值。
②栈封闭:在栈封闭中,只能通过局部变量才能访问对象。
③ThreadLocal类:这是线程封闭的更规范方法,这个类能使线程中的某个值与保存值的对象关联起来。
提供get()和set()方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。
ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。怎么理解呢?还是JDBC的Connection对象,防止共享,所以通常将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接。
3.4不变性:
满足同步需求的另一种方法是使用 不可变对象(Immutable Object)。
不可变对象一定是线程安全的。
当满足以下这些条件的时候,对象才是不可变的:
对象创建以后其状态就不能被修改;
对象的所有域都是 final 类型;
对象是正确创建的(在对象的创建期间,this引用没有逸出)。
两种很好的编程习惯:
除非使用更高的可见性,否则应将所有的域都声明为私有的;
除非需要某个域是可变的,否则都应该声明为final域;
3.5安全发布的常用模式:
因为可变对象必须以安全的方式发布,这就意味着发布和使用该对象的线程时都必须使用同步。
要安全的发布一个对象,对象的引用以及对象的状态必须同时对其它线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
在静态初始化函数中初始化一个对象引用。
将对象的引用保存到volatile类型的于或者AtomicReferance对象中。
将对象的引用保存到某个正确构造对象的final类型域中。
将对象的引用保存到一个由锁保护的域中。
安全地共享对象:
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改;
只读共享。在没有额外的同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。
线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的共有接口来进行访问而不需要进一步的同步。
保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
第5章 基础构建模块
5.1同步容器类:
同步容器:可以简单地理解为通过synchronized来实现同步的容器,比如Vector、Hashtable以及SynchronizedList等容器,如果有多个线程调用同步容器的方法,它们将会串行执行。
线程安全实现的方式:将他们的可变成员变量封装起来,并对每个方法都进行同步,使得每次仅仅有一个线程能访问这些可变的成员变量。
尽管这些类的方法都是同步的,但当并发访问多个方法的时候,还是有可能出错。
比如,有两个线程,一个执行同步的get方法,一个执行同步的remove方法,那么这两个线程仍然可能出现竞态条件(多个线程不同的时序导致程序出问题):“先remove在get就会出现问题”,在使用的时候仍然要注意。
ConcurrentModificationException异常:
如果有其他线程并发的修改容器,就要在迭代期间对容器加锁。
当迭代器发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException异常。
具体过程:将计数器的变化与容器关联起来,如果在迭代期间计数器被修改,那么hasNext()和next()将抛出如上异常。
如果在迭代期间不希望对容器加锁,那么一种替代方法就是“克隆”容器,并在副本上进行迭代。
隐藏迭代器:
调用了vector的toString()函数,这个函数就是一个隐藏的迭代过程。如果在这个过程中,一个线程获得了CPU并且执行了remove()方法,也会报告ConcurrentModificationException异常。
hashCode和equals也会间接执行迭代操作。
所以在所有对共享容器进行迭代的地方都要加锁。
5.2并发容器:
java 5.0提供了多种并发容器类来改进同步容器类的性能。同步容器将所有对容器状态的访问都串行化,以实现线程安全。严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重降低。
通过并发容器来代替同步容器,可以极大的提高伸缩性并且降低安全性风险。
ConcurrentHashMap:
基于散列的Map,并不是将每个方法都在同一个锁上同步使得每次只能有一个线程访问线程,而使用一种更细粒度的加锁机制来实现更大程度的共享。这种机制称为分段锁(Lock Striping)。在这种机制中,任意数量的读取线程可以并发地访问map,执行读取操作的线程和执行写入操作的线程可以并发地访问map,并且一定数量的写入线程可以并发地修改map。ConcurrentHashMap带来的结果是,在并发访问的环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。
ConcurrentHashMap与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁。ConcurrentHashMap返回的迭代器具有弱一致性。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反应给容器。
与 Hashtable 和 synchronizedMap 相比,ConcurrentHashMap 有更多的优势以及更少的劣势。因此在大多数情况下,用 ConcurrentHashMap 来代替同步 Map 能进一步提高代码的可伸缩性。只有当应用程序需要加锁Map 以进行独占访问时,才能放弃使用 ConcurrentHashMap。
CopyOnWriteArrayList:
用于替代同步List,在迭代期间不需要对容器进行加锁或复制。(类似,CopyOnWriteArraySet代替同步set)。
“写入时复制(Copy-On-Write)”容器的线程安全性在于,只要是正确发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。
每次修改容器都会复制底层数组,需要一定的开销。仅当迭代的操作远远多于修改操作时,才应该使用“写入时复制“的容器。
5.3阻塞队列和生产者-消费者模式:
BlockingQueue阻塞队列提供可阻塞的put和take方法,以及支持定时的offer和poll方法。如果队列已经满了,那么put方法将阻塞直到空间可用;如果队列为空,那么take方法将阻塞直到有元素可用。
队列可以是有界的也可以是无界的。
BlockingQueue的多种实现:
LinkedBlockingQueue和ArrayBlockingQueue:是FIFO,二者分别与LinkedList和ArrayList类似,但比同步List拥有更好的并发性能。
PriorityBlockingQueue:是一个按优先级排序的队列,当你希望按照某种顺序而不是FIFO来处理元素时,这个队列将非常有用。
SynchronousQueue:事实上它并不是一个真正的队列,因为它不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待这把元素加入或移出队列。
如果以洗盘子为比喻,就相当于没有盘架来暂时存放洗好的盘子,而是将洗好的盘子直接放入下一个空闲的烘干机中。
串行线程封闭:
优点:对于可变对象,生产者-消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。
线程封闭对象只能由单个线程拥有,通过安全地发布该对象“转移”所有权,实现了转移前由前一线程独占,转移后由后一线程独占。
实现方法:阻塞队列使得这种线程封闭的所有权转移变得容易,其次还可以通过ConcurrentMap的原子方法remove或者AtomicReference的原子方法compareAndSet来完成这项工作。
双端队列与工作密取:
Java 6增加了两种容器类型,Deque和BlockingDeque这两种容器类型,分别对Queue和BlockingQueue进行了扩展。
Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现包括ArrayDeque和LinkedBlockingDeque。
在生产者-消费者模式中,所有消费者有一个共享的工作队列,而在工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。
密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列上发生竞争。这是因为工作者线程不会在单个共享的任务队列上发生竞争。在大多数情况下,它们都只是访问自己的双端队列,从而极大地减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是头部获取工作,因此进一步降低了队列上的竞争程度。
第6章 任务执行
任务通常是一些抽象的且离散的工作单元。通过把应用程序的工作分解到多个任务中,可以简化程序的组织结构。
比如页面渲染器要执行绘制文本元素和绘制图像的任务。
6.1在线程中执行任务:
并发程序设计的第一步就是要划分任务的边界,理想情况下就是所有的任务都独立的:每个任务都是不依赖于其他任务的状态,结果和边界。因为独立的任务是最有利于并发设计的。
有一种最自然的任务划分方法就是以独立的客户请求为任务边界。每个用户请求是独立的,则处理任务请求的任务也是独立的。
显示地为任务创建线程:
任务处理线程从主线程分离出来,使得主线程不用等待任务完毕就可以去快速地去响应下一个请求,以达到高响应速度;
任务处理可以并行,支持同时处理多个请求;
任务处理是线程安全的,因为每个任务都是独立的。
无限制创建线程的不足:
线程的生命周期的开销很大:每创建一个线程都是要消耗大量的计算资源;
资源的消耗:活跃的线程要消耗内存资源,如果有太多的空闲资源就会使得很多内存资源浪费,导致内存资源不足,多线程并发时就会出现资源强占的问题;
稳定性:可创建线程的个数是有限制的,过多的线程数会造成内存溢出;
6.2Executor框架:
任务是一组逻辑工作单元,而线程则是任务异步执行的机制。为了让任务更好地分配到线程中执行,java.util.concurrent提供了Executor框架。
Executor基于生产者-消费者模式:提交任务的操作相当于生产者(生成待完成的工作单元),执行任务的线程则相当于消费者(执行完这些工作单元)。
通过使用Executor,将请求处理任务的提交与任务的实际执行解耦开来。
线程池:
线程池从字面意思来看,是指管理一组同构工作线程的资源池。
在线程池中执行任务比「为每一个任务分配一个线程」优势更多。通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。
另外一个额外的好处是,当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性。
Executors中的静态工厂方法提供了一些线程池:
newFixedThreadPool:固定长度的线程池
newCachedThreadPool:可缓存的线程池,线程池的规模不存在限制
newSingleThreadExecutor:单线程的线程池
newScheduledThreadPool:固定长度,且以延迟或定时的方式来执行任务
Executor的生命周期:
ExecutorService提供了两种方法关闭方法:
shutdown: 平缓的关闭过程,即不再接受新的任务,等到已提交的任务执行完毕后关闭进程池;
shutdownNow: 立刻关闭所有任务,无论是否再执行;
延迟任务和周期性任务:
Java中提供Timer来执行延时任务和周期任务,但是Timer类有以下的缺陷:
Timer只会创建一个线程来执行任务,如果有一个TimerTask执行时间太长,就会影响到其他TimerTask的定时精度;
Timer不会捕捉TimerTask未定义的异常,所以当有异常抛出到Timer中时,Timer就会崩溃,而且也无法恢复,就会影响到已经被调度但是没有执行的任务,造成“线程泄露”。
建议使用ScheduledThreadPoolExecutor来代替Timer类。
6.3找出可利用的并行性:
Executor以Runnable的形式描述任务,但是Runnable有很大的局限性:
没有返回值,只是执行任务;
不能处理被抛出的异常;
为了弥补以上的问题,Java中设计了另一种接口Callable。
Callable:
Callable支持任务有返回值,并支持异常的抛出。如果希望获得子线程的执行结果,那Callable将比Runnable更为合适。
无论是Callable还是Runnable都是对于任务的抽象描述,即表明任务的范围:有明确的起点,并且都会在一定条件下终止。
Executor框架下所执行的任务都有四种生命周期:
创建;
提交;
开始;
完成;
对于一个已提交但还没有开始的任务,是可以随时被停止;但是如果一个任务已经如果已经开始执行,就必须等到其相应中断时再取消;当然,对于一个已经执行完成的任务,对其取消任务是没有任何作用的。
Future:
Future类表示任务生命周期状态,并提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果和取消任务等,其命名体现了任务的生命周期只能向前不能后退。
Future类提供方法查询任务状态外,还提供get方法获得任务的返回值,但是get方法的行为取决于任务状态:
如果任务已经完成,get方法则会立刻返回;
如果任务还在执行中,get方法则会拥塞直到任务完成;
如果任务在执行的过程中抛出异常,get方法会将该异常封装为ExecutionException中,并可以通过getCase方法获得具体异常原因;
如果将一个Callable对象提交给ExecutorService,submit方法就会返回一个Future对象,通过这个Future对象就可以在主线程中获得该任务的状态,并获得返回值。
除此之外,可以显式地把Runnable和Callable对象封装成FutureTask对象,FutureTask不光继承了Future接口,也继承Runnable接口,所以可以直接调用run方法执行。
CompletionService:
CompletionService可以理解为Executor和BlockingQueue的组合:当一组任务被提交后,CompletionService将按照任务完成的顺序将任务的Future对象放入队列中。
除了使用CompletionService来一个一个获取完成任务的Future对象外,还可以调用ExecutorSerive的invokeAll()方法。
invokeAll支持限时提交一组任务(任务的集合),并获得一个Future数组。invokeAll方法将按照任务集合迭代器的顺序将任务对应的Future对象放入数组中,这样就可以把传入的任务(Callable)和结果(Future)联系起来。当全部任务执行完毕,或者超时,再或者被中断时,invokeAll将返回Future数组。
当invokeAll方法返回时,每个任务要么正常完成,要么被取消,即都是终止的状态了。
第8章 线程池的使用
8.1任务与执行策略之间的隐性耦合
并非所有的任务都能使用所有的执行策略。有些类型的任务需要明确地指定执行策略,包括:
依赖性任务:如果提交给线程池的任务需要依赖其他的任务,那么就隐含地给执行策略带来了约束,此时必须小心地维持这些执行策略以避免产生活跃性问题。
使用线程封闭机制的任务:任务要求其执行所在的Executor是单线程的。如果将Executor从单线程环境改为线程池环境,那么将失去线程安全性。
对响应时间敏感的任务:如果将一个运行时间较长的任务提交到单线程的Executor中,或者将多个运行时间较长的任务提交到一个只包含少量线程的线程池中,那么将降低由该Executor管理的服务的响应性。
使用ThreadLocal的任务:只有当线程本地值的生命受限于任务的生命周期时,在线程池的线程中使用ThreadLocal才有意义,而在线程池中不应该使用ThreadLocal在任务之间传递值。
线程饥饿死锁:
在线程中,如果任务依赖与其他任务,那么可能产生死锁。
在单线程的Executor中,如果一个任务将另一个任务提交到同一个Executor,并且等待这个被提交任务的结果,那么通常会引发死锁。第二个任务停留在工作队列中,并等待第一个任务完成,而第一个任务又无法完成,因为它在等待第二个任务的完成。
在更大的线程池中,如果所有正在执行的任务的线程都由于等待其他仍处于工作队列的任务而阻塞,那么会发生同样的问题,这个现象被称为线程饥饿死锁(Thread Starvation Deadlock)。
运行时间较长的任务:
有限线程池线程可能会被执行时间长任务占用过长时间,最终导致执行时间短的任务也被拉长了“执行”时间。可以考虑限定任务等待资源的时间,而不要无限制地等待。
8.2设置线程池的大小
线程池的理想大小取决于被提交任务的类型以及所部署系统的特性。在代码中通常不会固定线程池的大小,而应该通过某种配置机制来提供,或者根据Runtime.availableProcessors来动态计算。
在计算密集型的任务,在拥有Ncpu个处理器的系统上,当线程池的大小为Ncpu+1,通常能实现最优的利用率。
对于包含I/O操作或其他阻塞操作的任务,由于线程不会一直执行,因此线程池的规模应该更大、要正确地设置线程池的大小,你必须估算出任务的等待时间与计算时间的比值,这可以通过一些分析或监控工具来获得。
CPU周期并不是唯一影响线程池大小的资源,还包括内存,文件句柄,套接字句柄和数据库连接等。
8.3配置ThreadPoolExecutor
ThreadPoolExecutor是一个灵活的,稳定的线程池,允许进行各种定制。
1 public ThreadPoolExecutor(int corePoolSize, 2 int maximumPoolSize, 3 long keepAliveTime, 4 TimeUnit unit, 5 BlockingQueue<Runnable> workQueue, 6 ThreadFactory threadFactory, 7 RejectedExecutionHandler handler) { ... }
corePoolSize:基本大小也就是线程池的目标大小,即在没有任务执行时(初期线程并不启动,而是等到有任务提交时才启动,除非调用prestartAllCoreThreads)线程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。
maximumPoolSize:线程池的最大大小表示可同时活动的线程数量的上限。如果某个线程的空闲时间超过了存活时间,那么将被标记为可回收的,并且当线程池的当前大小超过了基本大小时,这个线程将被终止。
newFixedThreadPool:工厂方法将线程池的基本大小和最大大小设置为参数中指定的值,而且创建的线程池不会超时。
newCachedThreadPool:工厂方法将线程池的最大大小设置为Integ.MAX_VALUE,而且将基本大小设置为0,并将超时设置为1分钟,这种方法创建的线程池可以被无限扩展,并且当需求降低时会自动收缩。
执行excute方法:
1 .如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(需要获得全局锁)
2 .如果运行的线程等于或多于corePoolSize ,则将任务加入BlockingQueue
3 .如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务(需要获得全局锁)
4. 如果创建新线程将使当前运行的线程超出maxiumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()方法。
管理队列任务:
ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务。
基本的任务排队方法有3种:无界队列(unbounded queue,),有界队列(bounded queue,)和同步移交(synchronous handoff)。
newFixedThreadPool和newSingleThreadExecutor在默认情况下将使用一个无界的LinkedBlockingQueue。
如果所有工作者线程都处于忙碌状态,那么任务将在队列中等候。如果任务持续地达到,并且超过了线程池处理它们的速度,那么队列将无限制地增加。
一种更稳妥的资源管理策略是使用有界队列,例如ArrayBlockingQueue,有界的LinkedBlockingQueue,PriorityBlockingQueue。
有界队列有助于避免资源耗尽的情况发生,但队列填满后,由饱和策略解决。
在newCachedThreadPool工厂方法中使用了SynchronousQueue。
对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务排队,以及直接将任务从生产者移交给工作者线程。
SynchronousQueue不是一个真正的队列,而是一种在线程之间移交的机制。
要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接受这个元素。如果没有线程正在等待,并且线程池的当前大小小于最大值,那么TrheadPoolExecutor将创建一个新的线程,否则根据饱和策略,这个任务将被拒绝。 使用直接移交将更高效,因为任务会直接移交给执行它的线程,而不是首先放在队列中,然后由工作者线程从队列中提取该任务。
只有当线程池是无界的或者可以拒绝任务时,SynchronousQueue才有实际价值。
对于Executor,newCachedThreadPool工厂方法是一种很好的默认选择,他能提供比固定大小的线程更好的排队性能(由于使用了SynchronousQueue而不是LinkedBlockingQueue)。
当需要限制当前任务的数量以满足资源管理需求时,可以选择固定大小的线程池,就像在接受网络用户请求的服务器应用程序中,如果不进行限制,容易发生过载问题。
饱和策略:
当有界队列被填满后,饱和策略开始发挥作用。
ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。(如果某个任务被提交到一个已被关闭的Executor时,也会用到饱和策略)
AbortPolicy:“中止(Abort)策略”是默认的饱和策略,该策略将抛出未检查的Rejected-ExecutionException。调用这可以捕获这个异常,然后根据需求编写自己的处理代码。
DiscardPolicy:“抛弃(Discard)策略”会抛弃超出队列的任务。
DiscardOldestPolicy:“抛弃最旧策略“则会抛弃下个将被执行任务,然后尝试重新提交新的任务。(如果工作队列是一个优先队列,那么抛弃最旧策略将导致抛弃优先级最高的任务,因此最好不要将抛弃最旧饱和策略和优先级队列一起使用)
CallerRunsPolicy:“调用者运行策略“实现了一种调节机制。该策略不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而减低新任务的流量。 它不会在线程池的某个线程中执行新提交的任务,新任务会在调用execute时在主线程中执行。
线程工厂:
每当线程池需要创建一个线程时,都是通过线程工厂方法来完成的。
默认的线程工厂方法将创建一个新的,非守护的线程,并且不包含特殊的配置信息。
通过指定一个线程工厂方法,可以定制线程池的配置信息。
在ThreadFactory中只定义了一个方法newThread,每当线程池需要创建一个新线程都会调用这个方法。
在调用构造函数后再定制ThreadPoolExecutor:
在调用完ThreadPoolExecutor的构造函数后,仍然可以通过设置函数(Setter)来修改大多数传递给它的构造函数的参数(例如线程池的基本大小,最大大小,存活时间,线程工厂以及拒绝执行处理器(rejected execution handler))。如果Executor是通过Executors中的某个(newSingleThreadExecutor除外)工厂方法创建的,那么可以将结果的类型转换为ThreadPoolExecutor以访问设置器。
8.4扩展ThreadPoolExecutor
ThreadPoolExecutor是可扩展的,它提供了几个可以在子类化中改写的方法:beforeExecute,afterExecute和terminated,这些方法可以用于扩展ThreadPoolExecutor的行为。 在执行任务的线程中将调用beforeExecute和afterExecute等方法,在这些方法中还可以添加日志,计时,监视或统计信息收集的功能。
无论是从run中正常返回,还是抛出一个异常而返回,afterExecute都会被调用。(如果任务在完成后带有一个Error,那么就不会调用afterExecute)如果beforeExecute抛出一个RuntimeException,那么任务将不被执行,并且afterExecute也不会被调用。
在线程池完成关闭时调用terminated,也就是在所有任务都已经完成并且所有工作者线程也已经关闭后。terminated可以用来释放Executor在其生命周期里分配的各种资源,此外还可以执行发送通知,记录日志或收集finalize统计信息等操作。
8.5 递归算法的并行化
如果在循环中包含了一些密集计算,或者需要执行可能阻塞的I/O操作,那么只要每次迭代是独立的,都可以对其进行并行化。
1 void processSequentially(List<Element> elements) { //串行 2 for (Element e : elements) 3 process(e); 4 } 5 void processInParallel(Executor exec, List<Element> elements) { //并行 6 for (final Element e : elements) 7 exec.execute(new Runnable() { 8 public void run() { process(e); } 9 }); 10 }
第10章 避免活跃性危险
10.1 死锁
当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁,那么它们将永远被阻塞。
在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远等待下去。这种情况就是最简单的死锁形式
数据库中监测死锁以及从死锁中恢复:
当检测到了一组事务发生死锁时(通过在表示等待关系的有向图中搜索循环),将选择一个牺牲者并放弃这个事务。
如果所有线程都按照固有的顺序来获取锁,那么在程序中就不会出现锁顺序死锁的问题
如果在持有锁时调用某个外部方法,那么将出现活跃性问题,在这个外部方法中有可能会获取其他锁(这时有可能出现像之前的顺序死锁现象),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁
开放调用:
相对于持有锁时调用外部方法的情况,如果在调用某个方法时不需要持有锁,这种调用就叫做“开放调用”
在程序中应该尽量使用开放调用,以便于对依赖于开放调用的程序进行死锁分析
开放调用可能会使某个原子操作变成非原子操作,有时非原子操作也是可以接受的
1 class CooperatingNoDeadlock { 2 class Taxi { 3 private Point location, destination; 4 private final Dispatcher dispatcher; 5 public Taxi(Dispatcher dispatcher) { 6 this.dispatcher = dispatcher; 7 } 8 public synchronized Point getLocation() { 9 return location; 10 } 11 public void setLocation(Point location) { 12 boolean reachedDestination; 13 synchronized (this) { 14 this.location = location; 15 reachedDestination = location.equals(destination); 16 } 17 if (reachedDestination) 18 dispatcher.notifyAvailable(this); 19 } 20 } 21 class Dispatcher { 22 private final Set<Taxi> taxis; 23 private final Set<Taxi> availableTaxis; 24 public Dispatcher() { 25 taxis = new HashSet<Taxi>(); 26 availableTaxis = new HashSet<Taxi>(); 27 } 28 public synchronized void notifyAvailable(Taxi taxi) { 29 availableTaxis.add(taxi); 30 } 31 public Image getImage() { 32 Set<Taxi> copy; 33 synchronized (this) { 34 copy = new HashSet<Taxi>(taxis); 35 } 36 Image image = new Image(); 37 for (Taxi t : copy) 38 image.drawMarker(t.getLocation()); 39 return image; 40 } 41 } 42 }
资源死锁:
相对于多个线程相互持有彼此正在等待的锁而又不释放自己持有的锁时会发生死锁,当这种等待发生在相同的资源集合上时,也会发生死锁,称之为资源死锁
10.2 死锁的避免与诊断
支持定时的锁:就是显式使用Lock
类中的定时tryLock
功能来代替内置锁机制从而可以检测死锁和从死锁中回复过来
10.3 死锁其他活跃性危险
饥饿:当线程由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿”
而引发线程饥饿最常见的资源就是CPU时钟周期,如果一个线程的优先级不当或者在持有锁时发生无限循环、无限等待某个资源,这就会导致此线程长期占用CPU时钟周期,其他需要这个锁的线程无法得到这个锁,因此就发生了饥饿
避免使用优先级,因为这会增加平台依赖性从而导致活跃性问题,多数情况下,使用默认的线程优先级就可以了
活锁:这种问题发生时,尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复相同的操作,而且总是失败
要解决活锁问题,需要在重试机制中引入随机性(如以太协议在重复发生冲突时采用指数方式回退机制:冲突发生时等待随机的时间然后重试,如果等待的时间相同的话还是会冲突)
在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。
第13章 显示锁
13.1 Lock与ReentrantLock
Lock接口中定义了一种无条件、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显式的。
ReentrantLock实现了Lock接口,提供了与synchronized同样的互斥性和可见性,也同样提供了可重入性。
unlock必须在finally中释放锁,否则可能出现死锁。
public interfece Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long timeout, TimeUnit unit throw InterruptedException; void unlock(); Condition newCondition(); }
轮询锁与定时锁:
可由tryLock来实现
可以避免死锁的发生
轮询锁通过释放已获得的锁,并退回重新尝试获取所有锁(lock.tryLock())
定时锁通过释放已获得的锁,放弃本次操作(lock.tryLock(timeout, unit))来避免死锁
可中断的锁获取操作:
Lock.lockInterruptibly():用该方法获取锁,可以响应中断
如果线程未被中断,也不能获取到锁,就会一直阻塞下去,直到获取到锁或发生中断请求
定时的lock.tryLock(timeout, unit)同样能响应中断
非块结构加锁:
内置锁是基于块结构的加锁
Lock可以使块与块交叉实现非块结构的加锁(连锁式加锁或者锁耦合),例:链表中,next节点加锁后,释放pre节点的锁
13.3 公平性
公平锁——Lock fairLock = new ReentrantLock(true);
公平锁:线程将按照它们发出请求的顺序来获得锁
非公平锁:当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁
公平性将由于在挂起线程和恢复线程时存在的开销而极大地降低性能(非公平性的锁允许线程在其他线程的恢复阶段进入加锁代码块)
当持有锁的时间相对较长,或者请求锁的平局时间间隔较长,那么应该使用公平锁
内置锁为非公平锁
13.4 选择
在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。
当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。
否则,还是应该优先使用synchronized
synchronized,在线程转储中能给出在哪些调用帧中获得了哪些锁
13.5 读写锁
对于在多处理器系统上被频繁读取的数据结构,读 - 写锁能够提高性能。而在其他情况下,读 - 写锁的性能比独占锁的性能要略差一些,这是因为它们的复杂性更高