Java并发(11)- 有关线程池的10个问题

引言

在日常开发中,线程池是使用非常频繁的一种技术,无论是服务端多线程接收用户请求,还是客户端多线程处理数据,都会用到线程池技术,那么全面的了解线程池的使用、背后的实现原理以及合理的优化线程池的大小等都是非常有必要的。这篇文章会通过对一系列的问题的解答来讲解线程池的基本功能以及背后的原理,希望能对大家有所帮助。

  • 举个例子来说明为什么要使用线程池,有什么好处?
  • jdk1.8中提供了哪几种基本的线程池?
  • 线程池几大组件的关系?
  • ExecutorService的生命周期?
  • 线程池中的线程能设置超时吗?
  • 怎么取消线程池中的线程?
  • 如何设置一个合适的线程池大小?
  • 当使用有界队列时,如何设置一个合适的队列大小?
  • 当使用有界队列时,如果队列已满,如何选择合适的拒绝策略?
  • 如何统计线程池中的线程执行时间?

举个例子来说明为什么要使用线程池,有什么好处?

先来看这样一个场景,服务端在一个线程内通过监听8888端口来接收多个客户端的消息。为了避免阻塞主线程,每收到一个消息,就开启一个新的线程来处理,这样主线程就可以不停的接收新的消息。不使用线程池时代码的简单实现如下:

public static void main(String[] args) throws IOException {
    ServerSocket serverSocket = new ServerSocket(8888);

    while (true) {
        try {
            Socket socket = serverSocket.accept();

            new Thread(() -> {
                try {
                    InputStream inputStream = socket.getInputStream();
                    //do something
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }).start();

        } catch (IOException e) {
        }
    }
}

通过每次new一个新的线程的方式,不会阻塞主线程,提高了服务端接收消息的能力。但是存在几个非常明显的问题:

  • 不停初始化线程的内存消耗,任何时候资源都是有限的,无限制的新建线程会占用大量的内存空间。
  • 在CPU资源有限的情况下,新建更多的线程不仅不能达到并发处理客户端消息的目的,相反由于线程间的切换更加频繁,会导致处理时间更长,效率更加低下。
  • 线程本身的创建与销毁都需要耗费服务器资源。
  • 不方便对线程进行集中管理。
    而这些问题都是可以通过使用线程池得倒解决的。

jdk1.8中提供了哪几种基本的线程池以及它们的使用场景?

  • newFixedThreadPool,固定线程数的线程池。它的核心线程数(corePoolSize)和最大线程数(maximumPoolSize)是相等的。同时它使用一个无界的阻塞队列LinkedBlockingQueue来存储额外的任务,也就是说当达到nThreads的线程数在运行之后,所有的后续线程都会进入LinkedBlockingQueue中,不会再创建新的线程。

    使用场景:因为线程数固定,一般适用于可预测的并行任务执行环境。

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>());
}
  • newCachedThreadPool,可缓存线程的线程池。默认核心线程数(corePoolSize)为0,最大线程数(maximumPoolSize)为Integer.MAX_VALUE,它还有一个过期时间为60秒,当线程闲置超过60秒之后会被回收。内部使用SynchronousQueue作为阻塞队列。

    使用场景:由于SynchronousQueue无容量的特性,导致了newCachedThreadPool不适合做长时间的任务。因为如果单个任务执行时间过长,每当无空闲线程时,会导致开启新线程,而线程数量可以达到Integer.MAX_VALUE,存储队列又不能缓存任务,很容易导致OOM的问题。所以他的使用场景一般在大量短时间任务的执行上。

    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
  • newSingleThreadExecutor,单线程线程池。默认核心线程数(corePoolSize)和最大线程数(maximumPoolSize)都为1,使用无界阻塞队列LinkedBlockingQueue。

    使用场景:由于只能有一个线程在执行,而且其他任务都会排队,适用于单线程串行执行有序任务的环境。

    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }
  • newScheduledThreadPool与newSingleThreadScheduledExecutor,执行延时或者周期性任务的线程池,使用了一个内部实现的DelayedWorkQueue阻塞队列。可以看到它的返回结果是ScheduledExecutorService,它扩展了ExecutorService接口,提供了用于延时和周期执行任务的方法。

    使用场景:用于延时启动任务,或需要周期性执行的任务。

    public static ScheduledExecutorService newSingleThreadScheduledExecutor() {
        return new DelegatedScheduledExecutorService
            (new ScheduledThreadPoolExecutor(1));
    }

    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }

    public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }
  • newWorkStealingPool,它是jdk1.8提供的一种线程池,用于执行并行任务。默认并行级别为当前可用最大可用cpu数量的线程。

    使用场景:用于大耗时同时可以分段并行的任务。

    public static ExecutorService newWorkStealingPool() {
        return new ForkJoinPool
            (Runtime.getRuntime().availableProcessors(),
             ForkJoinPool.defaultForkJoinWorkerThreadFactory,
             null, true);
    }

