Skip to content

Java并发编程_线程池

概述:

image.png


个人仍然是推崇看一下JavaGuide的博客内容,然后辅助查阅一些资料去学习,同时辅助一些代码编程去理解。

博客地址: https://javaguide.cn/java/concurrent/java-thread-pool-summary.html

可以直接从第二章内容开始看。

1、什么是线程池

使用线程池的好处

类似数据库的连接池,能够有效减少线程的一些不必要的资源损耗(比如创建和销毁);

在高并发情况下,针对一些短期异步任务,建议使用线程池;

可以看一下这个区别:

直接使用线程的情况

  1. 简单或单次任务:对于一些简单的或只需执行一次的多线程任务,直接创建线程可能更为直接和简单。
  2. 精细控制:如果需要对每个线程的创建、启动、运行和销毁过程有更精细的控制,直接使用线程可能更合适。
  3. 特定行为:某些特定的行为可能需要单独的线程,例如长时间运行的监听线程等。

使用线程池的情况

  1. 大量短期任务:对于大量的短期异步任务,使用线程池可以显著减少线程创建和销毁的开销。
  2. 资源管理:线程池可以有效地管理线程资源,避免因为过多线程而导致的资源耗尽问题。
  3. 性能提升:在高负载的应用程序中,线程池可以通过重用现有线程提高性能。
  4. 负载平衡:线程池可以平衡任务负载,确保线程的有效利用。
  5. 任务队列和调度:线程池提供任务队列和调度功能,可以控制任务的执行顺序,同时提供定时执行和周期性执行等功能。

从这个定义中看一般在多线程的情况下,直接使用线程是在特定行为的情况下(监听线程等),其他情况考虑资源消耗和线程管理,建议使用线程池(尤其是在执行大量短期异步任务时)。

基本介绍

看一下 Java 线程池 的一些基本定义和介绍:

Java中的线程池是一种执行任务的框架,它用于减少在创建和销毁线程时所花费的时间和资源。线程池在执行大量短期异步任务时尤其有用,因为它可以显著提高程序性能。

在Java中,线程池主要是通过java.util.concurrent包中的Executor框架实现的。

线程池的关键概念

  1. 线程池(ThreadPool):线程池是一组工作线程,它们负责从任务队列中获取并执行任务。
  2. 任务(Task):提交给线程池的工作单元,通常是实现了RunnableCallable接口的对象。
  3. Executor框架:这个框架提供了管理线程池的工具,包括创建线程池、提交任务和管理线程池的生命周期。

线程池的主要类型

  1. FixedThreadPool:拥有固定数量线程的线程池。如果所有线程都在工作,新任务将在队列中等待。
  2. CachedThreadPool:一个可以根据需要创建新线程的线程池,但如果线程在一定时间内未使用,将被回收。
  3. SingleThreadExecutor:只有一个线程的线程池,它保证所有任务按照提交的顺序依次执行。
  4. ScheduledThreadPool:一个能够安排任务在给定延迟后执行,或定期执行的线程池。

使用线程池的好处

  1. 提高资源利用率:通过重用现有线程减少线程创建和销毁的开销。
  2. 提高响应速度:任务不需要等待线程创建就可以立即执行。
  3. 提供更好的线程管理:可以根据系统的资源情况调整线程池的大小。
  4. 提供任务队列和执行策略:可以根据需求管理和优化任务的执行顺序。

如何使用

以下是一个使用ExecutorService创建固定大小线程池的简单示例:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 创建拥有固定数量线程的线程池
        ExecutorService executor = Executors.newFixedThreadPool(4);

        // 提交任务给线程池
        for (int i = 0; i < 10; i++) {
            Runnable task = new Task();
            executor.execute(task);
        }

        // 关闭线程池
        executor.shutdown();
    }

    static class Task implements Runnable {
        @Override
        public void run() {
            // 任务代码
            System.out.println("Executing task in " + Thread.currentThread().getName());
        }
    }
}

注意事项

  • 在使用完线程池后,应该调用shutdown()方法来关闭线程池,否则程序可能不会终止。

应当合理配置线程池的大小,过大的线程池可能会消耗过多资源,过小则无法充分利用系统资源。

  • 在处理IO密集型任务时,可以配置比CPU核心数更多的线程,因为IO操作不会一直占用CPU。
  • 使用线程池时,应该注意任务提交和执行过程中可能出现的异常,并合理处理这些异常。
  • 对于定时或周期性任务,ScheduledThreadPoolExecutor提供了灵活的调度选项。

