Java基础整理之线程池

Java基础整理之线程池

hao Lv4

听说你熟悉线程池?

在Java的世界里,我们一遇到阻塞的问题,就会想着去创建线程。之所以这么做能够在某些情况下加快程序的运行,是基于这么几个事实。

  • 线程是CPU可以调度的最小任务单元。如果一个进程是单线程的,那么当他因为某种原因陷入阻塞时就会失去CPU的使用权。假如我们这台服务器就是给某个server用的,那它失去了使用权显然不是我们期待的行为。所以即使在单CPU的情况下,使用多线程模型也可以保证在其中一个线程因阻塞失去CPU使用权的时候,我们进程中还有其他任务可以得到CPU的调度。
  • 现代服务器基本都是多处理器,或者多核心的,从概念模型上,就是有多个CPU可以用。如果我们坚持使用单线程,因为一个线程只能被一个CPU执行,那么其他CPU就浪费了,这也显然不是我们期望的行为。

所以,在大部分情况下,使用多线程都会带来性能上的提升。

但是问题来了,是不是使用多线程一定会带来性能上的提升?

  • 场景一

    一个单线程在单CPU上执行CPU密集型的任务,根本没有IO什么乱七八糟的阻塞,他就是占据CPU,一直在运算,这时候多开一个线程,能不能提高运行效率?

  • 场景二

    在4个可用CPU的场景下,一口气提交了1000个任务,我是不是开1000个线程就能达到最佳性能?

答案都是否定的,我们来分析在启用和不启用多线程的情况下,CPU在处理任务的时候,到底干了哪些活儿,我们模拟一个08年的梦幻PC机,拥有牛叉的双核处理器。

  • 不启用多线程。

    • 提交了任务一。交由主线程,CPU-1拿到任务,开始处理。
    • 提交了任务二。交给主线程,CPU-1的任务还没有处理完,此时CPU-2可用,但是因为线程是CPU可调度的最小任务单元CPU-2无法和CPU-1同时处理一个线程的工作,因此任务二排队,CPU-2闲置。
    • CPU-1完成了任务一的处理,继续处理任务二。
  • 启用了多线程,线程按需创建。

    • 提交了任务一。交由线程1,CPU-1拿到任务,开始处理。

    • 提交了任务二。此时没有多余的线程可以处理任务,CPU-2创建了一个线程,他是线程2,任务2现在交个线程2处理,CPU-2单独调度线程2,此时CPU-1干任务1,CPU-2干任务2,很好。

    • 提交了任务三,此时没有多余的线程可以处理任务,CPU-1CPU-2都在忙,但是现代CPU都是时分复用的,于是CPU-1这时候暂时放下线程1中的任务1,为任务3创建了一个线程3,接下来,CPU-1CPU-2在3个线程之间来回切换。

    • more task the same

  • 启用了多线程,但只允许最多有两个线程。

    • 提交了任务一。交由线程1,CPU-1拿到任务,开始处理。
    • 提交了任务二。此时没有多余的线程可以处理任务,CPU-2创建了一个线程,他是线程2,任务2现在交个线程2处理,CPU-2单独调度线程2,此时CPU-1干任务1,CPU-2干任务2,很好。
    • 提交了任务三。这时候不允许创建新的线程了,于是任务3没办法,只能在等待某一个线程的任务完成以后,再把自己放到线程里面去,得到某个CPU的调度。此时CPU-1干任务1,CPU-2干任务2。

所以我们发现,多线程其实为整个CPU增加了一些额外的工作,这主要包括

  • 创建线程的开销。这个开销其实很大,是重量级的系统调用,还要为线程开辟栈空间、初始化一些数据,等等。
  • 任务切换的开销。这个开销也不小,CPU需要把寄存器里面的东西都替换出来(也就是任务上下文的保存),然后把之前保存的另外一个任务的上下文装载到寄存器里面,现代CPU本来内部还很复杂,流水线什么的基本上全部都破坏掉了,也非常消耗资源。

可见,整体效率最高的办法其实是:有几个CPU,我们就开几个线程,然后任务排队,这样CPU始终在跑一个任务,永远不切换,跑完一个换下一个。这样算下来,完成N个任务的总体时间是最短的。

但这并不是最友好的解决方案,现实世界总有那么多额外限制,让我们不得不做出权衡。假设我们现在有个服务器,有10个人先后请求了,那么是不是最后一个请求的人就倒霉到必须等到前面所有请求都干完了才轮到我呢?假如前面的人是下载一个视频,而我只是发个文本消息,我还得等到他们干完才轮到我,是不是很不爽?所以实际上,除了确实在干CPU密集型的任务而且容许排队,不然适当开多个线程,让CPU切换处理,是一种权衡之下的更优解。

好吧,上面是一些人尽皆知的基础知识……希望没有浪费各位看官的时间。