线程池几大组件的关系?

线程池简单来说可以分为四大组件:Executor、ExecutorService、Executors以及ThreadPoolExecutor。

  • Executor接口定义了一个以Runnable为参数的execute方法。这也是对线程池框架的一个抽象,它将线程池能做什么和具体怎么做拆分开来,也可以看做是一个生产者和消费者模式,Executor负责生产任务,具体的线程池负责消费任务,让使用者能够更加灵活的切换线程池具体策略,它也是线程池多样性的基础。
public interface Executor {
    void execute(Runnable command);
}
那么在ThreadPoolExecutor中,是怎么实现execute方法的呢?来看下ThreadPoolExecutor中execute方法的源码,里面的注释实在太详细了,简直时良好注释的典范。这里只做个简单总结:首先当工作线程小于核心线程数时会尝试添加worker到队列中去运行,如果核心线程不够用会将任务加入队列中,如果入队也不成功,会采取拒绝策略。
public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    /*
        * Proceed in 3 steps:
        *
        * 1. If fewer than corePoolSize threads are running, try to
        * start a new thread with the given command as its first
        * task.  The call to addWorker atomically checks runState and
        * workerCount, and so prevents false alarms that would add
        * threads when it shouldn't, by returning false.
        *
        * 2. If a task can be successfully queued, then we still need
        * to double-check whether we should have added a thread
        * (because existing ones died since last checking) or that
        * the pool shut down since entry into this method. So we
        * recheck state and if necessary roll back the enqueuing if
        * stopped, or start a new thread if there are none.
        *
        * 3. If we cannot queue task, then we try to add a new
        * thread.  If it fails, we know we are shut down or saturated
        * and so reject the task.
        */
    //ctl通过位运算同时标记了线程数量以及线程状态
    int c = ctl.get();
    //workerCountOf方法用来统计当前运行的线程数量
    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);
    }
    else if (!addWorker(command, false))
        reject(command);
}
  • ExecutorService接口继承自Executor接口,提供了更加完善的线程池控制功能。并将线程池的状态分为运行中,关闭,终止三种。同时提供了带返回值的提交,方便更好的控制提交的任务。
public interface ExecutorService extends Executor {
    //关闭线程池,关闭状态
    void shutdown();
    //立即关闭线程池,关闭状态
    List<Runnable> shutdownNow();
    
    boolean isShutdown();
    
    boolean isTerminated();
    //提交一个Callable类型的任务,带Future返回值
    <T> Future<T> submit(Callable<T> task);
    //提交一个Runnable类型的任务,带Future返回值
    Future<?> submit(Runnable task);
    //一段时间后终止线程池,终止状态
    boolean awaitTermination(long timeout, TimeUnit unit)
        throws InterruptedException;
    ......
}
还是通过ThreadPoolExecutor来说明,ThreadPoolExecutor中将线程池状态进行了扩展,定义了5种状态,这5种状态通过Integer.SIZE的高3位来表示。代码如下:
* The runState provides the main lifecycle control, taking on values:
*   能够接受新任务也能处理队列中的任务
*   RUNNING:  Accept new tasks and process queued tasks
*   不能接受新任务,但能处理队列中的任务
*   SHUTDOWN: Don't accept new tasks, but process queued tasks
    不能接受新任务,也不能处理队列中的任务,同时会中断正在执行的任务
