线程池基本概念(十二)

前言

  在面向对象编程中,创建和销毁对象是很费时间的,因为创建一个对象要获取内存资源或者其它更多资源。在 Java 中更是如此,虚拟机将试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。所以提高服务程序效率的一个手段就是尽可能减少创建和销毁对象的次数,特别是一些很耗资源的对象创建和销毁。

  如果并发的线程数多,并且每个线程都是执行一个时间很短的任务就结束了,这样会造成频繁的创建和销毁线程从而导致降低系统的效率。

  线程若是无限制的创建,可能会导致内存占用过多而产生OOM,并且会造成cpu过度切换。

  那么问题来了,有没有办法可用复用创建好的线程呢,也就是线程执行完一个任务后,不被销毁,继续执行其他的任务?

  用线程池来管理多个线程,复用空闲线程,减少线程的创建和销毁,提升系统性能。

一、使用线程池与不使用线程池的差别

先来看一下使用线程池与不使用线程池的差别,第一段代码是使用线程池的:

public static void main(String[] args)
{
    long startTime = System.currentTimeMillis();
    final List<Integer> l = new LinkedList<Integer>();
    ThreadPoolExecutor tp = new ThreadPoolExecutor(100, 100, 60, TimeUnit.SECONDS, 
                new LinkedBlockingQueue<Runnable>(20000));
    final Random random = new Random();
    for (int i = 0; i < 20000; i++)
    {
        tp.execute(new Runnable()
        {
            public void run()
            {
                l.add(random.nextInt());
            }
        });
    }
    tp.shutdown();
    try
    {
        tp.awaitTermination(1, TimeUnit.DAYS);
    }
    catch (InterruptedException e)
    {
        e.printStackTrace();
    }
    System.out.println(System.currentTimeMillis() - startTime);
    System.out.println(l.size());
}

接着是不使用线程池的:

public static void main(String[] args)
{
    long startTime = System.currentTimeMillis();
    final List<Integer> l = new LinkedList<Integer>();
    final Random random = new Random();
    for (int i = 0; i < 20000; i++)
    {
        Thread thread = new Thread()
        {
            public void run()
            {
                l.add(random.nextInt());
            }
        };
        thread.start();
        try
        {
            thread.join();
        }
        catch (InterruptedException e)
        {
            e.printStackTrace();
        }
    }
    System.out.println(System.currentTimeMillis() - startTime);
    System.out.println(l.size());
}

运行一下,我这里第一段代码使用了线程池的时间是194ms,第二段代码不使用线程池的时间是2043ms。这里默认的线程池中的线程数是100,如果把这个数量减小,虽然系统的处理数据能力变弱了,但是速度却更快了。当然这个例子很简单,但也足够说明问题了。

二、线程池的作用

2.1线程池的作用就2个:

1、减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务

2、可以根据系统的承受能力,调整线程池中工作线程的数据,防止因为消耗过多的内存导致服务器崩溃

使用线程池,要根据系统的环境情况,手动或自动设置线程数目。少了系统运行效率不高,多了系统拥挤、占用内存多。用线程池控制数量,其他线程排队等候。一个任务执行完毕,再从队列中取最前面的任务开始执行。若任务中没有等待任务,线程池这一资源处于等待。当一个新任务需要运行,如果线程池中有等待的工作线程,就可以开始运行了,否则进入等待队列。

2.2线程池的优势

(1)降低系统资源消耗,通过重用已存在的线程,降低线程创建和销毁造成的消耗;
(2)提高系统响应速度,当有任务到达时,通过复用已存在的线程,无需等待新线程的创建便能立即执行;
(3)方便线程并发数的管控。因为线程若是无限制的创建,可能会导致内存占用过多而产生OOM,并且会造成cpu过度切换(cpu切换线程是有时间成本的(需要保持当前执行线程的现场,并恢复要执行线程的现场))。
(4)提供更强大的功能,延时定时线程池。

三、线程池类结构

画了一张图表示线程池的类结构图:

这张图基本简单代表了线程池类的结构:

1、最顶级的接口是Executor,不过Executor严格意义上来说并不是一个线程池而只是提供了一种任务如何运行的机制而已

2、ExecutorService才可以认为是真正的线程池接口,接口提供了管理线程池的方法

3、下面两个分支,AbstractExecutorService分支就是普通的线程池分支,ScheduledExecutorService是用来创建定时任务的

