02.线程池

Java线程池

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

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

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

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

任务提交与执行

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

线程池类别

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池的大小为24个线程,而不是传统的1个线程,性能会稍好一些。

全局线程池

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

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

Java中的线程池

Executors、ExecutorExecutorService

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

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

Executor executor = Executors.newSingleThreadExecutor();

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

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

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

Links