总之,线程池在Java多线程编程中是一个非常重要的组件,它不仅能提高程序性能,还能提供更好的资源管理和更简洁的编程模型。正确使用线程池对于构建高效、稳定且可伸缩的Java应用至关重要。

这个例子感觉并不是很好,这里是使用 Executors 工具类来进行创建的一个线程池内部类。

在 JavaGuide 中提及到,阿里是建议大家都使用 通过ThreadPoolExecutor构造函数来创建 线程池,使用工具类 Executors 来创建的话,容易造成资源耗时(如果使用不当)。

这里的 ThreadPool 和 Task 不用太过于理解,你可以辅助去理解 Executor 框架的一个运行,通过它去引申一些内容,从而对整个线程池的内容有更好的使用和理解。

因此这一篇文章的内容第一章节的内容可以跳过看,并没有太重要。

扩展的一些内容概念:

ThreadPool

ThreadPool(线程池)在Java中是通过java.util.concurrent包中的Executor框架实现的。

再介绍一些基本概念

  1. Executor和ExecutorService
    • Executor是基础的接口,它提供了提交任务的方法。
    • ExecutorService是更完整的接口,它继承自Executor,提供了更复杂的功能,如管理线程池的生命周期。
  2. 任务类型
    • Runnable:一个不返回结果的任务。
    • Callable:一个可以返回结果的任务。

ThreadPool的一些基本用法:

  1. 创建线程池
    • 你可以使用Executors类中的静态工厂方法来创建不同类型的线程池,如newFixedThreadPoolnewCachedThreadPoolnewSingleThreadExecutor等。
  2. 提交任务
    • 使用execute(Runnable)方法提交不返回结果的任务。
    • 使用submit(Callable)submit(Runnable)方法提交返回结果的任务。
  3. 关闭线程池
    • 使用shutdown()方法来关闭线程池。这个方法不会立即关闭线程池,而是不再接受新任务,等待所有已提交的任务执行完毕后关闭。
    • 如果需要立即关闭线程池,可以使用shutdownNow(),但这会尝试停止正在执行的任务,并返回等待执行的任务列表。

Task

Task是一个用于执行具有返回值的异步计算的抽象概念。

在线程池这里主要是要引入java.util.concurrent包中的Callable接口与Future类;

他们两个的使用代表着Task的核心概念。

Callable

Callable是一个接口,与Runnable相似,但它可以返回一个结果,并能抛出异常。Callable通常用于那些需要返回结果的任务。

Callable<Integer> task = () -> {
    // 执行一些计算
    return 123;
};
Future

当你提交一个Callable任务给线程池执行时,你会得到一个Future对象。

Future代表了异步计算的结果,它提供了检查计算是否完成的方法,并且能够获取计算的结果。

ExecutorService executor = Executors.newFixedThreadPool(1);
Future<Integer> future = executor.submit(task);

具体可以看一下实际使用,能够对这两个概念有比较明确的理解。

Executor 框架 的内容可以看下一章节的内容。

2、Executor 框架介绍

在Java中,线程池主要是通过java.util.concurrent包中的Executor框架实现的。

基本概念

这个框架的作用就是简化并发编程,提供了线程池的实现。

介绍:

在Java中,Executor是一个接口,它代表了执行提供的任务的对象。它是java.util.concurrent包中的一部分,用于提供一种将任务的提交与每个任务的执行方式分离的机制。

在 JavaGuide 提及到 Executor 框架能够避免 this 逃匿,目前不是太了解这个概念,有兴趣可以了解下。

Executor 框架是 Java5 之后引进的,在 Java 5 之后,通过 Executor 来启动线程比使用 Threadstart 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。

this 逃逸是指在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。

下面这个是比较重点的一个内容,可以重点看一下(直接引入了JavaGuide 的一些内容进行阐述)

Executor 框架 三大部分组成

  • 任务(Runnable /Callable)
    • 执行任务需要实现的 Runnable 接口Callable接口
    • Runnable 接口Callable 接口 实现类都可以被 ThreadPoolExecutorScheduledThreadPoolExecutor 执行。
  • 任务的执行(Executor)
  • 异步计算的结果(Future)
    • Future 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。
    • 当我们把 Runnable接口Callable 接口 的实现类提交给 ThreadPoolExecutorScheduledThreadPoolExecutor 执行。(调用 submit() 方法时会返回一个 FutureTask 对象)

image.png

任务的执行(Executor)

