天天看点

聊聊并发(五)——线程池

  在使用线程时,需要new一个,用完了又要销毁,这样频繁的创建和销毁很耗资源,所以就提供了线程池。道理和连接池差不多,连接池是为了避免频繁的创建和释放连接,所以在连

接池中就有一定数量的连接,要用时从连接池拿出,用完归还给连接池,线程池也一样。

  线程池:一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。

  脑图:https://www.processon.com/view/link/61849ba4f346fb2ecc4546e5

  线程池用法很简单, 分为三步。首先用工具类Executors创建线程池,然后给线程池分配任务,最后关闭线程池就行了。

  注意:线程用完,要关闭线程池,否则程序依然在运行中。

  JDK 5.0 起提供了线程池相关API:顶级接口Executor,及子接口 ExecutorService 和工具类Executors。

  JUC包描述:图片来源API文档

  Executors:工具类,线程池的工厂类,用于创建并返回不同类型的线程池。

  ExecutorService:

  代码示例:创建固定 5 个线程的线程池为 10 个任务服务。

  可以看到,银行 5 个窗口为 10 个客户相继服务。若前面服务时间长(打开注释),线程池便没有新的线程来执行任务了。程序会陷入等待中。

  代码示例:创建单个线程的线程池为 10 个线程服务。代码同上,只修改:

  代码示例:创建可扩容线程的线程池为 10 个线程服务。代码同上,只修改:

  为什么要用线程池管理线程呢?当然是为了线程复用。

  背景:经常创建和销毁、使用量特别大的资源,比如并发情况下的线程,对性能影响很大。

  思路:提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁的创建和销毁,实现重复利用。类似生活中的公共交通工具。

  好处:提高响应速度(减少了创建新线程的时间);降低资源消耗(重复利用线程池中线程,不需要每次都创建);便于线程管理。

  前面介绍了三种(固定数、单一的、可变的)创建线程池的方式,实际工作用哪一个呢?都不使用!为什么呢?

  《阿里巴巴Java开发手册》明确规定:线程池不允许使用Executors创建,而是通过ThreadPoolExecutor的方式,规避资源耗尽风险。

  查看源码,可以看到,用Executors创建线程池的三种方式中,都 new 了一个 ThreadPoolExecutor。所以,实际生产一般通过 ThreadPoolExecutor 的 7 个参数,自定义线程池。

  源码示例:7 个参数的构造器。

  介绍线程池之前,先来看一个生活中的案例。银行业务办理流程,如图:

  某银行一共有 5 个服务窗口,但平时一般只开放两个,另外三个不开放。大厅中还有 10 个等待服务的座位。某天:

  (1)客人1(用Thread1表示)来办理业务,他就直接去开放的窗口1办理(假设他需要服务的时间很长,一直在服务中,后面的也一样)。

  (2)Thread2来办理业务,由于窗口1在服务中,所以他去了开放的窗口2办理。

  (3)Thread3来办理业务,由于窗口1和窗口2都在服务中,所以他去了大厅的等待服务座位上排队等待。

  (4)Thread4~Thread12 同理Thread3。

  (5)Thread13来办理业务,由于窗口1和窗口2都在服务中,且此时大厅的等待座位上也已满。银行经理便将关闭的窗口3打开来为Thread13服务。注意:这里并不是Thread13去大厅排队,然后队列中队头元素Thread3出队接受服务。而是直接为Thread13服务。

  (6)Thread14,Thread15来办理业务,会开放窗口4为Thread14服务,开放窗口5为Thread15服务。

  (7)Thread16来办理业务,此时,已无可用窗口,且大厅的等待座位上也已满。银行便拒绝再为 Thread16 服务。

  说明:若 Thread13、Thread14、Thread15 业务办理完毕后,没有新的客人来银行办理业务。那么窗口3、窗口4、窗口5会在一定时间后又关闭起来。

  下面介绍 ThreadPoolExecutor 构造器中的 7 个核心参数。

  corePoolSize:线程池的核心线程数。

  maximumPoolSize:线程池的最大线程数,要大于corePoolSize。

  keepAliveTime:非核心线程闲置下来最多存活的时间。

  unit:线程池中非核心线程保持存活的时间单位,与keepAliveTime一起使用。

  workQueue:用来保存提交后,等待执行任务的阻塞队列。

  threadFactory:创建线程的工厂类。

  handler:拒绝策略。

  在理解上一节"银行服务"的过程后,就不难理解上面 7 个参数的含义。

  corePoolSize = 2:窗口1 + 窗口2。

  maximumPoolSize = 5:窗口1 + 窗口2 + 窗口3 + 窗口4 + 窗口5。

  workQueue = 10:银行大厅排队队列的大小。关于阻塞队列 BlockingQueue<Runnable> workQueue 请看这篇。

  keepAliveTime + unit:"窗口3、窗口4、窗口5会在一定时间后又关闭起来"的时间。

  handler:"银行便拒绝再为 Thread16 服务"的拒绝方式。

  在了解 ThreadPoolExecutor 7个核心参数的作用后,再看Executors创建的三种线程池的源码,就不难理解他们的作用。也就明白为什么《阿里巴巴Java开发手册》中禁止使用Executors创建,而要使用ThreadPoolExecutor自定义线程池。

  源码示例:

  一池N线程:创建一个固定(可重用)线程数的线程池。

  一池一线程:创建一个只有一个线程的线程池。

  可扩容:创建一个可根据需要线程数,创建新的线程的线程池。

  在理解"银行服务"的过程后,其实也就说清楚了线程池的工作流程。只是一些细节没有说,比如:

  (1)窗口3为Thread13服务完成后,Thread14才来,情况如何?

  (2)……

  代码示例:银行 2+3 个窗口为陆续来的 16 个客户服务。

  其他的情况,可通过修改代码示例中相关参数进行测试,自然就理解。

  线程在Java中属于稀缺资源,线程池不是越大越好,也不是越小越好。那么,线程池的参数要如何设置才合理呢?

  任务分为CPU密集型、IO密集型、混合型。

  CPU密集型:大部分都在用CPU跟内存,加密,逻辑操作,业务处理等。

  IO密集型:数据库链接,网络通讯传输等。

  CPU密集型:一般推荐线程池不要过大,一般是CPU数 + 1,+1是因为可能存在页缺失(就是可能存在有些数据在硬盘中需要多来一个线程将数据读入内存)。如果线程池数太大,可能会频繁的进行线程上下文切换跟任务调度。

  获得当前CPU核心数代码如下:

  IO密集型:线程数适当大一点,机器的CPU核心数*2。

  混合型:可以考虑根绝情况将它拆分成CPU密集型和IO密集型任务,如果执行时间相差不大,拆分可以提升吞吐量,反之没有必要。

  当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,就会调用这个接口里的这个方法。也就是"银行便拒绝再为 Thread16 服务"的拒绝方式。

  ThreadPoolExecutor 提供了四种拒绝策略,分别是

  AbortPolicy:直接抛出异常,这也是默认策略。

  CallerRunsPolicy:返回给调用者处理。用调用者所在线程来运行任务。

  DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务。

  DiscardPolicy:不处理,直接丢弃当前任务。

  代码示例:4种拒绝策略,代码同上,只需修改:

  通过debug断点的方式,可以查看到:DiscardOldestPolicy策略中,此时阻塞队列中是客户4~客户16。也就是客户3 出队,被抛弃,客户16入队等待。

  如果不使用线程池提供的4种拒绝策略,也可以自己实现拒绝策略的接口,实现对这些超出数量的任务的处理。比如:为被拒绝的任务开启一个新的线程执行,如下。

  参考文档:https://www.matools.com/api/java8

  《阿里巴巴Java开发手册》百度网盘:https://pan.baidu.com/s/1aWT3v7Efq6wU3GgHOqm-CA    密码: uxm8

作者:Craftsman-L

出处:https://www.cnblogs.com/originator

本博客所有文章仅用于学习、研究和交流目的,版权归作者所有,欢迎非商业性质转载。

如果本篇博客给您带来帮助,请作者喝杯咖啡吧!点击下面打赏,您的支持是我最大的动力!