【Java学习笔记-并发编程】线程池

前言

在上篇整理了 Java 线程与任务的概念,这篇说一说 Java 的线程池,在我们更加了解线程池的同时,也增加一些设计思路。

传送门:

【Java学习笔记-基础篇】关于Java多线程-线程与任务

一、Java 线程池的出现

如果现在我们需要对并发的场景做性能优化,我们该从哪方面入手呢?显而易见,最简单的一个角度:节省线程创建与销毁的开销

无论是IO密集应用还是计算密集应用,线程的管理都是需要成本的,所以从计算机的角度出发,我们还应该 限制线程的数量。创建线程的目的是使应用拥有更好的性能,而如果悬挂的线程过多,线程管理成本过高,应用的性能同样会下降。

对于以上两点需求,线程池就诞生了。

首先给大家看一下本文接口与实现类的关系:

在这里插入图片描述

二、Java 线程池的基础接口

Executor 与 ExecutorService

这是两个线程池的基础接口,ExecutorService 继承了 Executor。一般情况下,运用 ExecutorService 的实现比较多,因为拥有更完整的规范。先看一下这两个接口:

public interface Executor {
    
    

    void execute(Runnable command);
    
}

与 Runnable 接口类似,定义非常简单,将任务实现放入执行器,执行(execute)任务。

public interface ExecutorService extends Executor {
    
    

	//关闭控制类函数
    void shutdown();
    List<Runnable> shutdownNow();
    boolean isShutdown();
    boolean isTerminated();
    boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;

	//运行类函数
    <T> Future<T> submit(Callable<T> task);
    <T> Future<T> submit(Runnable task, T result);
    Future<?> submit(Runnable task);
	
	//批量运行类函数
    <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException;
    <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException;
    <T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException;
    <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
}

在这里,我们可以看到线程池的雏形,为了更好的管理线程,实现线程池所需要的基本功能。

虽然 ExecutorService 的规范比 Executor 多了一些,但是总结无非就是三类:

  • 关闭等控制类函数(池级别)
  • 运行类函数(线程级别)
  • 批量运行类型函数(第二种的扩展)

这里我们可以留意一下这两个接口:

    <T> Future<T> submit(Callable<T> task);
    <T> Future<T> submit(Runnable task, T result);

感觉是不是很熟悉,这里先按下不表,下一节就能知道这里为什么要这样设计。

三、Java 线程池的基础实现

AbstractExecutorService

这个抽象类阶段性的实现了 ExecutorService 接口,在这个类中,明确了一些实现线程池的思路与步骤。

首先来看一下任务的基础实现与单任务的执行:

  • 线程池的基础任务类型为 FutureTask
  • 提交任务函数需要有2个步骤:新建任务对象 -> 执行器执行任务。
    protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
    
    
        return new FutureTask<T>(runnable, value);
    }

    protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
    
    
        return new FutureTask<T>(callable);
    }

    public Future<?> submit(Runnable task) {
    
    
        if (task == null) throw new NullPointerException();
        RunnableFuture<Void> ftask = newTaskFor(task, null);
        execute(ftask);
        return ftask;
    }

    public <T> Future<T> submit(Runnable task, T result) {
    
    
        if (task == null) throw new NullPointerException();
        RunnableFuture<T> ftask = newTaskFor(task, result);
        execute(ftask);
        return ftask;
    }

至此,我们理解了 submit 函数看起来为什么那么眼熟,是因为秉承着和 FutureTask 一样的逻辑,所以也提供了两个整齐的接口。

下面我们来看一下批量运行的处理方法,invokeAny 涉及到线程管理的调度,这里先按下不表,我们先看 invokeAll 中的实现:

    public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
        throws InterruptedException {
    
    
        if (tasks == null)
            throw new NullPointerException();
        ArrayList<Future<T>> futures = new ArrayList<>(tasks.size());
        try {
    
    
            for (Callable<T> t : tasks) {
    
    
                RunnableFuture<T> f = newTaskFor(t);
                futures.add(f);
                execute(f);
            }
            for (int i = 0, size = futures.size(); i < size; i++) {
    
    
                Future<T> f = futures.get(i);
                if (!f.isDone()) {
    
    
                    try {
    
     f.get(); }
                    catch (CancellationException | ExecutionException ignore) {
    
    }
                }
            }
            return futures;
        } catch (Throwable t) {
    
    
            cancelAll(futures);
            throw t;
        }
    }

