[整理] 线程池基础 (Java)

一、线程池简介

线程池可以看做是线程的集合。在没有任务时线程处于空闲状态,当请求到来:线程池给这个请求分配一个空闲的线程,任务完成后回到线程池中等待下次任务(而不是销毁)。这样就实现了线程的重用

我们来看看如果没有使用线程池的情况是这样的:

为每个请求都新开一个线程!

public class ThreadPerTaskWebServer {
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            // 为每个请求都创建一个新的线程
            final Socket connection = socket.accept();
            Runnable task = () -> handleRequest(connection);
            new Thread(task).start();
        }
    }
    private static void handleRequest(Socket connection) {
        // request-handling logic here
    }
}

为每个请求都开一个新的线程虽然理论上是可以的,但是会有缺点:

  • 线程生命周期的开销非常高。
    每个线程都有自己的生命周期,创建和销毁线程所花费的时间和资源可能比处理客户端的任务花费的时间和资源更多,并且还会有某些空闲线程也会占用资源。

  • 程序的稳定性和健壮性会下。
    每个请求开一个线程。如果受到了恶意攻击或者请求过多(内存不足),程序很容易就奔溃掉了。

所以说:我们的线程最好是交由线程池来管理,这样可以减少对线程生命周期的管理,一定程度上提高性能。

二、JDK提供的线程池API

JDK给我们提供了Excutor框架来使用线程池,它是线程池的基础。

  • Executor[ɪɡ'zekjʊtə]提供了一种将“任务提交”与“任务执行”分离开来的机制(解耦)

下面我们来看看JDK线程池的总体api架构:

接下来我们把这些API都过一遍看看:
Executor接口

ExcutorService接口

AbstractExecutorService类

ScheduledExecutorService接口

ThreadPoolExecutor类

ScheduledThreadPoolExecutor类

2.1 ForkJoinPool线程池

除了ScheduledThreadPoolExecutor和ThreadPoolExecutor类线程池以外,还有一个是JDK1.7新增的线程池:ForkJoinPool线程池

于是我们的类图就可以变得完整一些:

JDK1.7中新增的一个线程池,与ThreadPoolExecutor一样,同样继承了AbstractExecutorService。
ForkJoinPool是Fork/Join框架的两大核心类之一。
与其它类型的ExecutorService相比,其主要的不同在于采用了工作窃取算法(work-stealing):所有池中线程会尝试找到并执行已被提交到池中的或由其他线程创建的任务。这样很少有线程会处于空闲状态,非常高效。

这使得能够有效地处理以下情景:

  • 大多数由任务产生大量子任务的情况;
  • 从外部客户端大量提交小任务到池中的情况。

2.2补充:Callable和Future

学到了线程池,我们可以很容易地发现:很多的API都有Callable和Future这么两个东西。

    Future<?> submit(Runnable task)
    <T> Future<T> submit(Callable<T> task)

其实它们也不是什么高深的东西~~~

我们可以简单认为:Callable就是Runnable的扩展。

Runnable没有返回值,不能抛出受检查的异常,而Callable可以!

也就是说:当我们的任务需要返回值的时,我们就可以使用Callable!

Future一般我们认为是Callable的返回值,但他其实代表的是任务的生命周期(当然了,它是能获取得到Callable的返回值的)

简单来看一下他们的用法:

public class CallableDemo {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        // 创建线程池对象
        ExecutorService pool = Executors.newFixedThreadPool(2);

        // 可以执行Runnable对象或者Callable对象代表的线程
        Future<Integer> f1 = pool.submit(new MyCallable(100));
        Future<Integer> f2 = pool.submit(new MyCallable(200));

        // V get()
        Integer i1 = f1.get();
        Integer i2 = f2.get();

        System.out.println(i1);
        System.out.println(i2);

        // 结束
        pool.shutdown();
    }
}

Callable任务:

public class MyCallable implements Callable<Integer> {

    private int number;

    public MyCallable(int number) {
        this.number = number;
    }

    @Override
    public Integer call() throws Exception {
        int sum = 0;
        for (int x = 1; x <= number; x++) {
            sum += x;
        }
        return sum;
    }
}

