<Java Concurrency in Practice> 读书笔记 - Part2: 结构化并发应用程序

Table of Contents

第6章 任务执行

在线程中执行任务

无限制创建线程的不足

  • 线程生命周期的开销非常高.
  • 资源消耗.
  • 稳定性.

Executor 框架

public interface Executor {
    void execute(Runnable command);
}

Executor 基于生产者 - 消费者模式, 提交任务的操作相当于生产者(生成待完成的工作单元), 执行任务的线程则相当于消费者(执行完这些工作单元).

执行策略包括:

  • 在什么线程中执行任务?
  • 任务按照什么顺序执行(FIFO, LIFO, 优先级)?
  • 有多少个任务能并发执行?
  • 在队列中有多少个任务在等待执行?
  • 如果系统由于过载而需要拒绝一个任务, 那么应该选择哪一个任务? 另外, 如何通知应用程序有任务被拒绝?
  • 在执行一个任务之前或之后, 应该进行哪些动作?
每当看到这种形式的代码时: new Thread(runnable).start()
并且你希望获得一种更灵活的执行策略时, 请考虑使用 Executor 来代替 Thread.

线程池是指管理一组同构工作线程的资源池. 工作线程的任务很简单: 从工作队列中获取一个任务, 执行任务, 然后返回线程池并等待下一个任务.
类库提供了一个灵活的线程池以及一些有用的默认配置. 可以通过调用 Executors 中的静态工厂方法之一来创建一个线程池:

  • newFixedThreadPool. 将创建一个固定长度的线程池, 每当提交一个任务时就创建一个线程, 直到达到线程池的最大数量,
    这时线程池的规模将不再变化(如果某个线程由于发生了未预期的 Exception 而结束, 那么线程池会补充一个新的线程).
  • newCachedThreadPool. 将创建一个可缓存的线程池, 如果线程池的当前规模超过了处理需求时, 那么将回收空闲的线程, 而当需求增加时, 则可以添加新的线程池, 线程池的规模不存在任何限制.
  • newScheduledThreadPool. 将创建已个固定长度的线程池, 而且以延迟或定时的方式来执行任务, 类似于 Timer. (L: Timer 存在一些缺陷, 不要使用)
  • newSingleThreadExecutor. 是一个单线程的 Executor, 它创建单个工作线程来执行任务, 如果这个线程异常结束, 会创建另一个线程来替代. 它能确保依照任务在队列中的顺序来串行执行(例如FIFO, LIFO, 优先级).

为了解决执行服务的生命周期问题, ExecutorService 继承了 Excutor 接口, 添加了一些用于生命周期管理的方法(同时还有一些用于任务提交的便利方法).
ExecutorService 的生命周期有3种状态: 运行, 关闭和已终止. ExecutorService 在初始创建时处于 运行状态.
shutdown 方法将执行平缓的关闭过程: 不再接受新的任务, 同时等待已经提交的任务执行完成, 包括那些还未开始执行的任务.
shutdownNow 方法将执行粗暴的关闭过程: 它将尝试取消所有运行中的任务, 并且不再启动队列中尚未开始执行的任务.
在 ExecutorService 关闭后提交的任务将由 RejectedExecutionHandler 来处理不能执行的任务.
等所有任务都完成后, ExecutorService 将转入 终止状态. 可以调用 awaitTermination 来等待 ExecutorService 到达终止状态, 或者通过调用 isTerminated 来轮询 ExecutorService 是否已经终止.

找出可利用的并发性

如果向 Executor 提交了一组计算任务, 并且希望在计算完成后获得结构, 可以使用: CompletionService
CompletionService 将 Executor 和 BlockingQueue 的功能融合在一起. 可以将 Callable 任务提交给它来执行, 然后使用类似于队列操作的 take 和 poll 等方法来获得已完成的结果, 而这些结果会在完成时将被封装为 Future.

