Java 线程池简介

178 阅读12分钟

什么是线程池

线程池使用了池化技术的思想。池化技术指,把宝贵的稀缺资源放在一个池子中,每次使用都从里面获取,用完之后再放回池子供其他人使用。

存放一组线程的池子叫线程池,线程池中的线程可以被不同的任务复用。

为什么要引入线程池

计算机系统资源角度

线程池解决的核心问题就是资源管理问题。在并发环境下,系统不能够确定在任意时刻中,有多少任务需要执行,有多少资源需要投入。这种不确定性将带来以下若干问题:

  1. 频繁申请/销毁资源和调度资源,将带来额外的消耗,可能会非常巨大。
  2. 对资源无限申请缺少抑制手段,易引发系统资源耗尽的风险。
  3. 系统无法合理管理内部的资源分布,会降低系统的稳定性。

线程过多会带来额外的开销,其中包括创建销毁线程的开销、调度线程的开销等等,同时也降低了计算机的整体性能。线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。

当不使用线程池,而是频繁地创建线程时,对系统的危害主要有两个方面:

  • 一方面是线程创建和销毁时对系统资源的耗费
  • 另一方面是大量线程同时维持时对系统资源的消耗,此时对资源的占用主要有两个方面
    • 一个是加大在任务切换上的调度开销,减少了每个线程的执行时间
    • 另一个加大了内存占用

软件工程角度

  1. 解耦作用:线程的创建与执行完全分开,方便维护。

使用线程池的好处

  • 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。
  • 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
  • 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行。

创建线程池

创建线程池有两种方式,一种是通过Executors提供的工厂方法创建某种类型的线程池,另一种是使用ThreadPoolExecutor构造函数自定义创建。 Executors的工厂方法内部也是通过调用ThreadPoolExecutor的构造函数创建线程池。

ThreadPoolExecutor构造函数

ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler);

共7个参数,含义如下:

  1. corePoolSize: 核心线程数量,线程池中始终存活的线程数量。核心线程是指即使这些线程空闲,也不会被移出移出线程池销毁,除非allowCoreThreadTimeOut被设置。
  2. maximumPoolSize: 池中允许的最大线程数量。当池中线程数量达到这个限制后,后续添加到池中的任务会
  3. keepAliveTime: 当池中线程数量大于核心线程数量时,非核心线程空闲后,在被销毁前,等待新任务的最长时间
  4. unit: keepAliveTime的单位
  5. workQueue: 一个阻塞队列,用来保存等待执行的任务,均为线程安全。只保存被execute提交的Runnable任务。有7种可选,常用的是LinkedBlockingQueue。
  6. threadFactory: 用来创建新线程的工厂类,默认正常优先级、非守护线程
  7. handler: 任务拒绝策略。当线程池达到最大线程数量且待执行任务队列也满时,使用handler提供的策略拒绝新的任务。4种可选,默认为AbortPolicy。
    • AbortPolicy:拒绝并抛出异常。
    • CallerRunsPolicy:重试提交当前的任务,即再次调用运行该任务的execute()方法。
    • DiscardOldestPolicy:抛弃队列头部(最旧)的一个任务,并执行当前任务。
    • DiscardPolicy:抛弃当前任务。

Executors的4种工厂方法

// 创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程。
// ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
// 无核心线程,池中线程数量不设上限。
// 线程池会接收所有提交的任务,如果当前有空闲线程就使用空闲线程执行,如果没有空闲线程,就创建一个线程执行任务。
// 当线程空闲时间超过1分钟后会被销毁并移出池,直到最后一个空闲线程被移出池。
Executors.newCachedThreadPool();

// 创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待。
// ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
// 核心线程数量和最大线程数量相等
// 当池中线程数量少于核心线程数量,且无空闲线程时,会创建线程执行。
// 当池中线程数量已达到设定核心线程数量,且无空闲线程时,会拒绝这个任务的执行。
Executors.newFixedThreadPool(2);

// 创建一个单线程的线程池,可保证所有任务按照指定顺序(FIFO, LILO, 优先级)执行。
// ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
// newFixedThreadPool的特殊情况,只有一个工作线程
// 当工作线程忙时,新添加的任务会被先添加到任务队列中,任务队列不设上限
// 所有提交的任务会保证被依次执行,同一时间只有一个任务被执行
Executors.newSingleThreadExecutor();

// 创建一个周期性的线程池,支持定时及周期性执行任务。
Executors.newScheduledThreadPool(3);

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

  1. FixedThreadPool和SingleThreadPool:允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
  2. CachedThreadPool和ScheduledThreadPool:允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。

所以不允许使用Executors去创建线程池,而是应该使用ThreadPoolExecutor构造函数的方式,根据自己需要的场景来创建一个合适的线程池,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

使用线程池

如何配置线程池

线程池不是越大越好,通常根据任务的性质来确定:

  • IO密集型任务:由于线程并不是一直在运行,所以可以尽可能的多配置线程,比如 CPU 个数 * 2
  • CPU密集型任务:应当分配较少的线程,比如 CPU 个数相当的大小

如何关闭线程池