执行完任务之后可以获取得到任务返回的数据:

三、ThreadPoolExecutor详解

这是用得最多的线程池,所以本文会重点讲解它。

我们来看看顶部注释:

3.1内部状态

变量ctl定义为AtomicInteger,记录了“线程池中的任务数量”和“线程池的状态”两个信息。

线程的状态:

  • RUNNING:线程池能够接受新任务,以及对新添加的任务进行处理。
  • SHUTDOWN:线程池不可以接受新任务,但是可以对已添加的任务进行处理。
  • STOP:线程池不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。
  • TIDYING:当所有的任务已终止,ctl记录的"任务数量"为0,线程池会变为TIDYING状态。当线程池变为TIDYING状态时,会执行钩子函数terminated()。terminated()在ThreadPoolExecutor类中是空的,若用户想在线程池变为TIDYING时,进行相应的处理;可以通过重载terminated()函数来实现。
  • TERMINATED:线程池彻底终止的状态。

各个状态之间转换:

3.2 已默认实现的池

下面我就列举三个比较常见的实现池:

  • newFixedThreadPool
  • newCachedThreadPool
  • SingleThreadExecutor

如果读懂了上面对应的策略呀,线程数量这些,应该就不会太难看懂了。

3.2.1 newFixedThreadPool

一个固定线程数的线程池,它将返回一个corePoolSize和maximumPoolSize相等的线程池。

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

3.2.2 newCachedThreadPool

非常有弹性的线程池,对于新的任务,如果此时线程池里没有空闲线程,线程池会毫不犹豫的创建一条新的线程去处理这个任务。

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

3.2.3 SingleThreadExecutor

使用单个worker线程的Executor

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

3.3构造方法

我们读完上面的默认实现池还有对应的属性,再回到构造方法看看

构造方法可以让我们自定义(扩展)线程池

    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.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }
  • 指定核心线程数量
  • 指定最大线程数量
  • 允许线程空闲时间
  • 时间对象
  • 阻塞队列
  • 线程工厂
  • 任务拒绝策略

再总结一遍这些参数的要点:

  • 线程数量要点:

    • 如果运行线程的数量少于核心线程数量,则创建新的线程处理请求
    • 如果运行线程的数量大于核心线程数量,小于最大线程数量,则当队列满的时候才创建新的线程
    • 如果核心线程数量等于最大线程数量,那么将创建固定大小的连接池
    • 如果设置了最大线程数量为无穷,那么允许线程池适合任意的并发数量
  • 线程空闲时间要点:

    • 当前线程数大于核心线程数,如果空闲时间已经超过了,那该线程会销毁。
  • 排队策略要点:

    • 同步移交:不会放到队列中,而是等待线程执行它。如果当前线程没有执行,很可能会新开一个线程执行。
    • 无界限策略:如果核心线程都在工作,该线程会放到队列中。所以线程数不会超过核心线程数
    • 有界限策略:可以避免资源耗尽,但是一定程度上减低了吞吐量

当线程关闭或者线程数量满了和队列饱和了,就有拒绝任务的情况了:

  • 拒绝任务策略:
    • 直接抛出异常
    • 使用调用者的线程来处理
    • 直接丢掉这个任务
    • 丢掉最老的任务

四、execute执行方法

execute执行方法分了三步,以注释的方式写在代码上了~

    public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        int c = ctl.get();
        //如果线程池中运行的线程数量<corePoolSize,则创建新线程来处理请求,即使其他辅助线程是空闲的。
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }

        //如果线程池中运行的线程数量>=corePoolSize,且线程池处于RUNNING状态,且把提交的任务成功放入阻塞队列中,就再次检查线程池的状态,
            // 1.如果线程池不是RUNNING状态,且成功从阻塞队列中删除任务,则该任务由当前 RejectedExecutionHandler 处理。
            // 2.否则如果线程池中运行的线程数量为0,则通过addWorker(null, false)尝试新建一个线程,新建线程对应的任务为null。
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        // 如果以上两种case不成立,即没能将任务成功放入阻塞队列中,且addWoker新建线程失败,则该任务由当前 RejectedExecutionHandler 处理。
        else if (!addWorker(command, false))
            reject(command);
    }