ExecutorCompletionService 的实现非常简单. 在构造函数中创建一个 BlockingQueue 来保存计算完成的结果. 当计算完成时, 调用 FutureTask 中的 done 方法.
当提交某个任务时, 该任务将首先包装为一个 QueueingFuture, 这是 FutureTask 的一个子类, 然后再改写子类的 done 方法, 并将结果放入 BlockingQueue 中. take 和 poll 方法委托给了 BlockingQueue, 这些方法会在得出结果之前阻塞.

第7章 取消与关闭

任务取消

在 Java 的 API 或语言规范中, 并没有将中断与任务取消语义关联起来, 但实际上, 如果在取消之外的其他操作中使用中断, 那么都是不合适的, 并且很难支撑起更大的应用.
调用 interrupt 并不意味着立即停止目标线程正在进行的工作, 而只是传递了请求中断的消息.

对中断操作的正确理解是: 它并不会真正地中断一个正在运行的线程, 而只是发出中断请求, 然后由线程在下一个合适的时刻中断自己.
使用静态的 Thread.interupted() 时应该小心, 因为它会清除当前线程的中断状态.

通常, 中断是实现取消的最合理方式.
由于每个线程拥有各自的中断策略, 因此除非你知道中断对该线程的含义, 否则就不应该中断这个线程.
只有实现了线程中断策略的代码才可以屏蔽中断请求. 在常规的任务和库代码中都不应该屏蔽中断请求.
当 Future 的 get 方法抛出 InterruptedException 或 TimeoutException 时, 如果你知道不再需要结果, 那么就可以调用 Future 的 cancel 方法来取消任务.

停止基于线程的服务

对于持有线程的服务, 只要服务的存在时间大于创建线程的方法的存在时间, 那么就应该提供生命周期的方法.

处理非正常的线程终止

在运行时间较长的应用程序中, 通常会为所有线程的未捕获异常指定同一个异常处理器, 并且该处理器至少会将异常信息记录到日志中.

JVM 关闭

关闭钩子应该是线程安全的: 它们在访问共享数据时必须使用同步机制, 并且小心地避免发生死锁, 这与其他并发代码的要求相同.

线程可分为两种: 普通线程和守护线程. 在 JVM 启动时创建的所有线程中, 除了主线程以外, 其他的线程都是守护线程(例如垃圾回收器以及其他执行辅助工作的线程).
当创建一个新线程时, 新线程将继承创建它的线程的守护状态.
普通线程与守护线程之间的差异 在于当线程退出时发生的操作. 当一个线程退出时, JVM 会检查其他正在运行的线程, 如果这些线程都是守护线程, 那么 JVM 会正常退出操作.
当 JVM 正常停止时, 所有仍然存在的守护线程都将被抛弃, 它们既不会执行 finally 代码块, 也不会执行回卷栈, 而 JVM 只是直接退出.

此外, 守护线程通常不能用来替代应用程序管理程序中各个服务的生存周期.
避免使用终结器. (L: Object 的 finalize 方法)

第8章 线程池的使用

在任务与执行策略之间的隐形耦合

在一些任务中, 需要拥有或排除某种特定的执行策略. 如果某些任务依赖于其他的任务, 那么会要求线程池足够大, 从而确保它们依赖任务不会被放入等待队列中或被拒绝, 而采用线程封闭机制的任务需要串行执行.
通过将这些需求写入文档, 将来的代码维护人员就不会由于使用了某种不合适的执行策略而破坏安全性或活跃性.
每当提交一个有依赖性的 Executor 任务时, 要清楚地知道可能会出现线程"饥饿"死锁, 因此需要在代码或配置 Executor 的配置文件中记录线程池的大小限制或配置限制.

设置线程池的大小

要设置线程池的大小, 只需要避免“过大”和“过小”这两种极端情况.
如果线程池过大, 那么大量的线程将在相对很少的 CPU 和内存资源上发生竞争, 这不仅会导致更高的内存使用量, 而且还可能耗尽资源.
如果线程池过小, 那么将导致需要空闲的处理器无法执行工作, 从而降低吞吐率.
对于计算密集型的任务, 在拥有 N 个处理器的系统上, 当线程池的大小为 N+1 时, 通常能实现最优的利用率.
对于包含 I/O 操作或者其他阻塞操作的任务, 由于线程并不会一直执行, 因此线程池的规模应该更大.