*   STOP:     Don't accept new tasks, don't process queued tasks,
*             and interrupt in-progress tasks
    所有的任务都被终止,工作线程为0
*   TIDYING:  All tasks have terminated, workerCount is zero,
*             the thread transitioning to state TIDYING
*             will run the terminated() hook method
    terminated方法执行完成
*   TERMINATED: terminated() has completed
private static final int COUNT_BITS = Integer.SIZE - 3;

private static final int RUNNING    = -1 << COUNT_BITS;//101
private static final int SHUTDOWN   =  0 << COUNT_BITS;//000
private static final int STOP       =  1 << COUNT_BITS;//001
private static final int TIDYING    =  2 << COUNT_BITS;//010
private static final int TERMINATED =  3 << COUNT_BITS;//011
再来看看通过ExecutorService接口对这5种状态的转换:
public interface ExecutorService extends Executor {
    //关闭线程池,线程池状态会从RUNNING变为SHUTDOWN
    void shutdown();
    //立即关闭线程池RUNNING或者SHUTDOWN到STOP
    List<Runnable> shutdownNow();
    //STOP、TIDYING以及TERMINATED都返回true
    boolean isShutdown();
    //TERMINATED状态返回true
    boolean isTerminated();
    //一段时间后终止线程池,TERMINATED
    boolean awaitTermination(long timeout, TimeUnit unit)
        throws InterruptedException;
    ......
}
  • Executors提供了一系列获取线程池的静态方法,相当于线程池工厂,是对ThreadPoolExecutor的一个封装,简化了用户切换Executor和ExecutorService的各种实现细节。

  • ThreadPoolExecutor是对Executor以及ExecutorService的实现,提供了具体的线程池实现。

ExecutorService的生命周期?

这个问题在上面已经做了解说,ExecutorService的生命周期通过接口定义可以分为运行中,关闭,终止三种状态。

ThreadPoolExecutor在具体实现上提供了更加详细的五种状态:RUNNING、SHUTDOWN、STOP、TIDYING以及TERMINATED。各种状态的说明以及转换可以看上一个问题的答案。

线程池中的线程能设置超时吗?

线程池中的线程是可以进行超时控制的,通过ExecutorService的submit来提交任务,这样会返回一个Future类型的结果,来看看Future接口的代码:

public interface Future<V> {
    
    boolean cancel(boolean mayInterruptIfRunning);

    boolean isCancelled();

