Java并发(六)线程池入门

在我的Java并发学习笔记专栏的前五篇文章中,讲述了关于Java锁机制、乐观锁和悲观锁以及AQS、Reentrantlock、volatile关键字、ThreadLocal类、ConcurrentHashMap等关于Java并发的内容。

本篇将讲述Java的JUC包中开发常用的线程池,包括线程池的优点、核心参数、拒绝策略等。

文章目录

  • 线程池是什么
  • 使用线程池有什么好处
  • 线程池使用示例
  • ThreadPoolExecutor
  • ThreadPoolExecutor中的7个常数和5种状态
  • ThreadPoolExecutor构造方法中的核心参数
  • BlockingQueue工作队列类型
  • ThreadPoolExecutor构造方法使用举例
  • ScheduledThreadPoolExecutor
  • ThreadPoolExecutor中的拒绝策略
  • 线程池大小的确定
  • 线程池中的几种常见对比


线程池是什么

线程的使用过程需要经历三个阶段:创建 → 运行 → 销毁。其中,线程的创建和销毁这两个步骤是比较消耗资源而影响性能的,尤其是在大规模的并发场景下。

每次创建一个线程运行一个任务后销毁它,不免觉得有点浪费。那可不可以创建线程后并不断复用它来运行多个线程任务,进而优化性能?于是我们引入了线程池

下面引用JavaGuide中对线程池的介绍:

线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。

简单来说,线程池是一个充满了线程的池子,当我们要执行一个任务的时候,就会从线程池中取出一个线程来执行,任务执行完了后就把线程还回线程池,进而起到复用线程的作用,减少了线程的创建和销毁。

 

使用线程池有什么好处

《Java 并发编程的艺术》提到的使用线程池的好处可以归为3类:

  • 减少开销:重复利用已创建的线程,减少了线程的创建和销毁所带来的开销
  • 提高速度:当任务到达时,无需创建线程来执行,直接使用线程池中的线程即可立即执行
  • 便于管理:如果无限制创建,会消耗系统资源,降低系统稳定性。使用线程池可以对线程进行统一的分配、调优、监控等管理。

线程池使用示例

先创建一个任务类,打印当前线程的名字,然后sleep一秒钟:

class MyTask implements Runnable {
    int i;
    public MyTask(int i) {
        this.i = i;
    }
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " --> " + i);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

我们先使用CachedThreadPool来尝试运行100次该任务:

public class Sample {
    public static void main(String[] args) {
        ExecutorService e = Executors.newCachedThreadPool();
        for (int i = 0; i < 100; i++) e.execute(new MyTask(i));
    }
}

运行结果的部分截图:
Java并发(六)线程池入门_第1张图片
可以看到线程编号最大来到了100,也就是说线程池创建了100个线程,这是由于sleep导致前面运行的线程没来得及执行完任务后回池子。

接下来我们使用FixedThreadPool来尝试运行100次该任务,newFixedThreadPool方法参数为10:

public class Sample {
    public static void main(String[] args) {
        ExecutorService e = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 100; i++) e.execute(new MyTask(i));
    }
}

运行结果的部分截图:
Java并发(六)线程池入门_第2张图片
运行结果每隔1秒刷新一次,线程编号最大只有10次。可以发现其实线程池中最多只有10个线程,同时不断复用这些线程,一旦它们执行完了一个任务,就会立刻接着去执行下一个任务。

接下来我们使用SingleThreadExecutor来尝试运行100次该任务:

public class Sample {
    public static void main(String[] args) {
        ExecutorService e = Executors.newSingleThreadExecutor();
        for (int i = 0; i < 100; i++) e.execute(new MyTask(i));
    }
}

运行结果的部分截图:
Java并发(六)线程池入门_第3张图片
可以看到这种情况下只有一个线程在执行任务。

接下来我们分别点开newCachedThreadPoolnewFixedThreadPoolnewSingleThreadExecutor方法查看源码,发现其实它们都是调用了ThreadPoolExecutor构造方法,填入特定参数,以此返回了特定的ThreadPoolExecutor对象。
Java并发(六)线程池入门_第4张图片
Java并发(六)线程池入门_第5张图片
Java并发(六)线程池入门_第6张图片
ThreadPoolExecutor是什么呢?为什么对它的构造方法传入不同的参数就能得到具有不同功能的线程池呢?我们下面就来看看ThreadPoolExecutor这个类。

 

ThreadPoolExecutor

宏观上看,开发者将任务提交给ThreadPoolExecutor,然后ThreadPoolExecutor分配工作线程来执行任务,任务执行完成后,工作线程会回到ThreadPoolExecutor中等待后续任务的分配。

