一文搞懂Java线程池

一、创建线程的方式

1 继承 Thread 类并重写 run 方法。实现简单,但不符合里氏替换原则,不可以继承其他类。步骤:

(1)继承Thread类并重写run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。

(2)创建线程对象并调用start方法进行启动

2 实现 Runnable 接口并重写 run 方法。避免了单继承局限性,编程更加灵活,实现解耦。步骤:

(1)实现Runnable接口并重写run方法

(2)创建线程对象并调用start方法进行启动

**3 实现 Callable 接口并重写 call 方法。**可以获取线程执行结果的返回值,并且可以抛出异常。步骤:

(1)定义一个类实现Callable接口,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。

(2)创建线程对象,使用FutureTask类来包装Callable对象,并调用start方法进行启动 FutureTask ft = new FutureTask<>(mc);

(3)调用FutureTask对象的get()方法来获得子线程执行结束后的返回值

4 使用 Executors 工具类创建线程池

扫描二维码关注公众号,回复: 13156023 查看本文章

二、为什么要有线程池

想想我们之前没用线程池的时候,每次创建线程都是:new Thread(() -> {...}),再调start()方法来执行线程。这就会带来一系列问题,比如:线程的创建和销毁都是很耗时很浪费性能的操作。再者,简单的new两三个Thread还好,但若需要上百个线程呢?而且用完再销毁掉时又要一个一个的进行销毁。那这上百个线程的创建和销毁的性能是很糟糕的!

线程池诞生就是为了解决上述问题。其核心思想就是:线程复用。也就是说线程用完后不销毁,放到池子里等着新任务的到来,反复利用N个线程来执行所有新老任务。这带来的开销只会是那N个线程的创建,而不是每来一个请求都带来一个线程的从生到死的过程。

因此使用线程池的好处与优点:

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

  • 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。

  • 提高线程的可管理性。使用线程池可以对线程进行统一的分配,调优和监控。

三、创建线程池的方法

3.1 七大参数

可以通过 Executors 的静态工厂方法创建线程池:

不建议使用Executors来创建,而是使用ThreadPoolExceutor的方式,这样的处理⽅式让写的同学更加明确线程池的运⾏规则,规避资源耗尽的⻛险。

上源码(有七大参数):

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

corePoolSize:核心线程数

默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务。核⼼线程数定义了最⼩可以同时运⾏的线程数量。当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到工作队列当中。默认不会被回收掉,但是如果设置了allowCoreTimeOut为true,那么当核心线程闲置时,也会被回收。

maximumPoolSize:最大线程数

当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。如果与核心线程数设置相同代表固定大小线程池。

workQueue:工作队列

当线程请求数大于等于 corePoolSize 时线程会进入工作队列。阻塞队列,用来存储等待执行的任务,新任务被提交后,会先进入到此工作队列中,任务调度时再从队列中取出任务。这里的阻塞队列有以下几种选择:

  • ArrayBlockingQueue:基于数组的有界阻塞队列,按FIFO排序;

  • LinkedBlockingQueue:基于链表的无界阻塞队列(其实最大容量为Interger.MAX),按照FIFO排序;

  • SynchronousQueue:一个不缓存任务的阻塞队列,也就是说新任务进来时,不会缓存,而是直接被调度执行该任务;

  • PriorityBlockingQueue:具有优先级的无界阻塞队列,优先级通过参数Comparator实现。

unit:单位,keepAliveTime 的时间单位。比如:TimeUnit.MILLISECONDSTimeUnit.SECONDS

keepAliveTime:线程空闲时间

线程空闲时间达到该值后会被销毁,直到只剩下 corePoolSize 个线程为止,避免浪费内存资源。

threadFactory:线程工厂

当线程池需要新的线程时,会用threadFactory来生成新的线程

默认采用的是DefaultThreadFactory,主要负责创建线程。newThread()方法。创建出来的线程都在同一个线程组且优先级也是一样的。即用来生产一组相同任务的线程。可以给线程命名,有利于分析错误。

handler:拒绝策略

  • AbortPolicy 丢弃任务并抛出异常;