    boolean isDone();
    //获取返回结果,并在出现错误或者中断时throws Exception
    V get() throws InterruptedException, ExecutionException;
    //timeout时间内获取返回结果,并在出现错误、中断以及超时时throws Exception
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

Future定义了get()以及get(long timeout, TimeUnit unit)方法,get()方法会阻塞当前调用,一直到获取到返回结果,get(long timeout, TimeUnit unit)会在指定时间内阻塞,当超时后会抛出TimeoutException错误。这样就可以达到线程超时控制的目的。简单使用示例如下:

Future<String> future = executor.submit(callable);
try {
    future.get(2000, TimeUnit.SECONDS);
} catch (InterruptedException e1) {
    //中断后处理
} catch (ExecutionException e1) {
    //抛出异常处理
} catch (TimeoutException e1) {
    //超时处理
}

这里有一个问题就是因为get方法是阻塞的---通过LockSupport.park实现,那么线程池中线程比较多的情况下要怎么获取每个线程的超时呢?这里除了自定义线程池实现或者自定义线程工厂来实现之外,使用ThreadPoolExecutor本身的功能我也没想到更好的办法。有一个非常笨的解决方案是开启同线程池数量相等的线程进行监听。大家如果有更好的办法可以留言提出。

怎么取消线程池中的线程?

这个问题和上面的问题解决方案一样,同样也是通过ExecutorService的submit来提交任务,获取Future,调用Future中的cancel方法来达到目的。

public interface Future<V> {
    boolean cancel(boolean mayInterruptIfRunning);
}

cancel方法有一个mayInterruptIfRunning参数,当为true时,代表任务能接受并处理中断,调用了interrupt方法。如果为false,代表如果任务没启动就不要运行它,不会调用interrupt方法。

取消的本质实际上还是通过interrupt来实现的,这就是说,如果线程本身不能响应中断,就算调用了cancel方法也没用。一般情况下通过lockInterruptibly、park和await方法阻塞的线程都是能响应中断的,运行中的线程就需要开发者自己实现中断了。

如何设置一个合适的线程池大小?

如何设置一个合适的线程池大小,这个问题我觉得是没有一个固定公式的。或者可以说,只有一些简单的设置规则,但放到具体业务中,又各有不同,只能根据现场环境测试过后再来分析。

设置合适的线程池大小分为两部分,一部分是最大线程池大小,一部分是最小线程池大小。在ThreadPoolExecutor中体现在最大线程数(maximumPoolSize)和核心线程数(corePoolSize)。

最大线程池大小的设置首先跟当前机器cpu核心数密切相关,一般情况来说要想最大化利用cpu,设置为cpu核心数就可以了,比如4核cpu服务器可以设置为4。但实际情况又大有不同,因为往往我们执行的任务都会涉及到IO,比如任务中执行了一个从数据库查询数据的操作,那么这段时间cpu实际上是没有最大化利用的,这样我们就可以适当扩大maximumPoolSize的大小。在有些情况下任务会是cpu密集型的,如果这样设置更多的线程不仅不会提高效率,反而因为线程的创建销毁以及切换开销而大大降低了效率,所以说最大线程池的大小需要根据业务情况具体测试后才能设置一个合适的大小。

最小线程池大小相比较最大线程池大小设置起来相对容易一些,因为最小线程一般来说是可以根据业务情况来预估进行设置,比如大多数情况下会有2个任务在运行,很小概率会有超过2个任务运行,那么直接设置最小线程池大小为2就可以。但有一点需要知道的是每间隔多长时间会有超过2个任务,如果每2分钟会有一次超过2个任务的情况,那么我们可以将线程过期时间设置的稍微久一点,比如4分钟,这样就算频繁的超过2个任务,也可以利用缓存的线程池。

总的来说设置最大和最小线程池都是一个没有固定公式的问题,都需要考虑实际业务情况和机器配置,根据实际业务情况多做测试才能做到最优化设置。在一切没有决定之前,可以使用软件架构的KISS原则,设置最大以及最小线程数都为cpu核心数即可,后续在做优化。

当使用有界队列时,如何设置一个合适的队列大小?

要设置合适的队列大小,先要明白队列什么时候会被使用。在ThreadPoolExecutor的实现中,使用队列的情况有点特殊。它会先使用核心线程池大小的线程,之后会将任务加入队列中,再之后队列满了之后才会扩大到最大线程池大小的线程。也就是说队列的使用并不是等待线程不够用了才使用,而是等待核心线程不够用了就使用。我不是太能理解这样设计的意图,按《Java性能权威权威指南》一书中的说法是这样提供了两个节流阀,第一个是队列,第二个是最大线程池。但这样做并不能给使用者最优的体验,既然要使用最大线程池,那为什么不在第一次就使用呢?

知道了ThreadPoolExecutor使用线程池的时机,那么再来预估合适的队列大小就很方便了。如果单个任务执行时间在100ms,最小线程数是2,使用者能忍受的最大延时在2s,那么我们可以这样简单推算出队列大小:2/2s/100ms=10,这样满队列时最大延时就在2s之内。当然还有其他一些影响因素,比如部分任务超过或者小于100ms,最大线程池的利用等等,可以在这基础上做简单调整。

当使用有界队列时,如果队列已满,如何选择合适的拒绝策略?

ThreadPoolExecutor中提供了四种RejectedExecutionHandler,每种分工都比较明确,选择起来并不困难。它们分别是:AbortPolicy、DiscardPolicy、DiscardOldestPolicy以及CallerRunsPolicy。下面贴出了他们的源码并做了简单说明,使用的时候可以根据需要自行选择。

//AbortPolicy
//默认的拒绝策略,直接抛出RejectedExecutionException异常供调用者做后续处理
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    throw new RejectedExecutionException("Task " + r.toString() +
                                            " rejected from " +
                                            e.toString());
}

