本书分成四部分,
第一部分是基础,主要内容是并发的基础概念和线程安全,以及如何用java类库提供的并发构建块组成线程安全的类(2-5章节)
第二部分是构建并发应用程序,主要内容是如何利用线程来提高并发应用的吞吐量和响应能力(6-9章节)
第三部分是活跃性、性能和测试,主要内容是如何确保并发程序能够按照你的要求执行,并且具有可接受的性能(10-12章节)
第四部分是高级主题,涵盖了可能只有有经验的开发人员才会感兴趣的主题:显式锁、原子变量、非阻塞算法和开发自定义同步器(13-16章节)
Part 1 基础
Chapter 1 简介
1.1 并发简史
早期计算机没有操作系统,程序独占所有资源,一次只能有一个程序运行,效率低下
操作系统出现后,程序(进程)可以并发运行,由操作系统分配资源,进程互相隔离,必要时依靠粗粒度的通信机制:sockets、signal handlers、shared memory等机制通信
进程提高了系统吞吐量和资源利用率,线程的出现也是这个原因,线程有时被称为轻量级进程,大多数现代操作系统将线程而不是进程视为调度的基本单位,同一程序的多个线程可以同时在多个CPU上调度,如果没有同步机制协调对共享数据的访问,一个线程可能会修改另一个线程正在使用的变量,导致不可预测的结果
1.2 线程优势
- 减小开发和维护开销
- 提高复杂应用的性能
- 提高GUI的响应能力
- 简化JVM实现
1.3 线程风险
竞争(多个线程以未知顺序访问资源)
活跃性(死锁,饥饿,活锁)
性能(频繁切换导致开销过大)
1.4 无处不在的线程
- 框架通过在框架线程中调用应用程序代码将并发性引入到应用程序中,在代码中将不可避免的访问应用程序状态,因此所有访问这些状态的代码路径都必须是线程安全的
- Timer类,TimerTask在Timer管理的线程中执行
- Servlet(每个请求使用一个线程同时执行Servlet)
- RMI(由RMI负责打包拆包远程对象)
- Swing(具有异步性)
Chapter 2 线程安全
多个线程访问同一个可变的状态变量时没有使用合适的同步机制,可以用以下方法修复:
- 不在线程间共享该变量
- 将变量变为常量
- 访问时候使用同步
2.1 什么是线程安全?
如果一个类被多线程访问,不管线程调度或交叉执行顺序如何,类的表现都是正确的,那么类是线程安全的
线程安全类封装任何需要的同步,因此客户端不需要提供自己的同步。
无状态的对象永远是线程安全的
2.2 原子性
竞争情况(race condition):由于不恰当的执行顺序导致出现不正确的结果,常发生在以下情况中:
- 读取-修改-写入,例子: 自增操作
- 先检查后执行,例子:延迟初始化,不安全的单例模式,懒汉模式
第一种情况解决方式:使用juc里面的类,比如count可以用AtomicLong类型操作保证原子性
第二种情况解决方式:加锁保证原子性
2.3 加锁
如果类有多个变量需要更新,即使它们的各自操作都是原子性的,也要把他们放在同一个原子操作中,方式是加锁。Java 提供了锁机制来增强原子性:synchronized
内置锁: synchronized 实例方法会将被调用方法的对象作为内置锁或监视锁,内置锁是互斥的,同一时刻最多只有一个线程拿到这个锁
可重入: 内置锁是可重入的,已经拿到锁的线程可以再次获取锁,实现方式是锁会(就是lock对象)关联一个持有者和计数值,持有者再次进入次数加一,退出减一,减到0会释放锁
2.4 用锁来保护状态
混合操作比如说读取-修改-写入和先检查后执行,需要保证原子性来避免竞争情况。
常见错误: 只有写入共享变量才需要同步
原因:读取也需要同步,不然可能会看到过期值
每个共享的可变变量应该由同一个锁保护,常见的加锁习惯是将可变变量封装到一个对象中
对于不变性条件(invariant)中涉及的多个变量,这多个变量都需要用同一个锁保护,例如Servlet缓存了请求次数和请求数据(数组),不变性条件是请求数据的长度等于次数,这通过加锁来保证
2.5 活跃性和性能
给Servlet的方法声明syncronized极大降低了并发性,我们可以通过缩小同步块的范围,在保证线程安全的情况下提高并发性。合理的做法是将不影响共享状态的操作从同步块中排除
Chapter 3 共享对象
synchronized 块和方法可以确保操作的原子性执行,它还有另一个重要作用:内存可见性。我们不仅想要防止一个线程在另一个线程使用一个对象时修改它的状态,还想要确保当一个线程修改一个对象的状态时,其他线程可以看到最新更改
3.1 可见性
-
过期数据(当一个线程修改数据,但其他线程不能立马看到)。 读取操作如果不同步,仍然能看到一个过期数据,这叫做最低安全性(过期数据至少是由之前的线程设置的,而不是随机值)
-
大多数变量都满足最低安全性,除了非volatile修饰的64位变量(double和long),jvm允许将64位操作拆解为2个 32位操作,读取这样的变量可能会出现过期值的高32位+新值的低32位的结果
-
内置锁保证可见性
-
volatile: 保证可见性,禁止指令重排,不保证原子性(使用场合:保证自身可见性,引用对象状态可见性,标识重要的生命周期事件)
当且仅当满足以下所有条件时,才应该使用volatile变量:
- 对变量的写入不依赖于它的当前值,或者可以确保只有一个线程更新该值;
- 该变量不会与其他状态变量一起纳入不变性条件
- 在访问变量时,由于任何其他原因不需要加锁。
3.2 发布与逃逸
发布是指让对象在外部可见,常见方式是对象引用声明为 public static。发布对象的同时,任何通过非私有字段引用和方法调用链从发布对象中访问的对象也被发布了
逃逸是指对象的私有信息也对外可见了,比如发布一个对象包含一个私有数组,同时提供一个返回引用的get方法,外部可以通过引用修改内部私有数组
3.3 线程封闭
如果对象限制在一个线程中使用,即使对象不是线程安全的,也会自动线程安全
例子:Swing: 将组件和数据对象放到事件分发线程,其它线程访问不了这些对象;JDBC.Connection对象: 应用线程从数据库连接池中获取一个连接对象,连接对象由该线程独自使用
Java 提供 ThreadLocal 来实现线程封闭,程序员做的是阻止对象从线程中逃逸
线程封闭通常用来实现一个子系统,例如GUI,它是单线程的
-
Ad-hoc封闭: 核线程封闭性的职责完全由程序实现来承担(脆弱,少用)
-
栈封闭: 只能通过局部变量访问对象(Java基本类型或者局部变量)
-
ThreadLocal类: 提供getter和setter,每个使用该变量的线程存有一份独立的副本
3.4 不可变
不可变对象永远是线程安全的
满足以下条件,对象才是不可变的:
- 构造函数之后状态不可修改
- 所有域都是final
- 对象正确创建(this引用没有在构造期间逃逸)
多个状态的对象需要保证线程安全,可以将状态封装到一个不可变类中,用volatile修饰不可变对象引用
3.5 安全发布
-
不正确的发布对象会出现两个问题:其它线程会看到null或旧值;最糟糕的是其它线程看到最新的引用但是被引用的对象还是旧的
-
由于不可变对象很重要,Java内存模型为不可变对象的共享提供一种特殊的初始化安全性保证,不用同步也能安全发布
-
一个正确构造的对象可以通过以下方式安全发布:
- 静态初始化函数中初始化一个对象引用
- 引用保存到volatile域或者AtomicReference对象中
- 引用保存到某个正确构造对象的final域
- 引用保存到锁保护的域(容器也可)
-
不可变对象,可以放宽到事实不可变对象(对象在发布之后不会改变状态)
-
可变对象必须通过安全方式发布,并且必须是线程安全的或者锁保护起来
-
并发程序共享对象实用策略
- 线程封闭
- 只读共享
- 线程安全共享:对象内部实现同步
- 保护对象:锁机制
Chapter 4 组合对象
本章讨论如何将线程安全的组件组合成更大的组件或程序
4.1 设计一个线程安全的类
-
在设计线程安全类的过程中,常会包含以下三个基本要素:
- 找出构成对象状态的所有变量。
- 找出约束状态变量的不变性条件和后验条件。
- 建立对象状态的并发访问管理策略。
如果不了解对象的不变性条件和后验条件,就无法确保线程安全
-
依赖状态的方法需要先满足某种状态才能运行,即先验条件。java提供了 wait and notify 机制来等待先验条件成立,它依赖内置锁。更简单的实现方法是用java类库的阻塞队列或者信号量
-
一般情况下,状态所属权是封装状态的类,除非类公开可变对象的引用,这时候类只有共享权
4.2 实例封闭
在对象中封装数据,通过使用对象方法访问数据,从而更容易确保始终在持有适当锁的情况下访问数据。
- Java监视器模式:封装可变状态到对象中,使用对象的内置锁保护状态,使用私有锁对象更有优势
4.3 线程安全的委托
- 将线程安全的职责委托给线程安全的类,例如计数器类不做同步处理,依赖AtomicLong类型达到线程安全
- 可以将线程安全委托给多个基础状态变量,只要它们是独立的
- 委托失效:多个变量间有不变性条件,比如大小关系等,需要加锁,除非复合操作也可以委托给变量
- 如果一个状态变量是线程安全的,不参与任何限制其值的不变性条件,并且在任何操作中都没有禁止的状态转换,那么它就可以安全地发布。
4.4 给现有的线程安全类加功能
继承方式(可能会因为子父类加的锁不一样线程不安全)
- 客户端加锁,使用辅助类,若类的加锁依赖其它类,那么辅助类容易错误加锁
- 组合方式,加锁策略完全由组合类提供
4.5 文档化同步策略
为类的客户端记录线程安全保证;为其维护者记录其同步策略
Chapter 5 基础构建模块
在实际应用中,委托是创建线程安全类最有效的策略之一,本章介绍平台库的并发构建模块,例如线程安全的集合和各种可以协调线程控制流的同步器
5.1 同步集合
Vector、Hashtable,以及JDK1.2增加了 Collections.synchronizedXxx 创建同步包装类
- 复合线程安全的类的方法可能不是线程安全的,例如复合方法调用size和get方法,中间可能被删掉元素导致size结果不对
- 迭代器或者for-each不会锁定集合,在迭代过程中检测到集合变化时会抛出ConcurrentModificationException异常,检测是通过检测count值,但是没有同步,可能看到过期值
- 隐藏的迭代器(某些操作底层隐藏着调用迭代器,比如集合的toString)
5.2 并发集合
同步集合通过序列化对集合状态的所有访问来实现线程安全,性能低。Java 5增加了并发集合
- ConcurrentHashMap,使用分段锁,具有弱一致性,同时size和isEmpty是估计并不精确,只有需要独占Map,才不建议使用该Map
- CopyOnWriteArrayList,每次修改都是返回副本,建议迭代多修改少的时候使用
5.3 阻塞队列和生产者-消费者模式
BlockingQueue,常用来实现生产者和消费者,有一个特殊的实现SynchronousQueue,它不是一个实际的队列,当生产者生产数据时直接交给消费者,适用于消费者多的场景
Deque,常用来实现工作窃取模式。生产者和消费者模式中,消费者共享一个队列,工作窃取模式中,消费者有独自的队列,当消费完后会偷其他人的工作。工作窃取模式可以减少对于共享队列的竞争
5.4 阻塞方法与中断方法
- 当某个方法抛出InterruptedException,说明该方法是阻塞方法,可以被中断
- 代码中调用一个阻塞方法(阻塞方法和线程状态没有必然关系,方法可能是个长时间方法所以声明抛出InterruptedException,也有可能是会导致线程状态改变的sleep方法),必须处理中断响应.
- 捕获/抛出异常
- 恢复中断.调用当前线程的interrupt
5.5 同步器
- 阻塞队列
- 闭锁(Latch): 延迟线程进度,直到条件满足,FutureTask也可以做闭锁
- 信号量:类似发布凭证,但是任意线程都可以发布和返还
- 栅栏: 阻塞一组线程,直到某个条件满足;如果有某个线程await期间中断或者超时,所有阻塞的调用都会终止并抛出BrokenBarrierException
5.6 构建高效且可伸缩的缓存
- 使用hashMap+synchronized,性能差
- ConcurrentHashMap代替hashMap+synchronized,有重复计算问题
- ConcurrentHashMap的值用FutureTask包起来,只要键已经存在,从FutureTask获取结果,因为check-then-act模式,仍然存在重复计算问题
- 使用putIfAbsent设置缓存
Part 2 构建并发应用程序
Chapter 6 任务执行
6.1 在线程中执行任务
-
串行执行任务(响应会慢,服务器资源利用率低)
-
显式为每个请求申请一个线程
- 任务处理线程从主线程分离,提高响应速度
- 任务可以并行处理,提高吞吐量
- 任务处理代码必须是线程安全的,多个线程会并发执行
-
无限制创建线程的不足
- 创建销毁浪费时间
- 浪费资源
- 稳定性差
6.2 Executor框架
-
Executor基于生产-消费模式,提交任务相当于生产者,执行任务的线程相当于消费者.
-
执行策略
- What: 在什么线程中执行任务,按什么顺序执行,任务执行前后要执行什么操作
- How Many: 多少任务并发,多少等待
- Which: 系统过载时选择拒绝什么任务
- How: 怎么通知任务成功/失败
-
线程池,管理一组同构工作线程的资源池,跟工作队列密切相关
-
Executor生命周期
- 运行 : 对象新建时就是运行状态
- 关闭 : 不接受新任务,同时等待已有任务完成,包括未执行的任务,关闭后任务再提交由 “被拒绝的执行处理器” 处理或者直接抛异常
- 终止 : 关闭后任务完成
-
延迟任务和周期任务
Timer类可以负责,但是存在缺陷,应该考虑ScheduledThreadPoolExecutor代替它
Timer: 只用一个线程执行定时任务,假如某个任务耗时过长,会影响其他任务的定时准确性。除此之外,不支持抛出异常,发生异常将终止线程(已调度(scheduled)未执行的任务,线程不会执行,新任务不会调度,称为线程泄露)
DelayQueue: 阻塞队列的一种实现,为ScheduledThreadPoolExecutor提供调度策略
6.3 寻找可利用的并行性
-
将耗时的IO使用别的线程获取;而不是简单的串行执行
-
Future 表示一个任务的生命周期,并提供相应的方法判断完成/取消,get会阻塞或抛异常
-
使用Callable和Future并行化下载和渲染
-
异构任务并行化获取重大性能提升很困难.
- 任务大小不同
- 负载均衡问题
- 协调开销
-
CompletionService 将 Executor 和BlockingQueue结合在一起,Executor是生产者,CompletionService是消费者
-
使用 CompletionService 并行化下载和渲染
-
为任务设置时限
-
需要获取多个设置了时限的任务的结果可以用带上时间的 invokeAll 提交多个任务
Chapter 7 取消和关闭
本章讲解如何停止任务和线程,Java没有安全强制线程停止的方法,只有一种协作机制,中断
7.1 任务取消
有一种协作机制是在任务中设置取消位,任务定期查看该标识,假如置位就结束任务(假如线程阻塞了,就看不到取消位,那么就停不下来了)
- 中断: 在取消任务或线程之外的其他操作中使用中断是不合适的
- 每个线程都有一个中断标志,interrupt中断目标线程,isInterrupted返回目标线程的中断状态,interrupted(糟糕的命名)清除当前线程中断;
- Thread.sleep和Object.wait都会检查线程什么时候中断,发现时提前返回(不会立即响应,只是传递请求而已)
- 中断策略:尽快推迟执行流程,传递给上层代码;由于每个线程拥有各自的中断策略,除非知道中断对这个线程的含义,否则不应该中断该线程
- 中断响应
当调用会抛出InterruptedException的阻塞方法时,有两种处理策略- 传播异常,让你的方法也变成会抛出异常的阻塞方法(中断标志一直为true)
- 恢复中断状态,以便调用堆栈上较高级的代码处理它(try-catch之后中断标志为false,可以调用当前线程的interrupt方法恢复成中断状态)。
- 在中断线程之前,要了解线程的中断策略
- 通过Future取消任务
- 处理不可中断的阻塞
- java.io中的同步Socket I/O.通过关闭Socket可以使阻塞线程抛出异常
- java.io中的同步 I/O.终端一个InterruptibleChannel会抛出异常并关闭链路
- 获取某个锁. Lock提供lockInterruptibly
- 通过 newTaskFor 方法进一步优化
7.2 停止基于线程的服务
基于线程的服务:拥有线程的服务,例如线程池
只要拥有线程的服务的生命周期比创建它的方法的生命周期长,就提供生命周期方法。例如线程池 ExecutorService 提供了shutdown
- 日志服务:多生产者写入消息到阻塞队列,单消费者从阻塞队列中取消息,停止日志服务需要正确关闭线程。需要对结束标志位和队列剩余消息数同步访问(书有错误,LoggerThread 应该 synchronized (LogService.this))
- 毒丸,生产者将毒丸放在队列上,消费者拿到毒丸就结束
- shutdownNow 取消正在执行的任务,返回已提交未开始的任务,可以用个集合保存执行中被取消的任务
7.3 处理非正常的线程终止
通常是因为抛出运行时异常导致线程终止
处理方法:
- try-catch 捕获任务异常,如果不能恢复,在finally块中通知线程拥有者
- 当线程因未捕获异常而退出时,JVM会将事件报告给线程拥有者提供的UncaughtExceptionHandler,如果没有处理程序就将堆栈打印到System.err
- 通过execute提交的任务的异常由UncaughtExceptionHandler处理,submit提交的任务,通过调用Future.get方法,包装在ExecutionException里面
7.4 JVM关闭
有序关闭:最后一个非守护线程终止(可能是调用了System.exit,或者发送SIGINT或按Ctrl-C)后终止
突然关闭:通过操作系统终止JVM进程,例如发送SIGKIll
- 有序关闭中,JVM首先启动所有已注册的关闭钩子(通过Runtime.addShutdownHook注册的未启动线程)。如果应用程序线程在关闭时仍在运行,将与关闭线程并行执行。当所有关闭钩子都完成时,如果runFinalizersOnExit为true,那么jvm可能运行终结器,然后停止
- 守护线程:执行辅助功能的线程,不会阻止JVM关闭。当JVM关闭时,守护线程直接关闭,不执行 finally 块,栈不会展开。守护线程适合做“内务”任务,例如清缓存
- 终结器:GC在回收对象后会执行 finalize 方法释放持久资源。终结器在JVM管理的线程中运行,需要同步访问。终结器难写且性能低,除非要关闭 native 方法获取的资源,否则在 finally中显示关闭就够了
Chapter 8 使用线程池
本章将介绍配置和调优线程池的高级选项,描述使用任务执行框架时需要注意的危险
8.1 任务和执行策略之间的隐式耦合
Executor 框架在任务提交和执行之间仍存在一些耦合:
- 依赖其它任务的任务,相互依赖可能导致活跃性问题
- 利用线程封闭的任务,这类任务不做同步,依赖单线程执行
- 响应时间敏感的任务,可能需要多线程执行
- 使用 ThreadLocal 的任务,ThreadLocal不应该用于线程池中任务之间的通信
8.1.1 线程饥饿死锁
把相互依赖的任务提交到一个单线程的Executor一定会发生死锁。增大线程池,如果被依赖的任务在等待队列中,也会发生死锁
8.1.2 运行耗时长的任务
即使不出现死锁,也会降低性能,通过限制执行时间可以缓解
8.2 设置线程池大小
cpu数可以调用 Runtime.availableProcessors得出
- 计算密集型场景,线程池大小等于cpu数+1
- IO密集型场景,线程池大小等于cpu数 * cpu利用率 * (1+等待/计算时间比)
8.3 配置 ThreadPoolExecutor
8.3.1 线程创建和销毁
- corePoolSize:线程池大小,只有工作队列满了才会创建超出这个数量的线程
- maximumPoolSize:最大线程数量
- keepAliveTime:空闲时间超过keepAliveTime的线程会成为回收的候选线程,如果线程池的大小超过了核心的大小,线程就会被终止
8.3.2 管理工作队列
可以分成三类:无界队列、有界队列和同步移交。队列的选择和线程池大小、内存大小的有关
无界队列可能会耗尽资源,有界队列会带来队列满时新任务的处理问题,同步移交只适合用在无界线程池或饱和策略可以接受
8.3.3 饱和策略
当任务提交给已经满的有界队列或已经关闭的Executor,饱和策略开始工作
- Abort,默认策略,execute方法会抛RejectedExecutionException
- Discard:丢弃原本下个执行的任务,并重新提交新任务
- Caller-Runs:将任务给调用execute 的线程执行
- 无界队列可以使用信号量进行饱和策略
8.3.4 线程工厂
通过ThreadFactory.newThread创建线程,自定义线程工厂可以在创建线程时设置线程名、自定义异常
8.3.5 调用构造函数后再定制ThreadPoolExecutor
线程池的各项配置可以通过set方法配置,如果不想被修改,可以调用Executors.unconfigurableExecutorService
将其包装成不可修改的线程池
8.4 扩展 ThreadPoolExecutor
ThreadPoolExecutor给子类提供了钩子方法,beforeExecute、afterExecute和terminated
beforeExecute和afterExecute钩子在执行任务的线程中调用,可用于添加日志记录、计时、监控或统计信息收集。无论任务从run正常返回还是抛出异常,afterExecute钩子都会被调用。如果beforeExecute抛出一个RuntimeException,任务就不会执行,afterExecute也不会被调用
terminated钩子在任务都完成且所有工作线程都关闭后调用,用来释放资源、执行通知或日志记录
8.5 递归算法并行化
- 如果迭代操作之间是独立的,适合并行化
- 递归不依赖于后续递归的返回值
Chapter 9 GUI应用
9.1 为什么GUI是单线程的
由于竞争情况和死锁,多线程GUI框架最终都变成了单线程
9.1.1 串行事件处理
优点:代码简单
缺点:耗时长的任务会发生无响应(委派给其它线程执行)
9.1.2 Swing的线程封闭
所有Swing组件和数据模型对象都封闭在事件线程中,任何访问它们的代码必须在事件线程里
9.2 短时间的GUI任务
事件在事件线程中产生,并冒泡到应用程序提供的监听器
Swing将大多数可视化组件分为两个对象(模型对象和视图对象),模型对象保存数据,可以通过引发事件表示模型发生变化,视图对象通过订阅接收事件
9.3 长时间的GUI任务
对于长时间的任务可以使用线程池
- 取消 使用Future
- 进度标识
9.4 共享数据模型
- 只要阻塞操作不会过度影响响应性,那么事件线程和后台线程就可以共享该模型
- 分解数据模型.将共享的模型通过快照共享
9.5 其它形式单线程
为了避免同步或死锁使用单线程,例如访问native方法使用单线程
Part 3 活跃性、性能和测试
Chapter 10. 避免活跃性危险
Java程序不能从死锁中恢复,本章讨论活跃性失效的一些原因以及预防措施
10.1 死锁
哲学家进餐问题:每个人都有另一个人需要的资源,并且等待另一个人持有的资源,在获得自己需要的资源前不会释放自己持有的资源,产生死锁
10.1.1 Lock-ordering死锁
线程之间获取锁的顺序不同导致死锁。
解决方法:如果所有线程以一个固定的顺序获取锁就不会出现Lock-ordering死锁
10.1.2 动态Lock Order死锁
获取锁的顺序依赖参数可能导致死锁。
解决方法:对参数进行排序,统一线程获取锁的顺序
10.1.3 协作对象的死锁
如果在持有锁时调用外部方法,将会出现活跃性问题,这个外部方法可能阻塞,加锁等导致其他线程无法获得当前被持有的锁
解决方法:开放调用
10.1.4 开放调用
如果在方法中调用外部方法时不需要持有锁(比如调用者this),那么这种调用称为开放调用。实现方式是将调用者的方法的同步范围从方法缩小到块
10.1.5 资源死锁
和循环依赖锁导致死锁类似。例如线程持有数据库连接且等待另一个线程释放,另一个线程也是这样
10.2 避免和诊断死锁
使用两部分策略来审计代码以确保无死锁:首先,确定哪些地方可以获得多个锁(尽量使其成为一个小集合),然后对所有这些实例进行全局分析,以确保锁的顺序在整个程序中是一致的,尽可能使用开放调用简化分析
10.2.1 定时锁
另一种检测死锁并从死锁中恢复的技术是使用显示锁中的Lock.tryLock()代替内置锁
10.2.2 用Thread Dumps进行死锁分析
线程转储包含每个运行线程的堆栈信息,锁信息(持有哪些锁,从哪个栈帧中获得)以及阻塞的线程正在等待获得哪些锁
10.3 其它活跃性危险
10.3.1 饥饿
线程由于无法获得它所需要的资源而不能继续执行,最常见的资源是CPU
避免使用线程优先级,可能导致饥饿
10.3.2 糟糕的响应性
计算密集型任务会影响响应性,通过降低执行计算密集型任务的线程的优先级可以提高前台任务的响应性
10.3.3 活锁
线程执行任务失败后,任务回滚,又添加到队列头部,导致线程没有阻塞但永远不会有进展。多个相互合作的线程为了响应其它线程而改变状态也会导致活锁
解决方法:在重试机制中引入一些随机性
Chapter 11. 性能和可伸缩性
11.1 对性能的思考
11.1.1 性能和可伸缩性
性能: 可以用任务完成快慢或者数量来衡量,具体指标包括服务时间、延迟、
吞吐量、可伸缩性等
可伸缩性: 增加计算资源时提供程序吞吐量的能力
11.1.2 评估性能权衡
许多性能优化牺牲可读性和可维护性,比如违反面向对象设计原则,需要权衡
11.2 Amdahl定律
N:处理器数量
F:必须串行执行的计算部分
Speedup:加速比
$\text { Speedup } \leq \frac{1}{F+\frac{1-F}{N}} $
串行执行的计算部分需要仔细考虑,即使任务之间互不影响可以并行,但是线程从任务队列中需要同步,使用ConcurrentLinkedQueue比同步的LinkedList性能好
11.3 线程引入的开销
- 上线文切换
- 内存同步(同步的性能开销包括可见性保证,即内存屏障,可以用jvm逃逸分析和编译器锁粒度粗化进行优化)
- 阻塞(非竞争的同步可以在JVM处理,竞争的同步需要操作系统介入,竞争失败的线程必定阻塞,JVM可以自旋等待(反复尝试获取锁,直到成功)或者被操作系统挂起进入阻塞态,短时间等待选择自旋等待,长时间等待选择挂起)
11.4 减少锁的竞争
并发程序中,对伸缩性最主要的威胁就是独占方式的资源锁
三种减少锁争用的方法:
- 减少持有锁的时间
- 减少请求锁的频率
- 用允许更大并发的协调机制替换互斥锁
11.4.1 减小锁的范围
锁的范围即持有锁的时间
11.4.2 降低锁的力度
分割锁:将保护多个独立的变量的锁分割成单独的锁,这样锁的请求频率就可以降低
11.4.3 分段锁
分割锁可以扩展到可变大小的独立对象上的分段锁。例如ConcurrentHashMap使用了一个包含16个锁的数组,每个锁保护1/16的哈希桶
分段锁缺点:独占访问集合开销大
11.4.4 避免热点字段
热点字段:缓存
热点字段会限制可伸缩性,例如,为了缓存Map中的元素数量,添加一个计数器,每次增删时修改计数器,size操作的开销就是O(1)。单线程没问题,多线程下又需要同步访问计数器,ConcurrentHashMap每个哈希桶一个计数器
11.4.5 互斥锁的替代品
考虑使用并发集合、读写锁、不可变对象和原子变量
读写锁:只要没有一个写者想要修改共享资源,多个读者可以并发访问,但写者必须独占地获得锁
原子变量:提供细粒度的原子操作,可以降低更新热点字段的开销
11.4.6 监测CPU利用率
cpu利用率低可能是以下原因:
- 负载不够,可以对程序加压
- IO密集,可以通过iostat判断,还可以通过监测网络上的流量水平判断
- 外部约束,可能在等待数据库或web服务的响应
- 锁竞争,可以用分析工具分析哪些是“热”锁
11.4.7 不要用对象池
现在JVM分配和回收对象已经很快了,不要用对象池
11.5 例子:比较Map的性能
ConcurrentHashMap单线程性能略好于同步的HashMap,并发时性能超好。ConcurrentHashMap对大多数成功的读操作不加锁,对写操作和少数读操作加分段锁
11.6 减少上下文切换
- 日志记录由专门的线程负责
- 请求服务时间不应该过长
- 将IO移动到单个线程
Chapter 12. 并发程序的测试
大多数并发程序测试安全性和活跃性。安全性可以理解为“永远不会发生坏事”,活跃性可以理解为“最终会有好事发生”
12.1 正确性测试
12.1.1 基础单元测试
和顺序程序的测试类似,调用方法,验证程序的后置条件和不变量
12.1.2 阻塞操作测试
在单独的一个线程中启动阻塞活动,等待线程阻塞,中断它,然后断言阻塞操作完成。
Thread.getState不可靠,因为线程阻塞不一定进入WAITING或TIMED_WAITING状态,JVM可以通过自旋等待实现阻塞。类似地,Object.wait和Condition.wait存在伪唤醒情况,处于WAITING或TIMED_WAITING状态的线程可以暂时过渡到RUNNABLE。
12.1.3 安全性测试
给并发程序编写高效的安全测试的挑战在于识别出容易检查的属性,这些属性在程序错误时出错,同时不能让检查限制并发性,最好检查属性时不需要同步。
生产者和消费者模式中的一种方法是校验和,单生产者单消费者可以使用顺序敏感的校验和计算入队和出队元素的校验和,多生产者多消费者要用顺序不敏感的校验和
12.1.4 资源管理测试
任何保存或管理其他对象的对象都不应该在不必要的时间内继续维护对这些对象的引用。可以用堆检查工具测试内存使用情况
12.1.5 使用回调
回调函数通常是在对象生命周期的已知时刻发出的,这是断言不变量的好机会。例如自定义线程池可以在创建销毁线程时记录线程数
12.1.6 产生更多的交替操作
Thread.yield放弃cpu,保持RUNNABLE状态,重新竞争cpu
Thread.sleep放弃cpu进入TIME_WAITING状态,不竞争cpu,sleep较小时间比yield更稳定产生交替操作
tips:Java 线程的RUNNABLE 状态对应了操作系统的 ready 和 running 状态,TIME_WAITING(调用Thread.sleep) 和 WAITING(调用Object.wait) 和 BLOCKED(没有竞争到锁) 对应 waiting 状态。interrupt是种干预手段,如果interrupt一个RUNNABLE线程(可能在执行长时间方法需要终止),如果方法声明抛出InterruptedException,就表示可中断,方法会循环检查isInterrupted状态来响应interrupt,一般情况线程状态变成TERMINATED。如果interrupt一个 waiting 线程(可能是由sleep、wait方法导致,这些方法会抛出InterruptedException),线程重新进入 RUNNALBE 状态,处理InterruptedException
12.2 性能测试
- 增加计时功能(CyclicBarrier)
- 多种算法比较(LinkedBlockingQueue在多线程情况下比ArrayBlockingQueue性能好)
- 衡量响应性
12.3 避免性能测试的陷阱
12.3.1 垃圾回收
垃圾回收不可预测,会导致测试误差,需要长时间测试,多次垃圾回收,得到更准确结果
12.3.2 动态编译
动态编译会影响运行时间,需要运行足够长时间或者与完成动态编译后再开始计时
12.3.3 对代码路径的不真实采样
动态编译器会对单线程测试程序进行优化,最好多线程测试和单线程测试混合使用(测试用例至少用两个线程)
12.3.4 不真实的竞争情况
并发性能测试程序应该尽量接近真实应用程序的线程本地计算,并考虑并发协作。例如,多线程访问同步Map,如果本地计算过长,那么锁竞争情况就较弱,可能得出错误的性能瓶颈结果
12.3.5 无用代码的删除
无用代码:对结果没有影响的代码
由于基准测试通常不计算任何东西,很容易被优化器删除,这样测试的执行时间就会变短
解决方法是计算某个派生类的散列值,与任意值比较,加入相等就输出一个无用且可被忽略的消息
12.4 补充的测试方法
- 代码审查
- 静态代码分析
- 面向切面的测试工具
- 分析与检测工具
Part 4 高级主题
Chapter 13 显示锁
Java 5 之前,对共享数据的协调访问机制只有 synchronized 和 volatile,Java 5 增加了 ReentrantLock。
13.1 Lock和ReentrantLock
Lock接口定义加锁和解锁的操作。
ReentrantLock还提供了可重入特性
显示锁和内置锁很像,显示锁出现的原因是内置锁有一些功能限制
- 不能中断等待锁的线程
- 必须在获得锁的地方释放锁
13.1.1 轮询和定时获得锁
tryLock:轮询和定时获得锁
内置锁碰到死锁是致命的,唯一恢复方法是重启,唯一防御方法是统一锁获取顺序,tryLock可以概率避免死锁
13.1.2 可中断的获得锁
lockInterruptibly,调用后一直阻塞直至获得锁,但是接受中断信号
13.1.3 非块结构加锁
内置锁是块结构的加锁和自动释放锁,有时需要更大的灵活性,例如基于hash的集合可以使用分段锁
13.2 性能考虑
从Java 6 开始,内置锁已经不比显式锁性能差
13.3 公平性
内置锁不保证公平,ReentrantLock默认也不保证公平,非公平锁可以插队(不提倡,但是不阻止),性能相比公平锁会好一些
13.4 在 Synchronized 和 ReentrantLock 中选择
当你需要用到轮询和定时加锁、可中断的加锁、公平等待锁和非块结构加锁,使用 ReentrantLock,否则使用 synchronized
13.5 读写锁
读写锁:资源可以被多个读者同时访问或者单个写者访问
ReadWriteLock 定义读锁和写锁方法,和 Lock 类似,实现类在性能、调度、获得锁的优先条件、公平等方面可以不同
Chapter 14 构建自定义的同步工具
最简单的方式使用已有类进行构造,例如LinkedBlockingQueue、CountDown-Latch、Semaphore和FutureTask等
14.1 状态依赖性的管理
单线程中,基于状态的前置条件不满足就失败。但是多线程中,状态会被其它线程修改,所以多线程程序在不满足前置条件时可以等待直至满足前置条件
14.1.1 将前置条件的失败传播给调用者
不满足前置条件就抛异常是滥用异常。调用者可以自旋等待(RUNNABLE态,占用cpu)或者阻塞(waiting态,不占cpu),即需要调用者编写前置条件管理的代码
14.1.2 通过轮询和睡眠粗鲁的阻塞
通过轮询和睡眠完成前置条件管理,不满足是就阻塞,调用者不需要管理前置条件,但需要处理 InterruptedException
14.1.3 条件队列
Object的wait,notify 和 notifyAll构成内置条件队列的API,wait会释放锁(本质和轮询与休眠是一样的,注意sleep前要释放锁)
14.2 使用条件队列
14.2.1 条件谓词
条件谓词:由类的状态变量构造的表达式,例如缓冲区非空即count>0
给条件队列相关的条件谓词以及等待它成立的操作写Javadoc
条件谓词涉及状态变量,状态变量由锁保护,所以在测试条件谓词之前,需要先获得锁。锁对象和条件队列对象(调用wait和notify的对象)必须是同一个对象
14.2.2 过早唤醒
一个线程由于其它线程调用notifyAll醒来,不意味着它的等待条件谓词一定为真。每当线程醒来必须再次测试条件谓词(使用循环)
14.2.3 丢失的信号
线程必须等待一个已经为真的条件,但是在开始等待之前没有检查条件谓词,发生的原因是编码错误,正确写法是循环测试条件谓词,false就继续wait
14.2.4 通知
优先使用 notifyAll,notify 可能会出现“hijacked signal”问题,唤醒了一个条件还未真的线程,本应被唤醒的线程还在等待。只有所有线程都在等同一个条件谓词且通知最多允许一个线程继续执行才使用notify
14.2.5 例子:门
用条件队列实现一个可以重复开关的线程门
14.2.6 子类的安全问题
一个依赖状态的类应该完全向子类暴露它的等待和通知协议,或者禁止继承
14.2.7 封装条件队列
最好将条件队列封装起来,在使用它的类的外面无法访问
14.2.8 进入和退出协议
进入协议:操作的条件谓词
退出协议:检查该操作修改的所有状态变量,确认他们是否使某个条件谓词成真,若是,通知相关队列
14.3 显示 Condition
显示锁在一些情况下比内置锁更灵活。类似地,Condition 比内置条件队列更灵活
内置条件队列有几个缺点:
- 每个内置锁只能关联一个条件队列,即多个线程可能会在同一个条件队列上等待不同的条件谓词
- 最常见的加锁模式会暴露条件队列
一个 Condition 关联一个 Lock,就像内置条件队列关联一个内置锁,使用 Lock.newCondition 来创建 Condition,Condition比内置条件队列功能丰富:每个锁有多个等待集(即一个Lock可以创建多个Condition),可中断和不可中断的条件等待,基于截止时间的等待,以及公平或不公平排队的选择。
Condition中和wait,notify,notifyAll 对应的方法是 await,signal,signalAll
14.4 Synchronizer剖析
ReentrantLock 和 Semaphore 有很多相似的地方。ReentrantLock可以作为只许一个线程进入的 Semaphore,Semaphore 可以用 ReentrantLock 实现
它们和其它同步器一样依赖基类 AbstractQueuedSynchronizer(AQS)。AQS是一个用于构建锁和同步器的框架,使用它可以轻松有效地构建范围广泛的同步器。
14.5 AbstractQueuedSynchronizer
依赖AQS的同步器的基本操作是获取和释放的一些变体。获取是阻塞操作,调用者获取不到会进入WAITING或失败。对于锁或信号量,获取的意思是获取锁或许可。对于CountDownLatch,获取的意思是等待门闩到达终点。对于FutureTask,获取的意思是等待任务完成。释放不是阻塞操作。同步器还会根据各自的语义维护状态信息
14.6 JUC同步器类中的AQS
JUC许多类使用AQS,例如 ReentrantLock, Semaphore, ReentrantReadWriteLock, CountDownLatch, SynchronousQueue, FutureTask
Chapter 15 原子变量与非阻塞同步机制
非阻塞算法使用原子机器指令,例如 compare-and-swap 取代锁来实现并发下的数据完成性。它的设计比基于锁的算法复杂但可以提供更好的伸缩性和活跃性,非阻塞算法不会出现阻塞、死锁或其它活跃性问题,不会受到单个线程故障的影响
15.1 锁的劣势
JVM对非竞争锁进行优化,但是如果多个线程同时请求锁,就要借助操作系统挂起或者JVM自旋,开销很大。相比之下volatile是更轻量的同步机制,不涉及上下文切换和线程调度,然后volatile相较于锁,它不能构造原子性的复合操作,例如自增
锁还会出现优先级反转(阻塞线程优先级高,但是后执行),死锁等问题
15.2 并发操作的硬件支持
排它锁是悲观锁,总是假设最坏情况,只有确保其它线程不会干扰才会执行
乐观方法依赖碰撞检测来确定在更新过程中是否有其它线程的干扰,若有则操作失败并可以选择重试
为多处理器设计的cpu提供了对共享变量并发访问的特殊指令,例如 compare‐and‐swap,load-linked
15.2.1 Compare and Swap
CAS 有三个操作数:内存地址V,期待的旧值A,新值B。CAS在V的旧值是A的情况下原子更新值为B,否则什么都不做。CAS是一种乐观方法:它满怀希望更新变量,如果检测到其它线程更新了变量,它就会失败。CAS失败不会阻塞,允许重试(一般不重试,失败可能意味着别的线程已经完成该工作)
15.2.2 非阻塞的计数器
在竞争不激烈的情况下,性能比锁优秀。缺点是强制调用者处理竞争问题(重试、后退或放弃),而锁通过阻塞自动处理争用,直到锁可用
15.2.3 JVM对CAS的支持
JVM将CAS编译成底层硬件提供的方法,加入底层硬件不支持CAS,JVM会使用自旋锁。原子变量类使用了CAS
15.3 原子变量类
原子变量比锁的粒度更细,重量更轻,能提供volatile不支持的原子性
- 原子变量可以作为更好的 volatile
- 在高度竞争情况下,锁性能更好,正常情况下,原子变量性能更好
15.4 非阻塞的算法
如果一个线程的故障或挂起不会导致另一个线程的故障或挂起,则该算法称为非阻塞算法;如果一个算法在每个执行步骤中都有线程能够执行,那么这个算法被称为无锁算法。如果构造正确,只使用CAS进行线程间协调的算法可以是无阻塞和无锁的
15.4.1 非阻塞的栈
创建非阻塞算法的关键是如何在保持数据一致性的同时,将原子性更改的范围限制在单个变量内。
非阻塞的栈使用CAS来修改顶部元素
15.4.2 非阻塞的链表
Michale-scott算法
15.4.3 原子字段更新器
原子字段更新器代表了现有 volatile 字段的基于反射的“视图”,以便可以在现有的 volatile 字段上使用CAS
15.4.4 ABA问题
大部分情况下,CAS会询问“V的值还是A吗?”,是A就更新。但是有时候,我们需要知道“从我上次观察到V是A以来,它的值有没有改变?”。对于某些算法,将V的值从A->B,再从B->A,是一种更改,需要重新执行算法。解决方法是使用版本号,即使值从A变成B再变回A,版本号也会不同
Chapter 16 Java 内存模型
16.1 内存模型是什么,为什么我需要一个内存模型?
在并发没有同步的情况下,有许多原因导致一个线程不能立即或永远不能再另一个线程中看到操作的结果
- 编译器生成的指令顺序与源码不同
- 变量存在寄存器中而不是内存中
- 处理器可以并行或乱序执行指令
- 缓存可能会改变写入变量到主内存的顺序
- 存储在处理器本地缓存中的值可能对其它处理器不可见
16.1.1 平台的内存模型
在共享存储的多处理器体系结构中,每个处理器都有自己的高速缓存,这些告诉缓存周期性地与主存协调。
体系结构的内存模型告诉程序可以从内存系统得到什么一致性保证,并制定所需的特殊指令(内存屏障或栅栏),以在共享数据时获得所需的额外内存协调保证。为了不受跨体系结构的影响,Java提供了自己的内存模型,JVM通过在适当的位置插入内存屏障来处理JMM(Java内存模型)和和底层平台的内存模型之间的差异
顺序一致性:程序中所有操作都有一个单一的顺序,而不管他们在什么处理器上执行,并且每次读取变量都会看到任何处理器按执行顺序对该变量的最后一次写入。
现代处理器没有提供顺序一致性,JMM也没有
16.1.2 重排
指令重排会使程序的行为出乎意料。同步限制了编译器、运行时和硬件在重排序时不会破坏JMM提供的可见性保证
16.1.3 Java 内存模型
Java 内存模型由一些操作指定,包括对变量的读写、监视器的锁定和解锁。JMM对所有操作定义了一个称为 happens before 的偏序规则:
- 程序顺序规则:线程按程序定义的顺序执行操作
- 监视器锁规则:监视器锁的解锁必须发生在后续的加锁之前
- volatile 变量规则:对 volatile 字段的写入操作必须发生在后续的读取之前
- 线程启动规则:对线程调用Thread.start会在该线程所有操作之前执行
- 线程结束规则:线程中任何操作必须在其它线程检测到该线程已经结束之前执行或者从Thread.join返回或者Thread.isAlive返回false
- 中断规则:一个线程对另一个线程调用 interrupt 发生在被中断线程检测到中断之前
- 终结器规则:对象的构造函数必须在启动该对象的终结器之前执行完成
- 传递性:A发生在B之前,B发生在C之前,那么A发生在C之前
16.1.4 借用同步
通过类库保证 happens-before顺序:
- 将元素放入线程安全的容器发生在另一个线程从集合中检索元素之前
- 在CountDownLatch上进行倒数发生在该线程从门闩的await返回之前
- 释放信号量的许可发生在获取之前
- Future代表的任务执行的操作发生在另一个线程从Future.get返回之前
- 提交Runnable或者Callable任务给执行器发生在任务开始之前
- 线程到达CyclicBarrier或Exchanger发生在其它线程释放相同的barrier或者exchange point之前
16.2 发布
16.2.1 不安全的发布
当缺少happens-before关系时候,就可能出现重排序问题,这就解释了为什么在没有同步情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象
除了不可变对象之外,使用由其他线程初始化的对象都是不安全的,除非对象的发布发生在消费线程使用它之前
16.2.2 安全发布
使用锁或者volatile变量可以确保读写操作按照 happens-before 排序
16.2.3 安全初始化
静态字段在声明时就初始化由JVM提供线程安全保证。
延迟初始化,可以写在同步方法里面,或者使用辅助类,在辅助类中声明并初始化
16.2.4 双重检查锁
Java 5之前的双重检查锁会出现引用是新值但是对象是旧值,这意味着可以看到对象不正确的状态,Java5之后给引用声明加上 volatile 可以起到线程安全地延迟初始化作用,但是不如使用辅助类,效果一样且更容易懂
16.3 初始化的安全性
初始化安全性只能保证通过final字段可达的值从构造过程完成时开始的可见性(事实不可变对象以任何形式发布都是安全的)。对于通过非final字段可达的值,或者构成完成之后可能改变的值,必须采用同步确保可见性。