四、ThreadPoolExecutor七个核心参数

在实际项目中线程的应用都会使用线程池来管理,线程池的常用参数及配置学习记录。

4.1核心参数

这篇文章重点讲的就是线程池ThreadPoolExecutor,开头也演示过ThreadPoolExecutor的使用了。

下面来看一下ThreadPoolExecutor完整构造方法的签名,签名中包含了七个参数,是ThreadPoolExecutor的核心,对这些参数的理解、配置、调优对于使用好线程池是非常重要的。因此接下来需要逐一理解每个参数的具体作用。先看一下构造方法签名:

public ThreadPoolExecutor(int corePoolSize,
                            int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        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;
    }

1、corePoolSize

核心池的大小。在创建了线程池之后,默认情况下,线程池中没有任何线程,而是等待有任务到来才创建线程去执行任务。默认情况下,在创建了线程池之后,线程池钟的线程数为0,当有任务到来后就会创建一个线程去执行任务

2、maximumPoolSize

池中允许的最大线程数,这个参数表示了线程池中最多能创建的线程数量,当任务数量比corePoolSize大时,任务添加到workQueue,当workQueue满了,将继续创建线程以处理任务,maximumPoolSize表示的就是wordQueue满了,线程池中最多可以创建的线程数量

3、keepAliveTime

只有当线程池中的线程数大于corePoolSize时,这个参数才会起作用。当线程数大于corePoolSize时,终止前多余的空闲线程等待新任务的最长时间

4、unit

keepAliveTime时间单位

5、workQueue

存储还没来得及执行的任务

6、threadFactory

执行程序创建新线程时使用的工厂

7、handler

由于超出线程范围和队列容量而使执行被阻塞时所使用的处理程序

4.2详细说明

  • 1. corePoolSize:核心线程数
  • 1.1核心线程会一直存活,即使没有任务需要执行。
  • 1.2当线程数小于核心线程数时,即使有线程空闲,线程池也会优先创建新线程处理。
  • 1.3 设置allowCoreThreadTimeout=true(默认false)时,核心线程会超时关闭。
  • 1.4 可以通过 prestartCoreThread() 或 prestartAllCoreThreads() 方法来提前启动线程池中的基本线程。
  • 2. queueCapacity:任务队列容量(阻塞队列)
  • 2.1 当核心线程数达到最大时,新任务会放在队列中排队等待执行。
  • 3. maxPoolSize:最大线程数
  • 3.1 线程池所允许的最大线程个数
  • 3.2 maxPoolSize>当线程数>=corePoolSize,且任务队列已满时。线程池会创建新线程来处理任务。
  • 3.3 当线程数=maxPoolSize,且任务队列已满时,线程池会根据handle策略处理,默认是AbortPolicy 丢弃任务,抛运行时异常。
  • 4. keepAliveTime:线程空闲保持时间
  • 4.1 当线程空闲时间达到keepAliveTime时,线程会退出,直到线程数量=corePoolSize。
  • 4.2 如果allowCoreThreadTimeout=true,则会直到线程数量=0。
  • 5. allowCoreThreadTimeout:允许核心线程超时
  • 6.rejectedExecutionHandler:任务拒绝处理器
  • 6.1 两种情况会拒绝处理任务:
  • (1)当线程数已经达到maxPoolSize,切队列已满,会拒绝新任务。
  • (2)当线程池被调用shutdown()后,会等待线程池里的任务执行完毕,再shutdown。如果在调用shutdown()和线程池真正shutdown之间提交任务,会拒绝新任务。线程池会调用rejectedExecutionHandler来处理这个任务。如果没有设置默认是AbortPolicy,会抛出异常。
  • 6.2 ThreadPoolExecutor类有几个内部实现类来处理这类情况-handle饱和策略:
  • (1)AbortPolicy 丢弃任务,抛运行时异常。
  • (2)CallerRunsPolicy 执行任务。
  • (3)DiscardPolicy 忽视,什么都不会发生。
  • (4)DiscardOldestPolicy 从队列中踢出最先进入队列(最后一个执行)的任务。实现RejectedExecutionHandler接口,也可自定义处理器。

4.3corePoolSize与maximumPoolSize举例理解

上面的内容,其他应该都相对比较好理解,只有corePoolSize和maximumPoolSize需要多思考。这里要特别再举例以四条规则解释一下这两个参数:

1、池中线程数小于corePoolSize,新任务都不排队而是直接添加新线程