继承关系(箭头表示继承):
ThreadPoolExecutor → AbstractExecutorService → ExecutorService → Executor

在《阿里巴巴Java开发手册》“并发处理”这一章节中提到,线程资源必须通过线程池提供,而不允许在应用中自行显式创建线程。原因是:

使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

 

ThreadPoolExecutor中的7个常数和5种状态

在ThreadPoolExecutor类中可以看到有以下7个常数:

private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;

这几个常数代表什么呢?为什么要这么设计?

先看下面五个常数的名称,它们分别代表ThreadPoolExecutor不同的状态:

  • RUNNING:接受新任务,也能处理阻塞队列里的任务
  • SHUTDOWN:不接受新任务,但是处理阻塞队列中的任务
  • STOP:不接受任务,不处理阻塞队列中的任务,中断正在处理过程中的任务
  • TIDYING:当所有的任务都执行完了,当前线程池已经没有工作线程,这时线程池就会转换为TIDYING状态,并且将要调用terminated方法
  • TERMINATED:terminated方法调用完成

五个状态之间的转换图如图:
Java并发(六)线程池入门_第7张图片
我们发现在上面的常量当中,先设置了COUNT_BITS,值为Integer.SIZE - 3,一个int类型占用4个字节,也即是32位,所以Integer.SIZE的大小是32,则COUNT_BIT大小是32 - 3 = 29。

在下面表示五个状态的常量使用个位数左移COUNT_BITS位后的做过来表示,这是什么目的?其实这是因为ThreadPoolExecutor使用一个int变量来记录线程池状态和工作线程数这两个信息,线程池状态记录在int变量的高三位,工作线程数记录在int变量的低29位。

五个状态常量计算之后的结果是这样的,注意它们只有高三位不同:

RUNNING: 	11100000 00000000 00000000 00000000
SHUTDOWN: 	00000000 00000000 00000000 00000000
STOP: 		00100000 00000000 00000000 00000000
TIDYING: 	01000000 00000000 00000000 00000000
TERMINATED: 01100000 00000000 00000000 00000000

 

ThreadPoolExecutor构造方法中的核心参数

在《阿里巴巴Java开发手册》中提到说:

强制不允许使用Executor来创建线程池,必须使用ThreadPoolExecutor构造函数的方式来创建,因为这种处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

ThreadPoolExecutor类中提供四个构造方法,其中三个传入参数较少的构造方法都是直接调用传入参数最多的构造方法并传入默认值。

我们直接来看这个传入参数最多的构造方法:

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

一共有7个传入参数,我们一一来看一下。

  • corePoolSize:核心线程数,最小可以同时运行的线程数量
  • maximumPoolSize:最大线程数,当任务队列中的任务数达到队列容量时,当前可以同时运行的线程数量变为这个参数设定的最大线程数。
  • keepAliveTime:当池中线程数大于核心线程数且没有新任务提交时,核心线程外的线程在等待keepAliveTime时间之后会被回收销毁
  • unit:TimeUnit类型,代表keepAliveTime参数的时间单位
  • workQueue:这是一个存Runnable类型的队列,当新任务到来时判断当前运行线程是否达到了核心线程数,如果达到了,将新任务放入任务队列中
  • threadFactory:执行executor创建新线程时会用到的线程工厂
  • handler:拒绝策略,当拒绝执行任务时的处理方法

其中corePoolSizemaximumPoolSizekeepAliveTimeworkQueue这四个参数是比较关键的。怎么理解它们呢?

我这里做一个比喻,我们把线程池看作是一家工厂,那么corePoolSize就是工厂核心员工数量,maximumPoolSize就是工厂最大容纳员工的数量,wordQueue就是工厂核心员工数量下员工忙不过来时的任务清单,keepAliveTime就是如果当前厂里总人数大于核心员工数且没有工厂新任务时,空闲的员工会等待的时间,如果过了这段时间还没任务就会被开除。
这家工厂拥有corePoolSize数量的员工(当然一开始工厂也没员工,这些员工也是由一开始有任务后而慢慢招进来的,不是说工厂一建立就立刻招员工),当有任务需要执行时就厂里现有的员工来执行,当现有员工忙不过来时,会先把暂时还没法开始执行的任务列在任务清单中,但是这个任务清单中的任务数量是有上限的,当达到了上限时但是还有新任务需要做时,就会开始招聘新员工来执行后面新加入的任务(注意,临时工优先执行后来的任务,也就是说队列里塞不进的任务会招临时工来做,原本在队列里的任务还是在队列里),同时在招聘新员工的时候,员工总数量不能超过工厂的最大容纳员工数量。
当厂里员工数大于核心员工数且此时厂里没有新任务需要执行时,空闲的员工如果在keepAliveTime时间之后还没工作可以做,那么他们就会被开除,注意这里不论他们是不是之前的老员工,就是说被开除的员工有可能之前是最少数量员工数中的员工,但是如果遇到了此时正在裁员的情况,如果新的员工此时正在工作而老员工没在工作,那么他也会被开除(真残酷)。