五、线程池关闭

ThreadPoolExecutor提供了shutdown()和shutdownNow()两个方法来关闭线程池

shutdown() :

shutdownNow():

区别:

  • 调用shutdown()后,线程池状态立刻变为SHUTDOWN,而调用shutdownNow(),线程池状态立刻变为STOP。
  • shutdown()等待任务执行完才中断线程,而shutdownNow()不等任务执行完就中断了线程。

六、多线程调度器(ScheduledThreadPoolExecutor)

定时任务就是在指定时间执行程序,或周期性执行计划任务。
Java中实现定时任务的方法有很多,本文从从JDK自带的一些方法来实现定时任务的需求。

Timer和TimerTask

本文先介绍Java最原始的解决方案:Timer和TimerTask

Timer和TimerTask可以作为线程实现的第三种方式,在JDK1.3的时候推出。但是自从JDK1.5之后不再推荐时间,而是使用ScheduledThreadPoolExecutor代替

public class Timer {}
// TimerTask 是个抽象类
public abstract class TimerTask implements Runnable {}

快速入门
Timer运行在后台,可以执行任务一次,或定期执行任务。TimerTask类继承了Runnable接口,因此具备多线程的能力。一个Timer可以调度任意多个TimerTask,所有任务都存储在一个队列中顺序执行,如果需要多个TimerTask并发执行,则需要创建两个多个Timer。

很显然,一个Timer定时器,是单线程的

public static void main(String[] args) throws ParseException {
        Timer timer = new Timer();
        //1、设定两秒后执行任务
        //timer.scheduleAtFixedRate(new MyTimerTask1(), 2000,1000);
        //2、设定任务在执行时间执行,本例设定时间13:57:00
        SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
        Date time = dateFormatter.parse("2018/11/04 18:40:00");
        //让在指定的时刻执行(如果是过去时间会立马执行 如果是将来时间 那就等吧)
        timer.schedule(new MyTimerTask1(), time);
    }

    //被执行的任务必须继承TimerTask,并且实现run方法
    static class MyTimerTask1 extends TimerTask {
        public void run() {
            System.out.println("爆炸!!!");
        }
    }

相关API简单介绍(毕竟已经不重要了):

 schedule(TimerTask task, long delay, long period)   --指定任务执行延迟时间
 schedule(TimerTask task, Date time, long period)    --指定任务执行时刻
 scheduleAtFixedRate(TimerTask task, long delay, long period)
 scheduleAtFixedRate(TimerTask task, Date firstTime, long period)

这里需要注意区别:

  • schedule:
  • scheduleAtFixedRate:
    相关文章度娘一下,可找到答案。因此本文不做介绍了,毕竟不是本文重点。

终止Timer线程
调用Timer.cancle()方法。可以在程序任何地方调用,甚至在TimerTask中的run方法中调用;

  • 设置Timer对象为null,其会自动终止;
  • 用System.exit方法,整个程序终止。

下面例子:
启动一个timer任务,执行指定次数/时间后停止任务

备注:该示例在某些特殊的场景会很有用的,比如守护监控、守护检查等等

/**
 * 定时器
 */
public class TaskTest {

    /**
     * 需求描述:满足条件后启动一个定时任务,再满足另外一个条件后停止此定时任务
     * (阶段性定时任务)
     * 备注:若单线程就能搞定,就使用timer即可,若需要多线程环境,请使用JDK5提供的ScheduledThreadPoolExecutor
     */
    public static void main(String[] args) {
        Timer timer = new Timer();

        // 三秒后开始执行任务,每隔2秒执行一次  当执行的总次数达到10此时,停止执行
        timer.schedule(new Task(timer, 10), 3 * 1000, 2000);
    }
}

class Task extends TimerTask {
    private Timer timer;
    private int exeCount; //此处没有线程安全问题

    public Task(Timer timer, int exeCount) {
        this.timer = timer;
        this.exeCount = exeCount;
    }