关闭线程池有两个方法:shutdown()shutdownNow()

  • shutdown(): 执行后停止接受新任务,会把队列中的任务执行完
  • shutdownNow(): 执行后停止接受新任务,并中断所有任务,将线程池状态变为stop

工作原理

线程池内部分为两部分:任务管理,线程管理。 任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:(1)直接申请线程执行该任务;(2)缓冲到队列中等待线程执行;(3)拒绝该任务。 线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。

线程池状态

线程池有5种状态,由内部管理。

  • RUNNING:运行状态,可以接收新提交的任务,也可以处理阻塞队列中的任务
  • SHUTDOWN:指调用了 shutdown() 方法,不再接受新提交的任务,但是阻塞队列里的任务会执行完毕。
  • STOP:指调用了 shutdownNow() 方法,不再接受新任务,同时抛弃阻塞队列里的所有任务并中断所有正在执行任务。
  • TIDYING:所有任务都执行完毕,在调用 shutdown()/shutdownNow() 中都会尝试更新为这个状态。
  • TERMINATED:终止状态,当执行 terminated() 后会更新为这个状态。

线程池状态.png

任务调度

任务调度是当任务被提交(execute(Runnable))到线程池后,接下来任务如何执行的过程,是线程池的核心运行机制。如下图所示:

  1. 当线程数小于核心线程数时,创建线程。
  2. 当线程数大于等于核心线程数,且任务队列未满时,将任务放入任务队列。
  3. 当线程数大于等于核心线程数,且任务队列已满:
    • 若线程数小于最大线程数,创建线程。
    • 若线程数等于最大线程数,抛出异常,拒绝任务。

线程池任务调度.drawio.png

任务缓冲

任务缓冲模块是线程池能够管理任务的核心部分。线程池的本质是对任务和线程的管理,而做到这一点最关键的思想就是将任务和线程两者解耦,不让两者直接关联,才可以做后续的分配工作。线程池中是以生产者消费者模式,通过一个阻塞队列来实现的。阻塞队列缓存任务,工作线程从阻塞队列中获取任务。

使用不同的队列可以实现不一样的任务存取策略:

  • ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。按照先进先出的原则对任务进行排序
  • LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。按照先进先出的原则对任务进行排序,默认长度为Interger.MAX_VALUE
  • SynchronousQueue:一个不存储元素的阻塞队列,即直接提交给线程不保持它们。
  • PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列,只有在延迟期满时才能从中提取元素。
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。与SynchronousQueue类似,还含有非阻塞方法。
  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

任务申请

由上文的任务分配部分可知,任务的执行有两种可能:一种是任务直接由新创建的线程执行。另一种是线程从任务队列中获取任务然后执行,执行完任务的空闲线程会再次去从队列中申请任务再去执行。第一种情况仅出现在线程初始创建的时候,第二种是线程获取任务绝大多数的情况。

每个工作线程的内部都是一个循环体,循环地从创建线程时的初始任务或阻塞队列中获取任务并执行。具体过程如下:

截屏2022-02-10 下午3.38.26.png

调用execute(Runnable),提交任务后,当核心线程未满或者最大线程数未满时,会调用addWorker(firstTask, core)添加工作线程。

addWorker(firstTask, core),内部主要是创建一个Worker对象。Worker是Runnable的子类,在构造函数中保存firstTask,并创建一个Thread,创建Thread时把当前Worker对象传进去作为参数,即作为这个线程的第一个任务。然后创建启动这个Thread执行,线程执行时执行的任务就是这个Worker对象,所以会执行这个Worker对象的run方法,在run()方法中会调用runWorker(Worker w)方法。

runWorker(Worker w),首先从worker中获取firstTask作为task,作为初始化task,然后启动循环。当task不为空或者getTask()的结果不为空时,就进入循环体,执行这个task。直到task为空且getTask()返回空时结束循环。结束循环后线程空闲,如果非核心线程或者是可注销的核心线程,该线程会注销。

getTask()用于从阻塞队列中获取任务。workQueue是阻塞队列,如果队列中没有元素,take()方法一直阻塞地等待,直到有元素时才返回,poll(keepAliveTime, TimeUnit.NANOSECONDS)方法会等待指定的时间,如果在指定的时间内没有获取到任务,会返回null。获取任务时分为阻塞式获取和定时获取两种,核心线程使用阻塞式获取,一直等待,直到有队列中有任务,非核心线程会等待一定的时间,超时返回null。因为阻塞队列的存在,队列中没有任务时会阻塞地等待,所以获取任务的操作虽然是死循环,但不会一直不停地循环,不会影响性能。

private Runnable getTask() {
   boolean timedOut = false; // Did the last poll() time out?

   for (;;) {
      int c = ctl.get();
      int wc = workerCountOf(c);
      boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
      Runnable r = timed ?
         workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
         workQueue.take();
      if (r != null)
         return r;
      timedOut = true;
   }
}

非核心线程什么时候回收

非核心线程空闲时间达到 keepAliveTime * unit后,会被回收

总结

  1. 线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
  2. 推荐使用ThreadPoolExecutor的构造函数直接创建线程池,这样可以加深对线程池工作原理的理解。

参考