如下图所示,包括任务执行机制的核心接口 Executor ,以及继承自 Executor 接口的 ExecutorService 接口。

ThreadPoolExecutorScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService 接口

其中我们可以将更多的一个注意力放在 ThreadPoolExecutor 这个类中。

image.png

Executor框架使用流程

使用流程如下:

  1. 创建任务对象

首先,您需要创建一个任务对象。这可以是实现了Runnable接口的类,或者是实现了Callable接口的类。RunnableCallable的区别在于:

  • Runnable:不返回结果,也不能抛出经过检查的异常。
  • Callable:可以返回结果,并且能抛出异常。
Runnable runnableTask = () -> {
    // 执行一些操作
};

Callable<String> callableTask = () -> {
    // 执行一些操作,并返回结果
    return "Result";
};
  1. 创建ExecutorService并提交任务

然后,您需要创建一个ExecutorService,它是一个线程池服务,用于管理和执行任务。您可以通过Executors工具类创建不同类型的ExecutorService(不推荐)。

提交任务有两种方式:

  • execute(Runnable command) :用于提交不需要返回结果的Runnable任务。
  • submit(Runnable task) 或 submit(Callable< T > task) :用于提交RunnableCallable任务。这将返回一个Future对象,您可以用它来检查任务是否完成,并获取Callable任务的结果。
ExecutorService executorService = Executors.newFixedThreadPool(4);

// 使用execute方法提交Runnable任务
executorService.execute(runnableTask);

// 使用submit方法提交Callable任务,并获取Future对象
Future<String> future = executorService.submit(callableTask);
  1. 获取任务结果

如果您提交的是Callable任务并使用了submit方法,您将获得一个Future对象。您可以通过这个对象获取任务的结果:

  • get()方法:调用get()会阻塞当前线程直到任务完成,并返回结果。
  • cancel(boolean mayInterruptIfRunning):如果需要,您可以取消任务的执行。
try {
    String result = future.get(); // 等待任务完成并获取结果
} catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
}
  1. 关闭ExecutorService

最后,当您不再需要线程池时,应该关闭ExecutorService。这可以防止新任务被提交,并允许已提交的任务完成。

executorService.shutdown();

总结

使用Executor框架的流程大致如下:

  1. 创建实现RunnableCallable接口的任务对象。
  2. 创建ExecutorService并通过executesubmit提交任务。
  3. (可选)如果使用了submit提交Callable任务,可以通过返回的Future对象获取结果。
  4. 关闭ExecutorService

这种方式的好处在于它提供了一种灵活且强大的机制来管理线程和执行并发任务,同时也简化了线程的使用和资源管理。

通过使用线程池,可以避免创建过多的线程,从而提高应用程序的性能和响应速度。

扩充概念

Executors
  1. Executor接口
    • Executor接口定义了一个execute(Runnable command)方法,用于在未来某个时间执行给定的命令(任务)。
  2. ExecutorService接口
    • ExecutorService是一个更复杂的接口,继承自Executor。它提供了更多的功能,如任务提交、关闭线程池、跟踪任务的完成等。
  3. Executors工具类
    • Executors是一个工具类,提供了用于创建不同类型线程池的工厂方法。
线程池类型
  1. FixedThreadPool
    • 创建一个固定大小的线程池。当所有线程都在活动时,新任务将在队列中等待。
  2. CachedThreadPool
    • 创建一个可根据需要创建新线程的线程池。如果线程在一定时间内空闲,则会被回收。
  3. SingleThreadExecutor
    • 创建一个单线程的执行器,它用唯一的工作线程来执行任务,确保所有任务按照提交顺序依次执行。
  4. ScheduledThreadPool
    • 创建一个线程池,它可以安排命令在给定的延迟后运行,或者定期执行。

这种类型记忆内容建议使用思维导图 + 小黄鸭的方式记忆,并且定期回顾。

3、ThreadPoolExecutor 类使用 ★

线程池实现类 ThreadPoolExecutor 是 Executor 框架最核心的类。

构造方法

ThreadPoolExecutor 类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生。