通过以上例子,大家应该能大概明白corePoolSizemaximumPoolSizekeepAliveTimeworkQueue这四个参数的含义了。

 

BlockingQueue工作队列类型

线程池构造方法中的workQueue参数是需要传入一个实现了BlockingQueue接口的阻塞队列对象。JUC并发包提供了以下几种阻塞队列:

  • ArrayBlockingQueue:由数组结构组成的有界阻塞队列
  • LinkedBlockingQueue:由链表结构组成的有界阻塞队列
  • LinkedTransferQueue:由链表结构组成的无界阻塞队列
  • LinkedBlockingDeque:由链表结构组成的双向阻塞队列
  • PriorityBlockingQueue:支持优先级排序无界阻塞队列
  • DelayQueue:使用优先级队列实现的无界阻塞队列
  • SynchronousQueue:不存储元素的阻塞队列

下面选择性地简单介绍一些工作队列:

  • ArrayBlockingQueue:
    用数组实现,按FIFO排序任务
  • LinkedBlockingQueue:
    使用链表结构,按FIFO排序任务,容量可设置,若不设置容量则默认为一个无界队列,最大队列长度为Integer.MAX_VALUE,吞吐量通常高于ArrayBlockingQueue,FixedThreadPool使用这种队列
  • PriorityBlockingQueue:
    具有优先级的无界阻塞队列
  • DelayQueue:
    封装PriorityBlockingQueue得到的无界阻塞队列,只能放置实现了Delay接口的对象,队列是有序的,队头对象的延迟到期时间最长,队列中的对象只有在到期时才能从队列中取走,ScheduledThreadPool使用这种队列
  • SynchronousQueue:
    不存储元素,每个插入操作必须等到另一个线程调用了移除操作后才能进行,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue,CachedThreadPool使用这种队列

ThreadPoolExecutor构造方法使用举例

接下来我们再来回看之前CachedThreadPool、FixedThreadPool、SingleThreadExecutor三种线程池是怎么实现的:

newCachedThreadPool
Java并发(六)线程池入门_第8张图片
可见,newCachedThreadPool方法中设置的核心线程数是0,最大线程数是int的最大值,即是无界的,空闲线程存活时间设为60s,任务队列使用的是SynchronousQueue,即任务队列不会存储元素,一旦有任务打算放入工作队列那么就会立刻将任务安排掉。

所以在CachedThreadPool中,一开始线程数是0,任务队列一旦存入任务就会立刻将任务安排掉,当有任务需要执行时,就直接创建线程来执行它,且线程数无限制。

对于CachedThreadPool的理解我们可以依据它的名字中的Cache联想到缓存,线程池中的线程在执行完任务后在存活时间内可能会用于继续执行新来的任务。

同时这就意味着极端情况下如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool会不断创建新线程,这个线程数是没有限制的,所以该线程池不会拒绝任务,这会耗尽cpu和内存资源。

同时CachedThreadPool的最大线程数是Integer.MAX_VALUE,使用它可能会创建大量线程,引起OOM(Out Of Memory),所以不推荐使用CachedThreadPool的原因。

 
newFixedThreadPool
Java并发(六)线程池入门_第9张图片
可见,newFixedThreadPool方法中设置的核心线程数和最大线程数都是n,空闲线程存活时间为0,任务队列使用的是LinkedBlockingQueue且没有初始化容量,那么这个工作队列是无界的,即是一个无限大的队列。其实这里的最大线程数是个无效参数,因为任务队列无穷大,不可能出现任务队列满了后再创建新线程的情况。

顾名思义,FixedThreadPool只能有Fixed也即固定数量的线程数,当这个固定数量的线程忙不过来时,不断将新任务放入队列中。