    private int i = 1;

    @Override
    public void run() {
        System.out.println("第" + i++ + "次执行任务");

        //处理业务逻辑start...
        //处理业务逻辑end...

        //若满足此条件 退出此线程
        if (i > exeCount) {
            this.timer.cancel();
            System.out.println("#### timer任务程序结束 ####");
        }
    }
}

输出:

第1次执行任务
第2次执行任务
第3次执行任务
第4次执行任务
第5次执行任务
第6次执行任务
第7次执行任务
第8次执行任务
第9次执行任务
第10次执行任务
#### timer任务程序结束 ####

Timer线程的缺点(这个就重要了)

  • Timer线程不会捕获异常,所以TimerTask抛出的未检查的异常会终止timer线程。
    如果Timer线程中存在多个计划任务,其中一个计划任务抛出未检查的异常,则会引起整个Timer线程结束,从而导致其他计划任务无法得到继续执行。

  • Timer线程时基于绝对时间(如:2014/02/14 16:06:00),因此计划任务对系统的时间的改变是敏感的。
    (举个例子,假如你希望任务1每个10秒执行一次,某个时刻,你将系统时间提前了6秒,那么任务1就会在4秒后执行,而不是10秒后)

  • Timer是单线程,如果某个任务很耗时,可能会影响其他计划任务的执行。

  • Timer执行程序是有可能延迟1、2毫秒,如果是1秒执行一次的任务,1分钟有可能延迟60毫秒,一小时延迟3600毫秒,相当于3秒(如果你的任务对时间敏感,这将会有影响) ScheduledThreadPoolExecutor的时间会更加的精确

ScheduledThreadPoolExecutor(JDK全新定时器调度)解决了上述所有问题~

ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutor是JDK1.5以后推出的类,用于实现定时、重复执行的功能,官方文档解释要优于Timer。

构造方法:

ScheduledThreadPoolExecutor(int corePoolSize) //使用给定核心池大小创建一个新定定时线程池 
ScheduledThreadPoolExecutor(int corePoolSize, ThreadFactorythreadFactory) //使用给定的初始参数创建一个新对象,可提供线程创建工厂

需要手动传入线程工厂的,可以这么弄:

    private final static ScheduledThreadPoolExecutor schedual = new ScheduledThreadPoolExecutor(1, new ThreadFactory() {
        private AtomicInteger atoInteger = new AtomicInteger(0);

        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r);
            t.setName("xxx-Thread " + atoInteger.getAndIncrement());
            return t;
        }
    });

相关调度方法:
ScheduledThreadPoolExecutor还提供了非常灵活的API,用于执行任务。其任务的执行策略主要分为两大类:

  1. 在一定延迟之后只执行一次某个任务;
  2. 在一定延迟之后周期性的执行某个任务;
    如下是其主要API:
// 执行一次
public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit);
public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit);

// 周期性执行
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
                                                 long initialDelay, long delay, TimeUnit unit);
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                                  long initialDelay, long period, TimeUnit unit);

第一个和第二个方法属于第一类,即在delay指定的延迟之后执行第一个参数所指定的任务,区别在于,第二个方法执行之后会有返回值,而第一个方法执行之后是没有返回值的。

第三个和第四个方法则属于第二类,即在第二个参数(initialDelay)指定的时间之后开始周期性的执行任务,执行周期间隔为第三个参数指定的时间。

但是这两个方法的区别在于:第三个方法执行任务的间隔是固定的,无论上一个任务是否执行完成(也就是前面的任务执行慢不会影响我后面的执行)。而第四个方法的执行时间间隔是不固定的,其会在周期任务的上一个任务执行完成之后才开始计时,并在指定时间间隔之后才开始执行任务。

public class ScheduledThreadPoolExecutorTest {
  private ScheduledThreadPoolExecutor executor;
  private Runnable task;
  
  @Before
  public void before() {
    executor = initExecutor();
    task = initTask();
  }
  
  private ScheduledThreadPoolExecutor initExecutor() {
    return new ScheduledThreadPoolExecutor(2);;
  }
  