做个简单的总结,我个人认为,使用线程的两大主要目的,一个是提高CPU的使用效率,一个是保持合理的客户请求响应速度。显然,如果我们的程序就是要做大量运算的,不提供客户服务,显然线程数量和CPU数量保持一致最好;如果想保证合理的客户响应速度,需要多开一些线程,但线程新建的内存和CPU消耗,以及线程切换时的消耗随着线程数量的增加而增加,总会到某个节点,线程新建和切换的消耗完全抵消了多线程可能带来的性能提升,CPU的时间被大量浪费在新建和切换线程,所有的响应都慢的受不了。

所以,在我们业务要求我们必须保持合理的响应时间的时候,我们(被迫)必须考虑这样的两个核心问题:

  1. 能不能减少线程新建的开销?
  2. 能不能减少线程切换的开销,但却又能保证足够高的CPU利用率。

问题一的答案是使用线程池,而问题二的答案是设置合理的线程数量。

我们来看一段我们代码里使用线程池的方法:

1
2
3
4
5
6
7
public String stupidGuy() {
final ExecutorService executors = Executors.newFixedThreadPool(threadCount);
for (int i = 0; i < threadCount; i++) {
executors.execute(new someRunnable());
}
// 我们并不关心这些任务什么时候能结束,所以直接返回了
}

我们来看这段代码干了什么:

  1. 新建了一个线程池,这个线程池因为是使用Executors.newFixedThreadPool(threadCount)建立出来的,因此会有threadNum个核心线程,threadNum个最大线程,不会主动回收线程资源,并且初始化线程数量为0。
  2. 现在我们在for循环里面开始向线程池提交任务,那么他会一直创建新的线程来接受任务,即使有空闲线程可用,也会创建新的线程。直到线程数量达到threadNum。
  3. 然后因为我们不关心这些任务什么时候结束,所以直接返回了。

直接说答案,这是完全错误的用法,因为造成了内存泄露。

Executors.newFixedThreadPool(threadCount)在没有调用shutDown()的情况下,即使已经没有引用指向他了,他也永远不会释放自己维护的线程池里面的线程资源,所以一定会造成内存泄露。

于是我们稍微修改这代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public String stupidGuy() {
final ExecutorService executors = Executors.newFixedThreadPool(threadCount);
for (int i = 0; i < threadCount; i++) {
executors.execute(new someRunnable());
}
executors.shutdown();
// 如果我们希望所有任务完成后再继续,那么可以加上下面这段代码,但加不加对我们这里
// 想要考察的问题是没有影响的。
try {
executors.awaitTermination(1,TimeUnit.DAYS)
} catch (InterruptedException e) {
// 并没有人来打断这个线程,所以donothing
}
}
  1. 同上;
  2. 同上;
  3. shutdown()之后,且线程池里面的所有线程都运行完了,且线程池的引用也离开了作用域,这时候相关线程资源会被释放,于是Thread对象本身、其代表的线程所申请的栈空间等等,都会被回收释放。不会内存泄露啦,是不是有点开心。(Executors#创建其他线程池的方法创建出来的线程池,不需要像Executors.newFixedThreadPool(threadCount)创建出来的线程池一样显式调用shutdown()也可以内存回收)

等等,我们最开始说什么来着?创建线程池是为了干嘛?为了减少线程新建的开销,可是我们在这里干什么呢?每次调用这个方法,我都新建了个线程池,在第二步还新建了线程。

那你说我建个线程池图啥呢?

所以说:线程池不是这么用滴,实际上,一个线程池,基本应该是全局的。或者至少是类的静态变量。在方法里建局部线程池是错误的用法,因为完全没有解决线程池这个概念旨在解决的问题,甚至新增了一些维护线程池的负担。

然后我们看第二个问题,到底多少线程是合理的。

这个其实需要评估。使用线程池是为了更大限度提高CPU的利用率,那么实际上比较理想的模型是:当一个线程因为IO而阻塞时,总是有刚刚好有需要使用CPU的线程可供CPU调度,这需要根据业务进行量化评估。

比如说:我们一个线程中的工作,20%是CPU需要运算的,而80%则用来等待IO。那么我们可以把线程池中维护的线程的数量提高到CPU核心数量的4倍。这样则所有等待IO的时间,CPU都有实际的工作线程可以调度。为了维护服务对多个请求都有合理的响应时间,那么线程数量实际上还可以再多几个(30%-50%)。更多的线程未必能带来更好的效果。如果要更精细地调优,我感觉还得评估CPU上下文切换的开销到底有多大。这个太难了,不玩。

不是总结的总结

坑到处都有,很多正常不过的操作细想也不是那么回事……线程池最好是全局或者至少是类静态变量,不然就丧失了使用线程池的意义,而线程数量也不是越多越好,而要根据实际的业务需求进行评估,一般可能需要经过反复的测试、评估,才能找到最合适的线程数量。

聊胜于无的版权声明

仅用于几位好友的内部技术交流,请勿邮件转发给工作相关人员。

如果承蒙错爱想发博客,注明一下作者即可。