2、池中线程数大于等于corePoolSize,workQueue未满,首选将新任务加入workQueue而不是添加新线程

3、池中线程数大于等于corePoolSize,workQueue已满,但是线程数小于maximumPoolSize,添加新的线程来处理被添加的任务

4、池中线程数大于大于corePoolSize,workQueue已满,并且线程数大于等于maximumPoolSize,新任务被拒绝,使用handler处理被拒绝的任务

ThreadPoolExecutor的使用很简单,前面的代码也写过例子了。通过execute(Runnable command)方法来发起一个任务的执行,通过shutDown()方法来对已经提交的任务做一个有效的关闭。尽管线程池很好,但我们要注意JDK API的一段话:

强烈建议程序员使用较为方便的Executors工厂方法Executors.newCachedThreadPool()(无界线程池,可以进行线程自动回收)、Executors.newFixedThreadPool(int)(固定大小线程池)和Executors.newSingleThreadExecutor()(单个后台线程),它们均为大多数使用场景预定义了设置。

所以,跳开对ThreadPoolExecutor的关注(还是那句话,有问题查询JDK API),重点关注一下JDK推荐的Executors。

4.4ThreadPoolExecutor执行过程

1.当线程数小于核心线程数时,创建线程。

2.当线程数大于等于核心线程数,且任务队列未满时,将任务放入任务队列。

3.当线程数大于等于核心线程数,且任务队列已满。

(1)若线程数小于最大线程数,创建线程。

(2)若线程数等于最大线程数,抛出异常,拒绝任务。

五、Executors

  个人认为,线程池的重点不是ThreadPoolExecutor怎么用或者是Executors怎么用,而是在合适的场景下使用合适的线程池,所谓"合适的线程池"的意思就是,ThreadPoolExecutor的构造方法传入不同的参数,构造出不同的线程池,以满足使用的需要

  Java通过Executors提供四种线程池,分别为:

5.1 newCachedThreadPool()   无界线程池

newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

newCachedThreadPool:用来创建一个可以无限扩大的线程池,适用于负载较轻的场景,执行短期异步任务。(可以使得任务快速得到执行,因为任务时间执行短,可以很快结束,也不会造成cpu过度切换)

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

无界线程池,意思是不管多少任务提交进来,都直接运行。无界线程池采用了SynchronousQueue,采用这个线程池就没有workQueue容量一说了,只要添加进去的线程就会被拿去用。既然是无界线程池,那线程数肯定没上限,所以以maximumPoolSize为主了,设置为一个近似的无限大Integer.MAX_VALUE。 另外注意一下,单线程线程池和固定大小线程池线程都不会进行自动回收的,也即是说保证提交进来的任务最终都会被处理,但至于什么时候处理,就要看处理能力了。但是无界线程池是设置了回收时间的,由于corePoolSize为0,所以只要60秒没有被用到的线程都会被直接移除

5.2 newFixedThreadPool(int nThreads)   固定大小线程池

newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。

newFixedThreadPool:创建一个固定大小的线程池,因为采用无界的阻塞队列,所以实际线程数量永远不会变化,适用于负载较重的场景,对当前线程数量进行限制。(保证线程数可控,不会造成线程过多,导致系统负载更为严重)

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}

固定大小的线程池和单线程的线程池异曲同工,无非是让线程池中能运行的线程编程了手动指定的nThreads罢了。同样,由于是选择了LinkedBlockingQueue,因此其实第二个参数maximumPoolSize同样也是无意义的

5.3 newScheduledThreadPool

newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。

newScheduledThreadPool:适用于执行延时或者周期性任务。

5.4 newSingleThreadExecutor()   单线程线程池

newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

newSingleThreadExecutor:创建一个单线程的线程池,适用于需要保证顺序执行各个任务。

public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}

单线程线程池,那么线程池中运行的线程数肯定是1。workQueue选择了无界的LinkedBlockingQueue,那么不管来多少任务都排队,前面一个任务执行完毕,再执行队列中的线程。从这个角度讲,第二个参数maximumPoolSize是没有意义的,因为maximumPoolSize描述的是排队的任务多过workQueue的容量,线程池中最多只能容纳maximumPoolSize个任务,现在workQueue是无界的,也就是说排队的任务永远不会多过workQueue的容量,那maximum其实设置多少都无所谓了。

六、谈谈workQueue