  private Runnable initTask() {
    long start = System.currentTimeMillis();
    return () -> {
      print("start task: " + getPeriod(start, System.currentTimeMillis()));
      sleep(SECONDS, 10);
      print("end task: " + getPeriod(start, System.currentTimeMillis()));
    };
  }
  
  @Test
  public void testFixedTask() {
    print("start main thread");
    executor.scheduleAtFixedRate(task, 15, 30, SECONDS);
    sleep(SECONDS, 120);
    print("end main thread");
  }
  
  @Test
  public void testDelayedTask() {
    print("start main thread");
    executor.scheduleWithFixedDelay(task, 15, 30, SECONDS);
    sleep(SECONDS, 120);
    print("end main thread");
  }

  private void sleep(TimeUnit unit, long time) {
    try {
      unit.sleep(time);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }

  private int getPeriod(long start, long end) {
    return (int)(end - start) / 1000;
  }

  private void print(String msg) {
    System.out.println(msg);
  }
}

第一个输出:

start main thread
start task: 15
end task: 25
start task: 45
end task: 55
start task: 75
end task: 85
start task: 105
end task: 115
end main thread
第二个输出:
start main thread
start task: 15
end task: 25
start task: 55
end task: 65
start task: 95
end task: 105
end main thread

从结果,现在重点说说这两者的区别:
scheduleAtFixedRate

  • 是以上一个任务开始的时间计时,period时间过去后,检测上一个任务是否执行完毕,如果上一个任务执行完毕,则当前任务立即执行,如果上一个任务没有执行完毕,则需要等上一个任务执行完毕后立即执行。

  • 执行周期是 initialDelay 、initialDelay+period 、initialDelay + 2 * period} 、 … 如果延迟任务的执行时间大于了 period,比如为 5s,则后面的执行会等待5s才回去执行

scheduleWithFixedDelay
是以上一个任务结束时开始计时,period时间过去后,立即执行, 由上面的运行结果可以看出,第一个任务开始和第二个任务开始的间隔时间是 第一个任务的运行时间+period(永远是这么多)

注意: 通过ScheduledExecutorService执行的周期任务,如果任务执行过程中抛出了异常,那么过ScheduledExecutorService就会停止执行任务,且也不会再周期地执行该任务了。所以你如果想保住任务都一直被周期执行,那么catch一切可能的异常。

关于ScheduledThreadPoolExecutor的使用有三点需要说明

  1. ScheduledThreadPoolExecutor继承自ThreadPoolExecutor(ThreadPoolExecutor详解),因而也有继承而来的execute()和submit()方法,但是ScheduledThreadPoolExecutor重写了这两个方法,重写的方式是直接创建两个立即执行并且只执行一次的任务;

  2. ScheduledThreadPoolExecutor使用ScheduledFutureTask封装每个需要执行的任务,而任务都是放入DelayedWorkQueue队列中的,该队列是一个使用数组实现的优先队列,在调用ScheduledFutureTask::cancel()方法时,其会根据removeOnCancel变量的设置来确认是否需要将当前任务真正的从队列中移除,而不只是标识其为已删除状态;

  3. ScheduledThreadPoolExecutor提供了一个钩子方法decorateTask(Runnable, RunnableScheduledFuture)用于对执行的任务进行装饰,该方法第一个参数是调用方传入的任务实例,第二个参数则是使用ScheduledFutureTask对用户传入任务实例进行封装之后的实例。这里需要注意的是,在ScheduledFutureTask对象中有一个heapIndex变量,该变量用于记录当前实例处于队列数组中的下标位置,该变量可以将诸如contains(),remove()等方法的时间复杂度从O(N)降低到O(logN),因而效率提升是比较高的,但是如果这里用户重写decorateTask()方法封装了队列中的任务实例,那么heapIndex的优化就不存在了,因而这里强烈建议是尽量不要重写该方法,或者重写时也还是复用ScheduledFutureTask类。

posted @ 2020-12-23 10:52  哆啦梦乐园  阅读(81)  评论(0编辑  收藏  举报