**功能:**当触发拒绝策略时,直接抛出拒绝执行的异常,中止策略的意思也就是打断当前执行流程

**使用场景:**这个就没有特殊的场景了,但是一点要正确处理抛出的异常。

ThreadPoolExecutor中默认的策略就是AbortPolicy,ExecutorService接口的系列ThreadPoolExecutor因为都没有显示的设置拒绝策略,所以默认的都是这个。但是请注意,ExecutorService中的线程池实例队列都是无界的,也就是说把内存撑爆了都不会触发拒绝策略。当自己自定义线程池实例时,使用这个策略一定要处理好触发策略时抛的异常,因为他会打断当前的执行流程。

  • CallerRunsPolicy 调用执行自己的线程运行任务。但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应用程序可以承受此延迟并且你不能丢弃任何一个任务请求的话,你可以选择这个策略;

    功能:当触发拒绝策略时,只要线程池没有关闭,就由提交任务的当前线程处理。

    使用场景:一般在不允许失败的、对性能要求不高、并发量较小的场景下使用,因为线程池一般情况下不会关闭,也就是提交的任务一定会被运行,但是由于是调用者线程自己执行的,当多次提交任务时,就会阻塞后续任务执行,性能和效率自然就慢了。

  • DiscardOldestPolicy 表示抛弃队列里等待最久的任务并把当前任务加入队列;

    **功能:**直接静悄悄的丢弃这个任务,不触发任何动作

    **使用场景:**如果你提交的任务无关紧要,你就可以使用它 。因为它就是个空实现,会悄无声息的吞噬你的的任务。所以这个策略基本上不用了

  • DiscardPolicy 表示直接抛弃当前任务但不抛出异常。

    **功能:**如果线程池未关闭,就弹出队列头部的元素,然后尝试执行

    **使用场景:**这个策略还是会丢弃任务,丢弃时也是毫无声息,但是特点是丢弃的是老的未执行的任务,而且是待执行优先级较高的任务。基于这个特性,我能想到的场景就是,发布消息,和修改消息,当消息发布出去后,还未执行,此时更新的消息又来了,这个时候未执行的消息的版本比现在提交的消息版本要低就可以被丢弃了。因为队列中还有可能存在消息版本更低的消息会排队执行,所以在真正处理消息的时候一定要做好消息的版本比较。

3.2 示例

image-20211005160838978.png

3.3 内置封装好的的几个线程池

可以这样来创建:ExecutorService MyExecutorService = Executors.newCachedThreadPool();

  • newFixedThreadPool,固定大小的线程池,核心线程数也是最大线程数,不存在空闲线程,keepAliveTime = 0。该线程池使用的工作队列是无界阻塞队列 LinkedBlockingQueue,适用于负载较重的服务器。

  • newSingleThreadExecutor,使用单线程,相当于单线程串行执行所有任务,适用于需要保证顺序执行任务的场景。与单线程性能比较:虽然同是一个线程在工作,但是使用单线程池效率高多了。

  • newCachedThreadPool,该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

  • newScheduledThreadPool:支持定期及周期性任务执行,适用需要多个后台线程执行周期任务,同时需要限制线程数量的场景。与 newCachedThreadPool 的区别是不回收工作线程。原理:ScheduedThreadPoolExecutor是先把任务放到一个DelayQueue延迟队列中,然后再启动一个线程,再去队列中取周期时间离当前时间最近的那个任务。ScheduedThreadPoolExecutor维护了一个DelayQueue存储等待的任务,DelayQueue里面有一个PriorityQueue优先级队列,他会根据time的时间大小排序,时间越小的越靠前。DelayQueue也是一个无界队列,但是初始大小为16,超过16会进行一次扩容。有三种提交任务的方式:

    • schedule,特定时间延时后执行一次任务
    • scheduledAtFixedRate,固定周期执行任务(与任务执行时间无关,周期是固定的)
    • scheduledWithFixedDelay,固定延时执行任务(与任务执行时间有关,延时从上一次任务完成后开始)