同时FixedThreadPool也是不推荐使用的,因为它的任务队列是无界队列,不会出现拒绝任务的情况,可能会导致OOM。

 
newSingleThreadExecutor
Java并发(六)线程池入门_第10张图片
不难发现其实SingleThreadExecutor与FixedThreadPool其实同理,等同于调用newFixedThreadPool(1),所以同理不推荐使用SingleThreadExecutor

 

ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutor主要用来在给定的延迟后运行任务,或者定期执行任务。

ScheduledThreadPoolExecutor使用的任务队列DelayQueue封装了一个PriorityQueuePriorityQueue会对队列中的任务进行排序,执行所需时间短的放在前面先执行 ( ScheduledFutureTask 的 time 变量小的先执行),如果执行所需时间相同则先提交的任务将被先执行 ( ScheduledFutureTask 的 squenceNumber 变量小的先执行)。

 

ThreadPoolExecutor中的拒绝策略

上文我们提到过拒绝策略。拒绝策略是规定当线程池不断接受任务直到当前线程池中线程数大于允许的最大线程数maximumPoolSize时如何拒绝这些任务。

ThreadPoolExecutor 中定义一些 RejectedExecutionHandler 类型变量的对象作为拒绝策略:

  • AbortPolicy:直接抛出异常来拒绝新任务
  • CallerRunsPolicy:通过调用自己的线程来执行任务,也就是直接在调用了execute方法的线程中来执行被拒绝的任务。这种策略会降低新任务的提交速度,如果不介意速度慢且要求完成每一个任务,可以使用这个策略
  • DiscardPolicy:不处理新任务,直接丢弃
  • DiscardOldestPolicy:丢弃最早未处理的任务

 

线程池大小的确定

线程池中的线程个数的选择是有讲究的,线程个数过多或过少都会存在问题:

  • 线程池中线程个数过少,如果在同一时间有大量任务需要处理,那么就会导致大量任务在任务队列中排队,甚至可能会出现任务队列满了或大量任务堆积导致OOM的情况,这种情况下CPU资源没有得到充分利用
  • 线程池中线程个数过多,则可能存在大量线程同时竞争CPU资源的情况,导致发生大量的上下文切换,降低了执行效率

可以使用一个简单并且适用面广的公式:

  • CPU 密集型任务,将线程池个数设为 CPU核心数 + 1,多出来的一个线程是用于防止某一个线程发生缺页中断或者其它原因导致任务暂停的情况,一旦任务暂停,CPU处于空闲状态,多出来的一个线程就可以充分利用CPU的空闲时间
  • I/O 密集型任务,将线程池个数设为 CPU核心数 × 2,这种情况下,系统会把大部分时间用来处理 I/O 交互,线程在处理 I/O 过程中不会占用CPU资源,此时CPU资源就可以供其它线程使用。

那么如何判断当前情况是CPU密集型任务还是I/O密集型任务?

  • CPU 密集型,简单理解就是任务多为利用CPU计算能力的任务,如在内存中对大量数据进行排序
  • I/O 密集型,但凡涉及到网络读取、文件读取等操作的任务都是 I/O 密集型的,这类任务的特点是CPU计算耗费时间相比等待IO操作的时间来说很少,大部分时间都用在了等待IO操作上

 

线程池中的几种常见对比

RunnableCallable

  • Runnable接口不会返回结果及不可抛出异常
  • Callable接口可以返回结果和抛出异常

execute()submit()

  • execute方法用于提交不需要返回值的任务,且无法判断任务是否被线程池执行成功
  • submit方法用于提交需要返回值的任务,线程池会返回一个Future类型对象,可以通过对该Future类型对象判断任务是否执行完成以及调动get方法获取返回值。

shutdown()shutdownNow()

  • shutdown方法用于关闭线程池,将线程池状态变为SHUTDOWN,线程池不再接收新任务,但是任务队列中的任务得执行完毕
  • shutdownNoew方法也用于关闭线程池,但是是将线程池状态变为STOP,线程池会终止当前正在运行的任务,并停止处理排队任务并返回正在等待执行的List

isShutdown()isTerminated()

  • isShutdown方法在线程池调用了shutdown方法后返回true
  • isTerminated方法在线程池调用了shutdown方法且所有提交的任务完成后返回true

关于线程池就先写到这,其实关于线程池还有很多细节本文没有写到,后续可能会接着更新本篇文章吧。


2022.4.1 增加了ScheduledThreadPoolExecutor、工作队列类型、线程池大小确定的内容


参考:

  1. B站up主 寒食君 的视频:视频链接
  2. B站up主 free-coder 的视频:视频链接
  3. JavaGuide中的一篇文章
  4. 参考文章

你可能感兴趣的:(Java学习笔记,#,Java并发学习笔记,java)