配置 ThreadPoolExecutor

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) { ... }

线程的创建与销毁
maximumPoolSize 表示线程池的最大并发量, 如果大于 corePoolSize 的线程的空闲时间超过了存活时间, 那么这个线程将被终止.

管理队列任务
ThreadPoolExecutor 允许提供一个 BlockingQueue 来保存等待执行的任务. 基本的任务排队方式有3种: 无界队列, 有界队列和同步移交. 队列的选择与其他的配置参数有关, 例如线程池的大小等.

  • newFixedThreadPool 和 newSigleThreadExecutor 在默认情况下将使用一个无界的 LinkedBlockingQueue. 如果所有工作者线程都处于忙碌状态, 那么任务将在队列中等候.
    如果任务持续快速地到达, 并且超过了线程池处理它们的速度, 那么队列将无限制增加.
  • 一种更稳妥的资源管理策略是使用有界队列, 例如 ArrayBlockingQueue, 有界的 LinkedBlockingQueue 或 PriorityBlock]ingQueue.
    有界队列有助于避免资源耗尽的情况发生, 但它又带来了新的问题: 当队列填满后, 新的任务该怎么办?
  • 在使用有界的工作队列时, 队列的大小与线程池的大小必须一起调节.
    如果线程池较小而队列较大, 那么有助于减少内存使用量, 降低 CPU 的使用率, 同时还可以减少上下文切换, 但付出的代价是可能会限制吞吐量.
  • 对于非常大的或者无界的线程池, 可以通过使用 SynchronousQueue 来避免任务排队, 以及直接将任务从生产者移交给工作者线程.
    要将一个元素放入 SynchronousQueue 中, 必须有另一个线程正在等待接受这个元素.
    如果没有线程正在等待, 并且线程池的当前大小小于最大值, 那么 ThreadPoolExecutor 将创建一个新的线程, 否则根据饱和策略来处理.
    使用直接移交将更搞笑, 因为任务会直接移交给执行它的线程, 而不是被首先放在队列中, 然后由工作者线程从队列中提取该任务.
对于 Executor, newCachedThreadPool 工厂方法是一种很好的默认选择, 它能提供比固定大小的线程池更好的排队性能.
当需要限制当前任务的数量以满足资源管理需求时, 那么可以选择固定大小的线程池, 就像在接受网络客户请求的服务器应用程序中, 如果不进行限制, 那么很容易发生过载问题.

饱和策略
当有界队列被填满后, 饱和策略开始发挥作用.

  •    中止策略: 默认策略, 该策略将抛出未检查的 RejectedExecutionException.
  •    抛弃策略: 会悄悄的抛弃该任务.
  • 抛弃最旧的策略: 会抛弃下一个将被执行的任务, 然后尝试重新提交新的任务.
  • 调用者运行策略: 将某些任务退回给调用者, 从而降低新任务的流量.

线程工厂
每当线程池需要创建一个线程时, 都是通过线程工厂方法来完成的. 默认的线程工厂方法将创建一个新的, 非守护的线程, 而且不包括特殊的配置信息.
在许多情况下都需要使用定制的线程工厂方法, 比如: 给线程取一个更有意义的名称, 但尽量不要修改修改线程的优先级与守护状态.

扩展 ThreadPoolExecutor

它提供了几个可以在子类化中改写的方法: beforeExecute, afterExecute, terminated.
在执行任务的线程中将调用 beforeExecute 和 afterrExecute 等方法, 这些方法中还可以添加日志, 计时, 检视或统计信息收集的功能.
在线程池完成关闭操作时调用 terminated. 可以用来释放 Executor 在其生命周期里分配的各种资源, 此外还可以执行发送通知,记录日志或者收集 finalize 统计信息等操作.

第9章 图形用户界面应用程序

暂不关心.

Author: Saul Lawliet

Created: 2022-03-21 Mon 15:34