/**
* 用给定的初始参数创建一个新的ThreadPoolExecutor。
*/
public ThreadPoolExecutor(int corePoolSize,     //线程池的核心线程数量
                          int maximumPoolSize,  //线程池的最大线程数
                          long keepAliveTime,   //当线程数大于核心线程数时,多余的空闲线程存活的最长时间
                          TimeUnit unit,        //时间单位
                          BlockingQueue<Runnable> workQueue, //任务队列,用来储存等待执行任务的队列
                          ThreadFactory threadFactory,       //线程工厂,用来创建线程,一般默认即可
                          RejectedExecutionHandler handler  //拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
						  ){
    // 参数检查:核心线程数不能小于0,最大线程数不能小于或等于0,最大线程数不能小于核心线程数,keepAliveTime不能小于0
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)
        throw new IllegalArgumentException();

    // 参数检查:工作队列、线程工厂和拒绝策略不能为null
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();

    // 获取安全管理器的上下文,这是Java安全模型的一部分
    this.acc = System.getSecurityManager() == null ?
            null :
            AccessController.getContext();

    // 设置线程池的核心线程数
    this.corePoolSize = corePoolSize;

    // 设置线程池的最大线程数
    this.maximumPoolSize = maximumPoolSize;

    // 设置工作队列
    this.workQueue = workQueue;

    // 设置线程保持活动的时间,单位转换为纳秒
    this.keepAliveTime = unit.toNanos(keepAliveTime);

    // 设置线程工厂,用于创建新线程
    this.threadFactory = threadFactory;

    // 设置拒绝策略,当线程池不能接受任务时使用
    this.handler = handler;
}
参数说明

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
  • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor其他常见参数 :

  • keepAliveTime:线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁。
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会用到。
  • handler :饱和策略。

image.png

饱和策略

ThreadPoolExecutor的饱和策略(RejectedExecutionHandler)是一种用于处理在任务队列已满且当前运行的线程数量达到最大线程数量时提交的新任务的机制。

这些策略决定了线程池在饱和状态下如何响应新提交的任务。下面是ThreadPoolExecutor中常用的几种饱和策略:

    1. AbortPolicy
    • 这是默认的饱和策略。当线程池和队列都满时,新提交的任务将被拒绝,并抛出RejectedExecutionException异常。这种策略直接反馈执行失败,适用于那些希望立即知道任务无法执行的场景。
    1. CallerRunsPolicy
    • 在这种策略下,如果线程池饱和,那么提交任务的线程自己会执行该任务。这意味着任务将在execute方法的调用线程中运行。这种策略不会抛出异常,但可能会降低新任务的提交速度,从而影响整体性能。这适用于希望确保所有任务都被执行的场景,即使这可能导致原始任务提交线程的性能下降。
    1. DiscardPolicy
    • 此策略将静默丢弃被拒绝的任务,不抛出异常,也不提供任何警告。这可能会导致一些任务的丢失,因此使用这种策略时需要小心。
    1. DiscardOldestPolicy
    • 当线程池饱和时,这种策略将丢弃最早提交但尚未处理的任务,然后尝试重新提交新的任务。这种策略适用于那些可以接受处理最新任务为优先的场景

使用说明:

当使用Spring的ThreadPoolTaskExecutor或直接构造ThreadPoolExecutor时,如果没有指定RejectedExecutionHandler,则默认使用AbortPolicy。这意味着当线程池饱和时,新任务将被拒绝并抛出异常。对于需要可伸缩性的应用程序,可以考虑使用CallerRunsPolicy,因为它能够保证任务被执行,尽管这可能会降低任务提交的速度。

在选择饱和策略时,需要考虑应用程序的特定需求和行为。

比如,对于那些不能丢失任何任务的应用,CallerRunsPolicy可能是更好的选择。而对于那些可以容忍任务丢失的情况,DiscardPolicyDiscardOldestPolicy可能更合适。

线程池创建的两种方式

ThreadPoolExecutor构造函数

方式一:通过ThreadPoolExecutor构造函数来创建(推荐)

Executors工具类

方式二:通过 Executor 框架的工具类 Executors 来创建。

我们可以创建多种类型的 ThreadPoolExecutor

  • FixedThreadPool:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor 该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  • CachedThreadPool 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
  • ScheduledThreadPool:该返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池。

不建议使用第二种,原因如下:

《阿里巴巴 Java 开发手册》强制线程池不允许使用 Executors 去创建,

而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险

Executors 返回线程池对象的弊端如下:

  • FixedThreadPoolSingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
  • ScheduledThreadPoolSingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

这个弊端放后面再了解一下。

to be contined...

4、线程池原理分析

看博客内容: https://javaguide.cn/java/concurrent/java-thread-pool-summary.html#线程池原理分析-重要

5、线程池最佳实践

参考: https://javaguide.cn/java/concurrent/java-thread-pool-best-practices.html


参考