ref: Java Concurrency in Practice
常见线程问题处理
总结
Java thread里面关于异常的部分比较奇特。你不能直接在一个线程里去抛出异常。一般在线程里碰到checked exception,推荐的做法是采用try/catch块来处理。而对于unchecked exception,比较合理的方式是注册一个实现UncaughtExceptionHandler接口的对象实例来处理。这些细节的东西如果没有碰到过确实很难回答。
Exception Type | 处理策略 |
---|---|
checked exception | 推荐的做法是采用try/catch块来处理 |
unchecked exception | 比较合理的方式是注册一个实现UncaughtExceptionHandler接口的对象实例来处理 |
Java 中任务调度比较:ref: https://www.ibm.com/developerworks/cn/java/j-lo-taskschedule/
- Timer
- ScheduledExecutor
- 开源工具包 Quartz
- 开源工具包 JCronTab
Part 1 基础部分
Ch 1 简介
线程特点:
- 线程被称为轻量级进程;
- 线程会共享进程范围内的资源,例如内存句柄和文件句柄,但每个进程都有各自的程序计数器、栈以及局部变量等;
- 线程还提供了一种直观的分解模式来充分利用多处理其系统中的硬件并行性,而在同一个程序中的多个线程也可以被同时调度到多个CPU上运行;
线程安全性定义:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个列是线程安全的。
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
Ch 2 线程安全性
要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的,Shared和可变的,Mutable状态的访问。
无状态(1. 不包含任何可变的域;2. 不包含对其他类中可变域的引用)的对象一定是线程安全的。
Ch 3 对象的共享
- 非原子的64位操作
对非volatile 类型的long和double 变量,JVM允许将64位的读操作和写操作分解为2个32位的操作,因此在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护。
- 加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作的或写操作的线程都必须在同一个锁上同步。
volatile 关键字作用:
- 编译器和运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序;
- volatile变量不会被缓存在寄存器或则对其他处理器不可见的地方,因此在读取volatile类型变量时总会返回最新写入的值。
- volatile 变量的典型用法:
检查某个状态标记以判断是否退出循环。
private volatile boolean asleep;
while (!asleep) {
countSomeSleep();
}
当且仅当满足以下所有条件时,才应该使用volatile 变量:
- 对变量的写入操作不依赖与变量的当前值,或者你能确保只有单个现场更新变量的值;
- 该变量不会与其他状态变量一起纳入不变性条件中;
- 在访问变量时不需要加锁。
线程封闭:仅在单线程内访问数据
For Example:
- JDBC(Java Database Connectivity) 的 Connection 对象
- 局部变量
- ThreadLocal 对象
安全发布的常用模式
- 在静态初始化函数中初始化一个对象引用;
- 将对象的引用保存在volatile 类型的域或者AtomicReference 对象中;
- 将对象的引用保存到某个正确构造对象的final类型域中;
- 将对象的引用保存在一个由锁保护的域中。
并发程序使用和共享对象实用的策略:
- 线程封闭:线程封闭对象只能由一个线程拥有,对象封闭在该线程中,并且只能由这个线程修改;
- 只读共享:可有多个线程共享,但是任何线程都不能修改对象。
- 线程安全共享:线程安全的对象爱内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步;
- 保护对象:被保护的对象只能通过持有的特定的锁来访问。
Ch 4 对象的组合
设计线程安全的类的三个基本要素:
- 找出构成对象状态的所有变量;
- 找出约束状态变量的不变性条件;
- 建立对象状态的并发访问管理策略。
- 客户端加锁机制:
Code 4-14
@NotThreadSave
public class ListHelper<E> {
public List<E> list = Collections.synchronizedList(new ArrayList<>());
public synchronized boolean putIfAbsent(E e) {
if (absent) {
list.add(e);
return absent;
}
}
}
问题在于在错误的在锁上进行了同步:要使方法正确的执行,必须要使List在实现客户端加锁或外部加锁时使用同一个锁,客户端加锁是指,对于使用某个对象X的客户端代码,使用X本身用户保护其本身的锁来保护这段客户端代码。
Ch 5 基础构建对象
5.5 同步工具类 p78
同步工具类 | description |
---|---|
1. CountDownLatch | |
2. Future Task | |
3, Semaphore | |
4. Barrier |
迭代器与ConcurrentModificationException
如果在迭代器期间计数器被修改,那么hasNext(), next() 将抛出ConcurrentModificationException。然而,这种检查是在没有同步的的情况下进行的因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改。这是一种设计上的平衡。从而降低并发修改操作的检测代价对程序性能带来的影响。隐蔽容器
虽然加锁可以防止迭代器抛出ConcurrentModificationException,但必须要记住咋所有对共性容器进行迭代的地方都需要加锁。
常见情况:
- 调用容器默认的toString()时会迭代元素;
- hashCode(), equals(), containsAll(), removeAll(), retailAll() 等方法;
- 以及把容器作为参数的构造函数,都会对容器进行迭代,所有这些间接的迭代操作都可能抛出ConcurrentModificationException。
- 并发容器
通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。
- ConcurrentHashMap
- 与HashMap相比,ConcurrentHashMap使用一种完全不同的加锁策略来提供更高的并发性和伸缩性;
- ConcurrentHashMap并不是将每个方法上都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种力度更细的加锁机制来实现各大程度的共享,这种机制成为分段锁(Lock Striping)。该机制可以使得任意数量的读线程可以并发地访问Map,和一定数量的写入线程可以并发的修改Map;
- 提供迭代器不会抛出ConcurrentModificationException,因此不需要再迭代过程对容器加锁;
- 方法size(), isEmpty() 等在语义被略微减弱了以反映容器的并发特性。有size()放回的结果在计算时可能已经过期了,它实际上是一个估计值,因此允许size()返回一个近似值而不是一个精确值。
- CopyOnWriteArrayList, CopyOnWriteArraySet
- 用来替代synchronizedList,在某些情况下提供了更好的并发性能;
- 写入时复制(Copy-On-Write) 容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象是就不再需要进一步同步;
- 在每次修改时都会创建并重新发布一个新的容器副本,从而实现可变性;
- 仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器;
- 双端队列与工作密取
- 双端队列适用于工作密取(Work Stealing),在工作密取设计中,每个消费者都有各自的双端队列,如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。
- 工作密取非常适用于及时消费者也是生产者问题——但执行某个工作可能导致出现更多的工作。
例如:在网页爬虫程序中处理一个页面时,通常会发现有更多的页面需要处理。类似的还有搜索图算法。
- CountDownLatch
方法 | 说明 |
---|---|
countDown() | 递减计数器, 表示有一个事件已经发生了 |
await() | 等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器非零,那么await()会一直阻塞直到计数器为零,或者等待中的线程中断,或者等待超时。 |
- FutureTask
FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布。
Ch 6 任务执行
Executor 执行任务阶段
阶段 | 描述 |
---|---|
创建 | |
提交 | |
开始 | 在开始之前的任务允许取消 |
完成 | 已经开始,但是未完成的任务不可取消,只有当他们能响应中断时,才能取消 |
ExecutorService
状态 | Description |
---|---|
运行 | 初始创建时处于运行状态 |
关闭 | shutdown 方法将执行平稳的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成——包括哪些还未开始执行的任务。shutdownNow 方法将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中的尚未开始执行的任务。 |
调用interrupt并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。
通常,中断是实现取消的最合理方式。
Ch7 取消与关闭
- 要使任务和线程能安全,快速,可靠地停止下来,并不是一件容易的事。Java 没有提供任何机制来安全地终止线程。但它提供了中断(Interruption),这是一种协作机制,能够使一个线程终止另一个线程的当前工作。
- Java 中没有一种安全的抢占式方法来停止线程,因此也就没有安全的抢占式方法来停止任务,只有一些协作式的机制,是请求取消的任务和代码都遵循一种协商好的协议。
- 调用interrupt() 并不意味着立即停止目标线程正在进行的工作,而只是传递了中断的消息。
对中断操作的正确理解是:它并不会真正地中端一个正在运行的线程,而只是发出中断请求,然后又线程在下一个合适的时刻中断自己。
Ch 8 线程池的使用
- 线程池 Executors, ref: p143
线程池 | Description/默认任务队列 |
---|---|
newFixedThreadPool | LikedBlockingQueue |
newCachedThreadPool | SynchronousQueue |
newSingleThreadExecutor | LikedBlockingQueue |
newScheduledThreadPool |
- 管理任务队列
ThreadPoolExecutor 允许提供一个BlockingQueue 来保存等待执行的任务。
队列 | Description/实现 |
---|---|
无界队列 | 无界的LikedBlockingQueue, |
有界队列 | 有界的LikedBlockingQueue, ArrayBlockingQueue, PriorityBlockingQueue |
同步移交, Synchronous Handoff | SynchronousQueue 对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务排队,以及直接将任务从生产者移交给工作者线程。SynchronousQueue并不是一个真正的队列,而是一种在线程之间进行移交的机制。 |
优先级队列 | PriorityBlockingQueue |
- 饱和策略
当有界队列被填满后,饱和策略开始发挥作用,
如果某个任务被提交到一个已被关闭的Executor时,也会用到饱和策略。
设置方法:
ThreadPoolExecutor.setRejectedExecutionHandler()
- 饱和策略分类
策略 | Description |
---|---|
AbortPolicy | 默认饱和策略,该策略将抛出未检查的RejectedExecutionException,调用者可以捕获这异常,然后根据需求编写自己的业务代码。 |
CallerRunsPolicy | 调用者运行策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务会退到调用者中运行,从而降低新任务的流量。当线程池中的所有线程都被占用,并且工作队列被填满后,下一个任务会在调用execute时在主线程中执行。 |
DiscardPolicy | 当新提交的任务无法保存到队列中等待执行时,Discard策略会悄悄抛弃该任务 |
DiscardOldestPolicy | 抛弃下一个将被执行的任务,然后尝试重新提交新的任务。如果工作队列是一个优先队列,那么“抛弃最旧的策略”将导致抛弃优先级最高的任务,依尼茨最好不要将“抛弃最旧的策略”饱和策略和优先级队列一起使用。 |
Ch 11 性能与可伸缩性
线程作用:
- 提高性能;
- 提高响应性。