四、线程池处理任务的流程

① 核心线程池未满,创建一个新的线程执行任务,此时 workCount < corePoolSize,需要获取全局锁。

② 如果核心线程池已满,工作队列未满,将任务存储在工作队列,此时 workCount >= corePoolSize。

③ 如果工作队列已满,线程数小于最大线程数就创建一个新线程处理任务,此时 workCount < maximumPoolSize,这一步也需要获取全局锁。

④ 如果超过最大线程数,按照拒绝策略来处理任务,此时 workCount > maximumPoolSize。

线程池创建线程时,会将线程封装成工作线程 Worker,Worker 在执行完任务后还会循环获取工作队列中的任务来执行。

image-20211005160530675.png

举例说明

线程池参数配置:核心线程5个,最大线程数10个,队列长度为100。

那么线程池启动的时候不会创建任何线程,假设请求进来6个,则会创建5个核心线程来处理五个请求,另一个没被处理到的进入到队列。这时候有进来99个请求,线程池发现核心线程满了,队列还在空着99个位置,所以会进入到队列里99个,加上刚才的1个正好100个。这时候再次进来5个请求,线程池会再次开辟五个非核心线程来处理这五个请求。目前的情况是线程池里线程数是10个RUNNING状态的,队列里100个也满了。如果这时候又进来1个请求,则直接走拒绝策略。

五、线程池的执行与关闭

5.1 执行任务

线程池中 submit()execute() 方法有什么区别?

  • 接收参数:execute()只能执行 Runnable 类型的任务。submit()可以执行 Runnable 和 Callable 类型的任务。

  • 返回值:execute() ⽅法⽤于提交不需要返回值的任务,所以⽆法判断任务是否被线程池执⾏成功与否;submit() ⽅法⽤于提交需要返回值的任务。线程池会返回⼀个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执⾏成功,并且可以通过 Future 的 get() ⽅法来获取返回值, get() ⽅法会阻塞当前线程直到任务完成,⽽使⽤ get(long timeout,TimeUnit unit) ⽅法则会阻塞当前线程⼀段时间后⽴即返回,这时候有可能任务没有执⾏完。

  • 异常处理:submit()方便Exception处理

5.2 关闭线程池

可以调用 shutdown()shutdownNow() 方法关闭线程池,原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法中断线程,无法响应中断的任务可能永远无法终止。

二者区别

  • shutdownNow 首先将线程池的状态设为 STOP,然后尝试停止正在执行或暂停任务的线程,并返回等待执行任务的列表。
  • shutdown 只是将线程池的状态设为 SHUTDOWN,然后中断没有正在执行任务的线程。通常调用 shutdown 来关闭线程池,如果任务不一定要执行完可调用 shutdownNow。

六、线程池线程数如何设计?即线程池线程数与(CPU密集型任务和I/O密集型任务)的关系

CPU密集型: 这种任务一般不占用大量IO,所以后台服务器可以快速处理,压力落在CPU上。

I/O密集型:常有大数据量的查询和批量插入操作,此时的压力主要在I/O上。

  • 与CPU密集型的关系:一般情况下,CPU核心数 == 最大同时执行线程数。在这种情况下(设CPU核心数为n),大量客户端会发送请求到服务器,但是服务器最多只能同时执行n个线程。所以这种情况下,无需设置过大的线程池工作队列,(工作队列长度 = CPU核心数 || CPU核心数+1)即可。

  • 与I/O密集型的关系:由于长时间的I/O操作,导致线程一直处于工作队列,但它又不占用CPU,则此时有1个CPU是处于空闲状态的。所以,这种情况下,应该加大线程池工作队列的长度,尽量不让CPU空闲下来,提高CPU利用率。

一般说来,线程池的大小应该怎么设置(线程池初始的默认核心线程数大小?)(其中 N为CPU的个数 )。

  • 如果是CPU密集型应用,则线程池大小设置为 N+1

  • 如果是IO密集型应用,则线程池大小设置为 2N+1


不足之处请多指正!

猜你喜欢

转载自juejin.im/post/7015502203968454687