可以看到,取值是对整个 list 的一个遍历处理,因为是按 list 顺序 get 结果,所以其完成时间由 最慢任务决定

到此为止,我们已经对线程池有了初步的认知,下面来看一下 Java 是怎么实现一个完整线程池的。

ThreadPoolExecutor

首先,我们可以通过构造方法来看一看,线程池的初始化最少需要什么元素。

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue) {
    
    
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), defaultHandler);
    }

参数说明:

  • corePoolSize:线程池核心线程数。加入一个任务时,如果当前线程数(空闲+非空闲)小于 corePoolSize,即便当前有空闲的线程,也会创建新的线程来执行任务。如果当前线程数(空闲+非空闲)等于 corePoolSize,则不再重新创建线程。
  • maximumPoolSize:线程池最大线程数。如果阻塞队列已满,并且当前线程数(空闲+非空闲)小于等于 maximumPoolSize ,就会创建新的线程来执行任务。
  • keepAliveTime:如果当前线程数(空闲+非空闲)大于 corePoolSize ,并且空闲时间大于 keepAliveTime的话,就会将这些空闲线程销毁,释放资源。
  • unit:时间的单位。
  • workQueue:阻塞队列的实例。
  • ThreadFactory : 线程工厂类,一般使用默认工厂。
  • RejectedExecutionHandler:饱和策略。在任务过多时,对任务的处理策略。
1. 线程池的状态 —— 位运算

为了提升运行的效率,减少开销,我们在记录一些属性值的时候,会用到 bit 位的操作,即位运算。(比如算法课上要用哈夫曼树实现压缩,就需要 bit 位记录源文件的各种属性数据)。

如果不清楚位运算等概念的同学可以移步:

java中的 位运算 和 移位运算详解
原码、反码、补码知识详细讲解

我们来看一下,ThreadPollExecutor 是如何运用一个 int 来记录两项属性的:

    private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
    //能存放 32-3 位的活跃线程数量。
    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(3 bits free)
    // 在高位存储线程池状态
    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;

    // Packing and unpacking ctl
    private static int runStateOf(int c)     {
    
     return c & ~COUNT_MASK; }
    private static int workerCountOf(int c)  {
    
     return c & COUNT_MASK; }
    private static int ctlOf(int rs, int wc) {
    
     return rs | wc; }
2. 生产消费模型 —— BlockingQueue(阻塞队列)

在 ThreadPoolExecutor 中,管理保存任务的数据结构就是阻塞队列。其中,生产的方式用的是 offer ,消费的方式是 poll 与 take。

public interface BlockingQueue<E> extends Queue<E> {
    
    

	//……………………

    boolean offer(E e);

    boolean offer(E e, long timeout, TimeUnit unit)
        throws InterruptedException;
	//如果取出元素后,队列为空,一直阻塞等待。
    E take() throws InterruptedException;
    //如果取出元素后,队列为空,则阻塞超时后返回null。
    E poll(long timeout, TimeUnit unit)
        throws InterruptedException;

	//……………………
	
}

接口规范中,很容易看到 poll 与 take 的区别。看一下在线程池中的应用:

    public void execute(Runnable command) {
    
    
		//……………………
        //向队列生产任务
        if (isRunning(c) && workQueue.offer(command)) {
    
    
            //……………………
        }
        //……………………
    }

    private Runnable getTask() {
    
    
			//……………………
			boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
            try {
    
    
            //依据元素是否是具备时间(timed)属性,而选择消费任务的方式。
                Runnable r = timed ?
                	//如果queue is null,即没有可执行任务,则阻塞超时后 return null。
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
                if (r != null)
                    return r;
                timedOut = true;
            } catch (InterruptedException retry) {
    
    
                timedOut = false;
            }
        }
    }

Tips:
依据元素是否是具备时间(timed)属性,而选择消费任务的方式。
如果是 poll 消费方式,则元素具备超时属性(具体可以看BlockingQueue的源码),会在取值处阻塞,直到达到超时(空闲)时间,返回null,在 runWorker() 函数中,调用销毁线程方法 processWorkerExit ,从而达到:线程数 > corePoolSize 时,空闲线程按时回收。
而 take 消费方式,则会一直阻塞等待,对于 getTask() 而言,总有返回值,即不会销毁线程。从而达到:线程数 <= corePoolSize 时,空闲线程不回收。

3. 任务执行者 —— Worker