//DiscardPolicy
//不做任何处理,将任务直接抛弃掉
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
}

//DiscardOldestPolicy
//抛弃队列中的下一个任务,然后尝试做提交。这个使用我觉得应该是在知道当前要提交的任务比较重要,必须要被执行的场景
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    if (!e.isShutdown()) {
        e.getQueue().poll();
        e.execute(r);
    }
}

//CallerRunsPolicy
//直接使用调用者线程执行,相当于同步执行,会阻塞调用者线程,不太友好感觉。
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    if (!e.isShutdown()) {
        r.run();
    }
}

如何统计线程池中的线程执行时间?

要统计线程池中的线程执行时间,就需要了解线程池中的线程是在什么地方,什么时机执行的?知道了线程的执行状态,然后在线程执行前后添加自己的处理就可以了,所以先来找到ThreadPoolExecutor中线程具体执行的代码:

final void runWorker(Worker w) {
    Thread wt = Thread.currentThread();
    Runnable task = w.firstTask;
    w.firstTask = null;
    w.unlock(); // allow interrupts
    boolean completedAbruptly = true;
    try {
        while (task != null || (task = getTask()) != null) {
            w.lock();
            // If pool is stopping, ensure thread is interrupted;
            // if not, ensure thread is not interrupted.  This
            // requires a recheck in second case to deal with
            // shutdownNow race while clearing interrupt
            if ((runStateAtLeast(ctl.get(), STOP) ||
                    (Thread.interrupted() &&
                    runStateAtLeast(ctl.get(), STOP))) &&
                !wt.isInterrupted())
                wt.interrupt();
            try {
                beforeExecute(wt, task); //执行task.run()的前置方法
                Throwable thrown = null;
                try {
                    task.run();
                } catch (RuntimeException x) {
                    thrown = x; throw x;
                } catch (Error x) {
                    thrown = x; throw x;
                } catch (Throwable x) {
                    thrown = x; throw new Error(x);
                } finally {
                    afterExecute(task, thrown);//执行task.run()的后置方法
                }
            } finally {
                task = null;
                w.completedTasks++;
                w.unlock();
            }
        }
        completedAbruptly = false;
    } finally {
        processWorkerExit(w, completedAbruptly);
    }
}

可以看到runWorker方法中在task.run()也就是任务执行前后分别执行了beforeExecute以及afterExecute方法,着两个方法在ThreadPoolExecutor的继承类中都是可重写的,提供了极大的灵活性。我们可以在继承ThreadPoolExecutor之后在任务执行前后做任何自己需要做的事情,当然也就包括任务执行的时间统计了。

顺便说一句,熟悉spring源码的同学看到这里是不是发现和spring中的postprocesser前后置处理器有异曲同工之妙?区别在于一个是通过继承来覆盖,一个是通过接口来实现。

总结

其实线程池框架涉及到的问题远不止这些,包括ThreadFactory、ForkJoinPool等等还有很多值得花时间研究的地方。本文也只是阅读jdk源码、《Java并发编程实战》以及《Java性能优化权威指南》之后的一点点总结,如有错误遗漏的地方,希望大家能多多指出。

参考资料:

  • 《Java并发编程实战》
  • 《Java性能优化权威指南》
posted @ 2018-11-09 07:30  knock_小新  阅读(4150)  评论(1编辑  收藏  举报