底层的并发功能与上层应用程序的并发语义之间并不存在一种简单的而只管的映射关系。为了解决在Java 底层机制与设计级策略之间的不匹配问题,我们给出了一组简化的并发程序编写规则。
设计规则和思维模式
并发编程基础理论
并发简史
线程的优势
线程带来的风险
安全性问题
活跃性问题
死锁、饥饿、活锁
性能问题
线程无处不在
待开发的程序有或者没有,开发时使用的框架会有使用多线程,基本上所有的java应用程序都会有。
线程安全性
编写线程安全的代码,核心在于要对状态访问操作进行管理,特别是对共现的和可变的状态的访问。
什么是线程安全性
当多个线程访问某个类时,这个类始终都能表现出正确的行为。
无状态对象一定是线程安全的。
原子性
竞态条件
由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,也就是竞态条件。
竞态条件和数据竞争的区别
延迟初始化中的竞态条件
复合操作
java.util.concurrent.atomic包中含有原子变量类,用于实现在数值和对象引用上的原子状态转换。
加锁机制
内置锁
重入
内置锁是可重入的
用锁来保护状态
活跃性与性能
对象的共享
可见性
访问某个共享且可变得变量时要求所有相册给在同一个锁上同步。
失效数据
非原子的64位操作
加锁与可见性
Volatile变量
volatile变量是一种比sychronized关键字更轻量级的同步机制
当且仅当满足以下所有条件时,才应该使用volatile变量
发布与逸出
发布一个对象是指,使对象能够在当前作用域之外的代码中使用。
当某个不应该发布的对象被发布时,这种情况就被称为逸出
安全的对象构造过程
在构造函数中注册一个事件监听器或启动线程,使用一个室友的构造函数和一个公共的工厂方法来防止this引用在构造过程中逸出
线程封闭
如果仅在单线程类范围数据,就不需要同步,这个技术被成为相册线程封闭。封闭技术应用于Swingy以及JDBC的Connection对象。
Ad-hoc线程封闭
维护线程封闭性的职责完全由程序实现来承担
使用单线程子系统的另一个原因是为了避免死锁
栈封闭
ThreadLocal类
当某个频执行的操作西药一个临时对象,例如一个缓冲区,而同时又洗碗避免在每次执行时重新分配该临时对象,就可以使用这项技术。
在实现用用程序框架是大量使用了ThreadLocal.在EJB调用期间
不变性
不可变对象一定是线程安全的
Final域
使用Volatile类型来发布不可变对象
安全发布
不正确的发布:正确的对象被破坏
不可变对象于初始化安全性
安全发布的常用模式
要安全地阿发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
在静态初始化函数中初始化一个对象引用;
将对象地引用保存到volatile类型地域或者AtomicReferance对象中。
将对象的引用保存到某个正确构造对象的final类型域中。
将对象的引用保存到一个由锁保护的域中。
线程安全库中的容器类提供了以下的安全发布保证:
类库中的其他数据传递机制同样能实现安全发布。
事实不可变对象
可变对象
对象的发布需求取决于它的可变性
不可变对象可以通过任意机制来发布;
事实不可变对象必须通过安全方式来发布;
可边对象必须通过安全方式来发布,并且必须时是线程安全的或者由某个锁保护起来。
安全地共享对象
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
- 线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
- 只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
- 线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
- 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封闭在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
对象的组合
设计线程安全的类
通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。
同步策略定义了如何在不违背对象的不变性条件或后验条件的情况下对其状态的访问操作进行协同。同步策略规定了如何将不可变性、线程安全性与加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。
在设计线程安全的类的过程中,需要包含以下三个基本要素:
- 找出构成对象状态的所有变量
- 找出约束状态变量的不变性条件
- 建立对象状态的并发访问管理策略
收集同步需求
要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏,这就需要对其状态进行推断。
如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。要满足在状态变量的有效值或状态转换上的各种约束条件,就需要借助于原子性于与封装性。
依赖状态的操作
状态的所有权
垃圾回收机制使我们避免了如何处理所有权的问题。
为了防止多个线程在并发访问同一个对象时产生的相互干扰,这些对象应该要么时线程安全的对象,要么是事实不可变的对象,或者由锁来保护的对象。
实例封闭
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。
Java平台的类库中由很多线程封闭的示例,其中有些类的唯一用途就是将非线程安全的类转化为线程安全的类。
Java监视器模式
私有锁和对象的内置锁
示例:车辆追踪
线程安全性委托
示例:基于委托的车辆追踪
独立的状态变量
当委托失效时
发布底层的状态变量
示例:发布状态的车辆追踪器
在现有的线程安全类中添加功能
Java类库包含许多有用的“基础模块”类。通常,我们应该优先选择重用这些现有的类而不是创建新的类:重用能降低开发工作量、开发风险(因为现有的类都已经通过测试)以及维护成本。
客户端加锁机制
客户端加锁是指,对于使用某个对象X的客户端代码,使用X本身用于保护其状态的锁来保护客户代码。要使用客户端加锁,你必须知道对象X使用的是哪一个锁。
组合
将同步策略文档化
基础构建模块
同步容器类
同步容器类有Vector和Hashtable,由Collections.synchronizedXxx等工厂方法创建。这些类实现线程安全的方式是:将它们的状态封装起来,并对每个共有方法都进行同步,使得每次只有一个线程能访问容器的状态。
同步容器类的问题
通过客户端加锁来解决不可靠迭代问题,但要牺牲一些伸缩性。
迭代器与ConcurrentModificationException
隐藏迭代器
容器的hashCode和equals等方法也会间接地执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。同样,containsAll、removeAll和retainAll等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可能抛出ConcurrentModificationException。
并发容器
Java5.0提供了许多并发容器类来改进同步容器的性能。同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种哈哈的代价是杨中国降低并发性,当多个线程竞争容器的锁时,吞吐量将严重降低。
ConcurrentHashMap
额外的原子Map操作
CopyOnWriteArrayList
阻塞队列和生产者—消费者模式
一种常见的生产者-消费者设计模式就是线程池与工作队列的组合,在Executor任务执行框架中就体现呢这种模式。
在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮。
示例:桌面搜索
串行线程封闭
双端队列与工作密取
阻塞方法与中断方法
同步工具类
闭锁-Latch
闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会在改变状态,因此这扇门将永远保持打开的状态。
Future Task
信号量-Semaphore
semaphore可以用于实现资源池,例如数据库的连接池。
栅栏-Barrier
栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。栅栏用于实现一些协议。
构建高效且可伸缩的结果缓存
简单的缓存可能会将性能瓶颈转变成可伸缩性瓶颈,即使缓存是用于提升单线程的性能。
小结
并发技巧
可变状态是至关重要的。所有的并发问题都可以归结为如何协调对并发状态的访问。可边状态越少,就越容易确保线程安全性。
尽量将域声明为final类型,除非需要它们是可变的。
不可变对象一定是线程安全的。不可变对象能极大地降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无须使用加锁或保护性复制等机制。
封装有助于管理复杂性。在编写线程安全程序时,虽然可以将所有数据都保存至全局变量中,但是为什么要这样做。将数据封装在对象中,更易于维持不变性条件:将同步机制封装在对象中,更易于遵循同步策略。
用锁来保护每个可变变量
当保护同一个不变性条件的所有变量时,要使用一个锁。
在执行复合操作期间,要持有锁
如果虫多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题。
不要故作聪明地推断出不需要使用同步。
在设计过程中考虑线程安全,或者在文档中明 确地指出它不是线程安全的。
将同步策略文档化。
并发应用程序的构造理论
大多数并发应用程序都市围绕“任务执行”来构造的:任务通常是一些抽象的且离散的工作单元。通过把应用程序的工作分解到多个任务中,可以简化程序的组织结构,提供一种自然的十五边界来优化错误恢复过程,以及提供一种自然的并行工作结构来提升并发性。
任务执行
在线程中执行任务
串行地执行任务
显式地为任务创建线程
无限制创建线程的不足
Executor框架
示例:基于Excutor的Web服务器
执行策略
线程池
Executor的生命周期
延迟任务与周期任务
找出可利用的并行性
示例:串行的页面渲染器
携带结果的任务Callable与Future
示例:使用Futuren实现页面渲染器
在异构任务并行化中存在的局限
CompletionService:Executor与BlockingQueue
示例:使用CompletionService实现页面渲染器
为任务设置时限
示例:旅行预订门户网站
小结
通过围绕任务执行设计应用程序,可以简化开发过程,并有助于实现并发。Executor框架将任务提交与执行策略解耦开来,同时还支持多种不同类型的执行策略。当需要创建线程来执行任务时,可以考虑使用Executor。要想在将应用程序分解为不同的任务时获得最大的好处,必须定义清晰的任务边界。某些应用程序中存在着比较明显的任务边界,而在其他一些程序中则需要进一步分析才能揭示出粒度更细的并行性。
取消与关闭
任务取消
中断
中断策略
响应中断
示例:计时运行
通过Future来实现取消
处理不可中断的阻塞
采用newTaskFor来封装非标准的取消
停止基于线程的服务
示例:日志服务
关闭ExecutorService
“毒丸”对象
示例:只执行一次的服务
shutdownNow的局限性
处理非正常的线程终止
未捕获异常的处理
JVM关闭
关闭钩子
守护线程
终结器
小结
在任务、线程、服务以及应用程序等模块中的生命周期结束问题,可能会增加它们在设计和实现时的复杂性。Java并没有提供某种抢占式的机制来取消或者总结线程。相反,它提供了一种协作式的中断机制来实现取消操作,但这要依赖于如何构建取消操作的协议,以及能否始终遵循这些协议。通过使用FutureTask和Executor框架,可以帮助我们构建可取消任务和服务。
线程池的使用
在任务与执行策略之间的隐性耦合
线程饥饿死锁
运行时间较长的任务
设置线程池的大小
配置TheadPoolExecutor
线程的创建与销毁
管理队列任务
饱和策略
线程工厂
在调用构造函数后再定制ThreadPoolExecutork
扩展ThreadPoolExecutor
给线程池添加统计信息
递归算法的并行化
谜题框架
小结
对于并发执行的任务,Executor框架是一种强大且灵活的框架。它提供了大量可调节的选项,例如创建线程和关闭线程的策略,处理队列任务的策略,处理过多任务的策略,并且提供了几个钩子方法来扩展它的行为。然而,与大多数功能强大的框架一样,其中有些设置参数并不能很好地工作,某些类型地任务需要特定的执行策略,而一些参数组合则可能产生奇怪的结果。
并发编程的性能调优
避免活跃性危险
安全性与活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但是如果过度地使用加锁,则可能导致锁顺序死锁。同样,我们使用线程池和信号量来限制对资源的使用,但这些被限制地行为可能会导致资源死锁。Java应用程序无法从死锁诉说中恢复过来,因此在设计时一定要排那些些导致死锁出现的条件。
死锁
锁顺序死锁
动态的锁顺序死锁
在协作对象之间发生的死锁
开放调用
资源死锁
死锁的避免与诊断
支持定时的锁
通过线程转储信息来分析死锁
其他活跃性危险
饥饿
糟糕的响应
活锁
小结
活跃性故障是一个非常严重的问题,因为当出现活跃性故障时,除了中止应用程序之外没有其他任何机制可以帮助从这种故障时恢复过来。最常见的活跃性故障就是锁顺序死锁。在设计时应该避免产生锁顺序死锁:确保线程在获取多个锁时采用一致的顺序。最好的解决方法是在程序中始终使用开放调用。这将大大减少需要同时持有多个锁的地方,也更容易发现这些地方。
性能与可伸缩性
线程的最主要目的是提高程序的运行性能。
对性能的思考
更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。
性能与可伸缩性
可伸缩性:当增加计算资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力相应地增加。
评估各种性能权衡因素
Amdahl定律
在所有并发程序中都包含一些串行部分
示例:在各种框架中隐藏的串行部分
Amdahl定律的应用
线程引入的开销
上下文切换
内存同步
阻塞
减少锁的竞争
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。减少锁的竞争能够提高性能和可伸缩性。
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。
有三种方式可以降低锁的竞争程度:减少锁的持有时间;降低锁的请求频率;使用带有协调机制的独占锁,这些机制允许更高的并发性。
缩小锁的范围
减少锁的粒度
降低线程请求锁地频率(从而减少发生竞争的可能性)。这可以通过锁分段和锁分解等技术来实现。
对竞争适中的锁进行分解时,实际上是把这些锁转变未非竞争锁,从而有效地提高性能和可伸缩性。
锁分段
避免热点域
一些替代独占锁的方法
并发容器、读写锁、不可变对象以及原子变量
检测CPU利用率
向对象池说“不”
示例:比较Map的性能
减少上下文切换的开销
小结
由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多地将侧重点放在吞吐量和可伸缩性上,而不是服务时间。Amdahl定律告诉我们,程序的可伸缩性取决于在所有代码中必须被串行执行的代码的比例。因为Java程序中串行操作的主要来源是独占方式的资源锁,因此通常可以通过一i啊方式来提升可伸缩性:减少锁的持有时间,降低锁的粒度,以及采用非独占的锁或非阻塞锁来代替独占锁。
小结
由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多地将侧重店放在吞吐量和可伸缩性上,而不是服务时间。Amdahl定律告诉我们,程序地可伸缩性取决于在所有代码中必须被串行执行地代码比例。因为Java程序中串行操作地主要来源是独占方式地资源锁,因此通常可以通过以下方式来提升可伸缩性:减少锁的持有时间,降低锁的粒度,以及采用非独占的锁或非阻塞锁来代替独占锁。
并发程序的测试
并发编程的高级主题
Lock和ReentrantLock
轮询锁与定时锁
可中断的锁获取操作
非块结构的加锁
性能考虑因素
公平性
在synchronized和ReentrantLock之间进行选择
在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentranLock,这些功包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用synchronized。
读-写锁
小结
与内置锁相比,显示的Lock提供了一些扩展功能,在处理锁的不可用性方面有着更高的灵活性,并且对队列有着更好的额控制。但ReentranLock不能完全替代synchronized,只有在synchronized无法忙着需求时,才应该使用它。
读-写锁允许多个读线程并发地访问被保护地对象,当范围以读取操作为主地数据结构时,它能提高程序地可伸缩性。
构建自定义的同步工具
原子变量与非阻塞同步机制
非阻塞算法在可伸缩性和活跃性上拥有巨大的优势。由于非主赛算法可以使读个线程咋竞争相同的数据时不会发生阻塞,因此他们在粒度更细的层次上进行协调,并且极大地减少调度开销。而且,在非阻塞算法中不存在死锁和其他活跃性问题
锁的劣势
硬件对并发的支持
比较并交换
非阻塞的计数器
JVM对CAS的支持
原子变量类
原子变量是一种“更好的volatile”
性能比较:锁与原子变量
非阻塞算法
如果在某种算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,那么这种算法就被称为非阻塞算法。
非阻塞的栈
非阻塞的链表
原子的域更新器
ABA问题
小结
非阻塞算法通过底层的并发原语(例如比较交换而不是锁)来维持线程的安全性。这些底层的原语通过原子变量类向外公开,这些类也用作一种“更好的volatile”,从而为整数和对象引用提供原子的更新操作。
非阻塞算法在设计和实现时非常困难,但通常能够提供更高的可伸缩性,并能更好地防止活跃性故障的发生。在JVM从一个版本升级到下一个版本的过程中,并发性能的主要提升都来自于(在JVM内部以及平台类库中)对非阻塞算法的使用。
Java内存模型
什么是内存模型,为什么需要它
平台的内存模型
重排序
Java内存模型简介
借助同步
发布
不安全的发布
安全的发布
安全初始化模式
双重检测加锁
初始化过程中的安全性
小结
Java内存模型说明了某个线程的内存操作在哪些情况下对于其他线程是可见的。其中包括确保这些操作是按照一种Happens-before的偏序关系进行排序,而这种关系是基于内存操作和同步操作等级别来定义的。如果缺少重组的同步,那么当相册给访问共享数据时,会发生一些非常器官的额问题。然而,如果使用第2章与第3章介绍的更高级规则,例如@GuardedBy和安全发布,那么即使不考虑Hapens-Before的底层细节,也能确保线程安全性。
线程安全类
Vector
其他同步集合类
参考
《Java 并发编程实践》机械工业出版社 2012.2