02.线程池

Java 线程池

Java 语言的实现中,把 Java 线程一一映射到操作系统级的线程,而后者是操作系统的资源,这意味着,如果开发者毫无节制地创建线程,那么线程资源就会被快速的耗尽。在 Windows 操作系统上,每个线程要预留出 1m 的内存空间,意味着 2G 的内存理论上做多只能创建 2048 个线程。而在 Linux 上,最大线程数由常量 PTHREAD_THREADS_MAX 决定,一般为 1024。

出于模拟并行性的目的,Java 线程之间的上下文切换也由操作系统完成。因为线程上下文切换需要消耗时间,所以,一个简单的观点是:产生的线程越多,每个线程花在实际工作上的时间就越少。

为什么会有线程上下文切换?一台电脑,运行起来后,它的 CPU 是固定的,05 年之前,还是单核时代,也就是一次只能运行一个线程,虽然随着时间的推移,现在的 CPU 已经有很多个核心,比如 8 核 16 核之类的。但相比于一个应用程序能够创建的线程数,那真的是太少了。而每个核心一次只能运行一个线程,所以多个线程需要运行时就需要来回不停的在多个线程间切换,这就是线程之间的上下文切换。

为了节制创建线程的数量,也为了节省创建线程的开销,因此提出了线程池的概念。线程池模式有助于节省多线程应用程序中的资源,还可以在某些预定义的限制内包含并行性。当我们使用线程池时,我们可以以并行任务的形式编写并发代码并将其提交到线程池的实例中执行。这个线程池实例控制了多个重用线程以执行这些任务。

任务提交与执行

这种线程池模式,允许我们控制应用程序创建的线程数,生命周期,以及计划任务的执行并将传入的任务保留在队列中。

线程池类别

JVM 上的线程池通常应被分为以下三类:CPU-bound、Blocking IO、Non-blocking IO polling。每个类别都有不同的最佳配置和使用模式。

CPU 绑定的线程池

对于 CPU-bound 的任务,你需要一个绑定的线程池(Bounded thread poll);这个线程池是预先分配的,并且正好与 CPU 的数量相符合。你在这个池子里唯一能做的就是就是利用 CPU 进行的相关计算,所以超过 CPU 的数量是没有意义的,除非你碰巧有一个非常特别的工作流程,可以使用超线程(在这种情况下,你可以使用双倍的 CPU 数量)。请注意,CPU 数量加一的惯例来自于混合模式的线程池,在那里,CPU 绑定的任务和 IO 绑定的任务被合并,现在则通常不会这样做。

处理 Blocking IO 的线程池

固定线程池的问题是,任何阻塞的 IO 操作都会吃掉一个线程,而线程是一种极其有限的资源。因此,我们希望不惜一切代价避免在 CPU-bound 的池子里进行阻塞操作。不幸的是,这并不总是可能的(例如,当被迫使用一个阻塞的 IO 库时)。在这种情况下,你应该总是把你的阻塞操作(IO 或其他)推到一个单独的线程池:这个独立的线程池应该是缓存的,并且是没有任何预分配的大小。说白了,这是一种非常危险的线程池类型,它并不能阻止你在其他线程阻塞时分配越来越多的线程,最终可能导致系统陷入非常危险的状态。你需要确保任何导致在这个池子上运行动作的数据流是有外部约束的,这意味着你有语义上更高层次的检查,以确保在任何时间点上只有固定数量的阻塞动作可能是未完成的(这通常是通过非阻塞约束队列完成的)。

这里需要注意的是,在实现该线程池时,一般会有两种模式:对接受限队列的无界限线程池(Unbounded thread poll with bounded queue)以及自身就有界限的线程池(bounded thread pool),我们优先选择后者。有界限的线程池包含无界限的任务队列,完全不受你的控制。你无法看到有多少未完成的任务,重新安排它们,取消它们,改变你的语义,等等。当你的稀缺资源开始耗尽时,你需要能够以临时连接中断的形式将该信息传回上游,甚至更好的是,触发自动缩放以创造更多的资源。你希望在堆栈中尽可能高的层次上做到这一点,因为这给了你最大的资源管理的控制能力。

非阻塞轮询的线程池

最后一类有用的线程(假设你不是一个 Swing/SWT 应用程序)是异步 IO 轮询。这些线程基本上只是坐在那里询问内核是否有一个新的未完成的异步 IO 通知,并将该通知转发给应用程序的其他部分。你想用非常少的固定的、预先分配的线程来处理这个问题。许多应用程序只用一个线程来处理这个任务。这些线程应该被赋予最大的优先权,因为应用程序的延迟将围绕它们的调度而被约束。但你需要注意的是,永远不要在这个线程池上做任何工作!永远不要。永远不要。当你收到一个异步通知的时候,你应该立即转回 CPU 池。你在异步 IO 线程上花费的每一纳秒都会给你的应用程序增加延迟。由于这个原因,一些应用程序可能会发现,使他们的异步 IO 池的大小为 2 或 4 个线程,而不是传统的 1 个线程,性能会稍好一些。

全局线程池

我看到很多关于不要使用全局线程池的建议在流传,比如 scala.concurrent.ExecutionContext.global。这个建议的根源在于,全局线程池可以被任意的代码(通常是库代码)访问,而且你无法(很容易)确保这些代码适当地使用线程池。这对你来说有多大的影响,很大程度上取决于你的 classpath。全局线程池是非常方便的,但同样地,拥有你自己的应用程序内部的全局池也并不难。

在这一点上,对于任何框架或库,如果 a)使配置线程池变得困难,或者 b)直接默认为一个你无法控制的线程池,都要非常谨慎地看待。无论如何,你几乎总是会在你的应用程序中的某个地方有某种单子对象,它有这三个池子,预先配置好供使用。如果你赞成 “隐式 ExecutionContext 模式”,那么你应该让 CPU 池成为隐式的,而其他的必须明确选择。

Java 中的线程池

Executors、Executor 和 ExecutorService

Executors 是一个帮助类,提供了创建几种预配置线程池实例的方法。如果你不需要应用任何自定义的微调,可以调用这些方法创建默认配置的线程池,因为它能节省很多时间和代码。Executor 和 ExecutorService 接口则用于与 Java 中不同线程池的实现协同工作。通常,你应该将代码与线程池的实际实现分离,并在整个应用程序中使用这些接口。Executor 接口提供了一个 execute() 方法将 Runnable 实例提交到线程池中执行。

下面的代码是一个快速示例,演示了如何使用 Executors API 获取包含了单个线程池和无限队列支持的 Executor 实例,以便按顺序执行任务。

Executor executor = Executors.newSingleThreadExecutor();

获取了 Executor 示例后,我们就可以使用 execute() 方法将一个只在屏幕上打印 Hello World 的任务提交到队列中执行。

executor.execute(() -> System.out.println("Hello World"));

上面这个示例使用了 lambda(Java 8 特性)提交任务,JVM 会自动推断该任务为 Runnable

Links