在 ThreadPoolExecutor 中,有一个 Worker 的内部类。简单来说就是任务(runnable的实现 —— task)的容器,可以粗暴的理解为一个线程,但是不完全相同。

Worker 是什么

实际上 Worker 是一个任务容器,并且构造方法非常简单:

    private final class Worker extends AbstractQueuedSynchronizer
        implements Runnable
    {
    
    
        Worker(Runnable firstTask) {
    
    
            setState(-1); // inhibit interrupts until runWorker
            this.firstTask = firstTask;
            this.thread = getThreadFactory().newThread(this);
        }
    }

创建一个新的线程对象,并向其中添加一个任务。

Executor 如何管理 Worker

首先 Executor 是通过一个 set 来存储 Worker 的,确保每个 worker 的唯一性。

	private final HashSet<Worker> workers = new HashSet<>();

其次,对于线程池而言,最重要的就是 addWorker 和 runWorker 了。这里就不贴源码了,简单说一下这两个函数的区别与用途。

addWorker() 函数是 真正创建 Worker 实例并开启线程的地方 ,因为其中找到了 thread.start() 函数。稍微检索一下,我们就可以看到 execute() 函数里面调用了 addWorker,所以可以得知:线程池如果要执行任务,就需要调用execute() 函数。

runWorker() 函数实际上就是 Worker 的 run,(补充说明)。

4. 执行函数 —— execute

这里的代码,揭示了线程池线程池在线程数不同情况下的处理策略。

在这里插入图片描述
上图摘自:https://thinkwon.blog.csdn.net/article/details/102541900

    public void execute(Runnable command) {
    
    
        if (command == null)
            throw new NullPointerException();
        int c = ctl.get();

		//池中现有线程个数(空闲+非空闲) < 核心线程数
        if (workerCountOf(c) < corePoolSize) {
    
    
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        
        //池中现有线程个数(空闲+非空闲) >= 核心线程数,或线程创建失败,放入阻塞队列。
        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);
        }

		//阻塞队列满,新建线程执行任务知道 max,如果创建失败或任务超过max,则启用饱和策略——中断。
        else if (!addWorker(command, false))
            reject(command);
    }
5. 饱和策略 —— handler

饱和策略是用于处理在线程异常与线程数已满的情况下,处理当前任务的处理方式。大家可以稍作了解。

  • AbortPolicy: 中止执行提交的任务,抛出 RejectedExecutionException 异常;这是线程池中默认的策略。
    	//默认饱和策略
    	private static final RejectedExecutionHandler defaultHandler =
        	new AbortPolicy();

		//实现在池子中的内部类
	    public static class AbortPolicy implements RejectedExecutionHandler {
    
    

        public AbortPolicy() {
    
     }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    
    
            throw new RejectedExecutionException("Task " + r.toString() +
                                                 " rejected from " +
                                                 e.toString());
        }
    }
  • CallerRunsPolicy:用调用者所在的线程来执行任务,即在调用者视角,此任务串行在调用者的主线程中。
    public static class CallerRunsPolicy implements RejectedExecutionHandler {
    
    

        public CallerRunsPolicy() {
    
     }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    
    
            if (!e.isShutdown()) {
    
    
                r.run();
            }
        }
    }
  • DiscardPolicy:不处理直接丢弃掉任务(执行空函数);
    public static class DiscardPolicy implements RejectedExecutionHandler {
    
    
    
        public DiscardPolicy() {
    
     }
        
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    
    
        
        }
    }
  • DiscardOldestPolicy:丢弃阻塞队列中头部任务(即存放最久的任务),执行当前任务。
    public static class DiscardOldestPolicy implements RejectedExecutionHandler {
    
    

        public DiscardOldestPolicy() {
    
     }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    
    
            if (!e.isShutdown()) {
    
    
                e.getQueue().poll();
                e.execute(r);
            }
        }
    }

Tips:
实际上,Java 的饱和策略算是抛砖引玉,在业务场景中,可以针对特定的业务功能,自定义饱和策略。这才是关键。

四、为什么要使用线程池

经过以上了解,我们可以总结出线程池的价值:

  • 降低资源消耗。通过复用已存在的线程,降低创建和销毁线程的资源消耗;
  • 提高线程的可管理性。线程是稀缺资源,不能无限增生,过多的线程也会给计算机造成负担。

猜你喜欢

转载自blog.csdn.net/weixin_43742184/article/details/113736775