上面四种线程池都提到了一个概念,workQueue,也就是排队策略。排队策略描述的是,当前线程大于corePoolSize时,线程以什么样的方式排队等待被运行。

排队有三种策略:直接提交、有界队列、无界队列。

谈谈后两种,JDK使用了无界队列LinkedBlockingQueue作为WorkQueue而不是有界队列ArrayBlockingQueue,尽管后者可以对资源进行控制,但是个人认为,使用有界队列相比无界队列有三个缺点:

1、使用有界队列,corePoolSize、maximumPoolSize两个参数势必要根据实际场景不断调整以求达到一个最佳,这势必给开发带来极大的麻烦,必须经过大量的性能测试。所以干脆就使用无界队列,任务永远添加到队列中,不会溢出,自然maximumPoolSize也没什么用了,只需要根据系统处理能力调整corePoolSize就可以了

2、防止业务突刺。尤其是在Web应用中,某些时候突然大量请求的到来都是很正常的。这时候使用无界队列,不管早晚,至少保证所有任务都能被处理到。但是使用有界队列呢?那些超出maximumPoolSize的任务直接被丢掉了,处理地慢还可以忍受,但是任务直接就不处理了,这似乎有些糟糕

3、不仅仅是corePoolSize和maximumPoolSize需要相互调整,有界队列的队列大小和maximumPoolSize也需要相互折衷,这也是一块比较难以控制和调整的方面

当然,最后还是那句话,就像Comparable和Comparator的对比、synchronized和ReentrantLock,再到这里的无界队列和有界队列的对比,看似都有一个的优点稍微突出一些,但是这绝不是鼓励大家使用一个而不使用另一个,任何东西都需要根据实际情况来,当然在一开始的时候可以重点考虑那些看上去优点明显一点的。

七、四种拒绝策略

所谓拒绝策略之前也提到过了,任务太多,超过maximumPoolSize了怎么把?当然是接不下了,接不下那只有拒绝了。拒绝的时候可以指定拒绝策略,也就是一段处理程序。

决绝策略的父接口是RejectedExecutionHandler,JDK本身在ThreadPoolExecutor里给用户提供了四种拒绝策略,看一下:

1、AbortPolicy

直接抛出一个RejectedExecutionException,这也是JDK默认的拒绝策略

2、CallerRunsPolicy

尝试直接运行被拒绝的任务,如果线程池已经被关闭了,任务就被丢弃了

3、DiscardOldestPolicy

移除最晚的那个没有被处理的任务,然后执行被拒绝的任务。同样,如果线程池已经被关闭了,任务就被丢弃了

4、DiscardPolicy

不能执行的任务将被删除

八、Java线程池参数配置

1、线程池的默认值

corePoolSize=1

queueCapacity=Integer.MAX_VALUE

maxPoolSize=Integer.MAX_VALUE

keepAliveTime=60s

allowCoreThreadTimeout=false

rejectedExcutionHandler=AbortPolicy()

2、参数配置

配置参数时需要考虑 CPU密集型任务 、 IO密集型任务 、内存使用率 、下游系统抗并发的能力。

配置参数:
CPU密集型 CPU的核数+1
IO密集型 一般配置 2*CPU的核数
参考公式(某大厂配置):
CPU核数/(1-阻塞系数) 阻塞系数在0.8~0.9之间
比如8核CPU 8/(1-0.9) = 80个线程数

九、execute()和submit()方法

1、execute()

执行一个任务,没有返回值。

2、submit()

提交一个线程任务,有返回值。

  • submit(Callable<T> task)能获取到它的返回值,通过future.get()获取(阻塞直到任务执行完)。一般使用FutureTask+Callable配合使用(IntentService中有体现)。
  • submit(Runnable task, T result)能通过传入的载体result间接获得线程的返回值。
  • submit(Runnable task)则是没有返回值的,就算获取它的返回值也是null。
  • Future.get方法会使取结果的线程进入阻塞状态,知道线程执行完成之后,唤醒取结果的线程,然后返回结果。

参看链接:

https://www.cnblogs.com/xrq730/p/4856453.html

https://blog.csdn.net/xinpz/article/details/110132365

https://www.jianshu.com/p/7726c70cdc40

https://www.cnblogs.com/dolphin0520/p/3949310.html

http://gityuan.com/2016/01/16/thread-pool/

posted @ 2020-03-31 16:43  慎终若始  阅读(204)  评论(0编辑  收藏  举报