8.线程管理

一.可靠性:线程的未捕获异常与监控

 如果线程的run方法抛出未被捕获的异常,那么随着run方法的退出,相应的线程也提前终止。对于线程的这种异常终止,可以通过Thread内部定义的UncaughtExceptionHandler接口来得知异常发生并作出补救动作。该接口只定义了一个方法:

void uncaughtException(Thread t, Throwable e);

 uncaughtException方法中两个参数包括了异常终止的线程本身(对应第1个参数)以及导致线程提前终止的异常(对应第2个参数)。那么,在uncaughtException方法当中就可以将异常终止的相关信息记录到日志文件中,甚至为异常终止线程创建并启动一个替代线程。设thread为任意一个线程,eh为任意一个UncaughtExceptionHandler实例,那么可以在启动thread通过调用thread.setUncaughtExceptionHandler(eh)来为thread关联一个UncaughtExceptionHandler。当thread抛出未被捕获的异常后,thread.run()返回,接着thread会在其终止调用eh.uncaughtException方法。

uncaughtException方法是执行在抛出异常的线程thread中的,在执行uncaughtException方法的时候线程thread还是活着的(Live),uncaughtExeption方法返回之后线程thread就终止了。

二.线程的高效利用:线程池

 线程是一种昂贵的资源,其开销主要包括:

  • 线程的创建与启动开销。与普通对象相比,Java线程还占用了额外的存储空间——栈空间。并且,线程的启动也会产生相应的线程调度开销。
  • 线程的销毁。线程的销毁也有其开销。
  • 线程调度的开销。线程的调度会导致上下文切换,从而增加处理器资源的消耗,使得应用程序本身可以使用的处理器资源减少。
  • 一个系统能够创建的线程总是受限于该系统所拥有的处理器数目。无论是CPU密集型还是I/O密集型线程,这些线程的数量临界值总是处理器的数目。

因此,从整个系统乃至整个主机的角度来看我们需要一种有效使用线程的方式。线程池就是有效使用线程的一种常见方式。

 线程池内部可以预先创建一定数量的工作者线程,客户端代码并不需要向线程池借用线程而是将其需要执行的任务作为一个对象提交给线程池,线程池可能将这些任务缓存在队列(工作队列)之中,而线程池内部的各个工作者线程则不断地从队列中取出任务并执行之。

             图2-1 线程池原理示意图

 java.util.concurrent.ThreadPoolExecutor类就是一个线程池,客户端代码可以调用ThreadPoolExecutor.submit方法向其提交任务。

 线程池内部维护的工作者线程的数量就被称为该线程的线程池大小(Pool Size)。ThreadPoolExecutor的线程池大小有3种形态:

  • 当前线程池大小(Current Pool Size)表示线程池中实际工作者线程的数量;
  • 最大线程池大小(Maximum Pool Size)表示线程池中允许存在的工作者线程上限;
  • 核心线程池大小(Core Pool Size)表示一个不大于最大线程池大小的工作者线程数量上限。

 除了当前线程池大小是对线程池中现有工作者线程进行计数的结果,其他有关线程池大小的概念实际上都是由开发人员或者系统配置指定的一个阈值(Threshold)。

 ThreadPoolExecutor的构造器中包含参数数量最多的一个构造器声明为:

public ThreadPoolExecutor(int corePoolSize,
                        int maximumPoolSize,
                        long keepAliveTime,
                        TimeUnit unit,
                        BlockingQueue<Runnable> workQueue,
                        ThreadFactory threadFactory,
                        RejectedExecutionHandler handler)
  • workQueue是被称为工作队列的阻塞队列,它相当于生产者——消费者模式中的传输通道;
  • corePoolSize用于指定核心线程池大小;
  • maximumPoolSize用于指定最大线程池大小;
  • keepAliveTime和unit合在一起用于指定线程池空闲(Idle)线程的最大存活时间。
  • threadFactory用于指定创建工作者线程的线程工厂。
  • handler用于指定被拒绝的任务的处理策略。

 在初始状态下,客户端每提交一个任务线程池就创建一个工作者线程来处理该任务。随着客户端不断地提交任务,当前线程池大小也相应增加。在当前线程池大小达到核心线程池大小的时候,新来的任务会被存入工作者队列之中。这些缓存的任务由线程池中的所有工作者线程负责取出进行执行。线程池将任务存入工作队列的时候调用的是BlockingQueue的非阻塞方法offer(E e),因此工作队列满并不会使提交任务的客户端线程暂停。当工作队列满的时候,线程池会继续创建新的工作者线程,直到当前线程池大小达到最大线程池大小。

 线程池是通过调用threadFactory.newThread方法来创建工作者线程的。如果在创建线程池的时候没有指定线程工厂(即调用了ThreadPoolExecutor的其他构造器),那么ThreadPoolExecutor会使用Executors.defaultThreadFactory()所返回的默认线程工厂。

 当线程池饱和(Saturated)时,即工作者队列满并且当前线程池大小达到最大线程池大小的情况下,客户端试图提交的任务会被拒绝(Reject)。为了提高线程池的可靠性,Java标准库引入了一个RejectedExecutionHandler接口用于封装被拒绝任务的处理策略,该接口仅定义了如下方法:

void rejectedExecution(Runnable r, ThreadPoolExecutor executor)

其中,r代表被拒绝的任务,executor代表拒绝任务r的线程池实例。可以通过线程池构造器参数handler或者线程池的setRejectedExecutionHandler(RejectedExecutionHandler handler)方法来为线程池关联一个RejectedExecutionHandler。当客户端提交的任务被拒绝时,线程池所关联的RejectedExecutionHandler的rejectedExecution方法会被线程池调用。ThreadPoolExecutor自身提供了几个现成的RejectedExecutionHandler接口实现类,AbortPolicy是默认策略,如果默认策略无法满足要求,优先考虑ThreadPoolExecutor自身提供的其他策略,其次才去考虑使用自行实现的策略。

表2-1 ThreadPoolExecutor提供的RejectedExecutionHandler实现类
实现类 所实现的处理策略
ThreadPoolExecutor.AbortPolicy 直接抛出异常
ThreadPoolExecutor.DiscardPolicy 丢弃当前被拒绝的任务(而不抛出任何异常)
ThreadPoolExecutor.DiscardOldestPolicy 将工作队列中最老的任务丢弃,然后重新尝试接纳被拒绝的任务
ThreadPoolExecutor.CallerRunsPolicy 在客户端线程中执行被拒绝的任务

 在当前线程池大小超过核心线程池大小的时候,超过核心线程池大小部分的工作者线程空闲(即工作者队列中没有待处理的任务)时间达到keepAliveTime所指定的时间后就会被清理掉,即这些工作者线程会自动终止并被从线程池中移除。这种空闲线程清理机制有利于节约有限的线程资源,但是keepAliveTime值设置不合理(特别是设置得太小)可能导致工作者线程频繁地被清理和创建反而增加了开销。

 线程池中数量上等于核心线程池大小的那部分工作者线程,习惯性称之为核心线程(Core Thread)。当前线程池大小是随着线程池接收到的任务数量而逐渐向核心线程池大小靠拢的,即核心线程是逐渐被创建与启动的。ThreadPoolExecutor.prestartAllCoreThreads()方法则可以使线程池在未接收到任何任务的情况下预先创建并启动所有核心线程,这样可以减少任务被线程池处理所需的等待时间(等待核心线程的创建与启动)。

 ThreadPoolExecutor.shutdown()/shutdownNow()方法可以用来关闭线程池。

  • 使用shutdown()关闭线程池的时候,已经提交的任务会被继续执行,而新提交的任务会像线程池饱和时那样被拒绝掉。ThreadPoolExecutor.shutdown()返回的时候线程池可能尚未关闭,即线程池中可能还有工作者线程正在执行任务。应用代码可以通过调用ThreadPoolExecutor.awaitTermination(long timeout, TimeUnit unit)来等待线程池关闭结束。
  • 使用shutdownNow()关闭线程池的时候,正在执行的任务会被停止,已提交而等待执行的任务也不会被执行。该方法返回的是已提交而未执行的任务列表。

由于ThreadPoolExecutor.shutdownNow()内部是通过调用工作者线程的interrupt方法来停止任务的,因此某些无法响应中断的任务可能永远也不会停止。

2-1 任务的处理结果、异常处理与取消

 如果客户端关心任务的处理结果,那么它可以使用ThreadPoolExecutor的有如下声明的submit方法:

public <T> Future<T> submit(Callable<T> task);

task参数代表客户端需要提交的任务,其类型为java.util.concurrent.Callable。Callable接口定义的唯一方法声明如下:

V call() throws Exception
  • Callable接口也是对任务的抽象:任务的处理逻辑可以在Callable接口实现类的call方法中实现。
  • Callable接口相当于一个增强型的Runnable接口:call方法的返回值代表相应任务的处理结果,其类型V是通过Callable接口的类型参数指定的。
  • call方法代表的任务在其执行过程中可以抛出异常。而Runnable接口中的run方法既无返回值也不能抛出异常。
  • Executors.callable(Runnable task, T result)能够将Runnable接口转换为Callable接口实例。

 上述submit方法的返回值类型为java.util.concurrent.Future。Future接口实例可被看作提交给线程池执行的任务的处理结果句柄(Handle),Future.get()方法可以用来获取task参数所指定任务的处理结果,get方法的声明为:

V get() throws InterruptedException, ExecutionException

 Future.get()被调用时,如果相应的任务尚未执行完毕,那么Future.get()会使当前线程暂停,直到相应的任务执行结束(包括正常结束和抛出异常而终止)。因此,Future.get()是个阻塞方法,该方法能够抛出InterruptedException说明它可以响应线程中断。

 假设相应的任务执行过程中抛出一个任意异常originalException,那么Future.get()方法本身就会抛出相应的ExecutionException异常。调用这个异常的(ExecutionException)的getCause()方法可返回originalException。

 由于在任务未执行完毕的情况下调用Future.get()方法来获取该任务的处理结果会导致等待并由此导致上下文切换,因此客户端代码应该尽可能早地向线程池提交任务,并尽可能晚地调用Future.get()方法获取任务的处理结果。

 Future接口还支持任务得取消。Future接口定义了如下方法:

boolean cancel(boolean mayInterruptIfRunning)
  • 该方法得返回值表示相应的任务取消是否成功。
  • 任务取消失败的原因包括待取消的任务已经执行完毕或者正在执行(任务在执行时,是否是mayInterruptIfRunning为false就一定取消失败)、已经被取消以及其他无法取消因素。
  • 参数mayInterruptIfRunning表示是否允许通过给相应任务的执行线程发送中断来取消任务。
  • Future.isCancelled()返回值代表相应的任务是否被成功取消。
  • 由于一个任务被成功取消之后,相应的Future.get()调用会抛出CancellationException异常(运行时异常),因此如果任务有可能被取消,那么在获取任务的处理结果时对CancellationException异常做处理。

 Future.isDone()方法可以检测相应的任务是否执行完毕。①任务执行完毕②执行过程中抛出异常以及③任务被取消都会导致该方法返回true。

 Future.get()会使其执行线程无限制地等待,直到相应的任务执行结束。这种无限制的等待一般是不现实的,可以使用可以设置超时时间的get方法,其声明为:

V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException

 该方法的作用与Future.get()相同,但它允许指定一个等待时间。如果在该时间内相应的任务未执行结束,那么该方法就会抛出TimeoutException。

  • 由于该方法参数中指定的超时时间仅仅用于控制客户端线程(即该方法的执行线程)等待相应任务的处理结果最多会等待多长时间,而非相应任务本身的执行时间限制,因此,客户端线程通常需要在捕获到TimeoutException之后执行Future.cancel(true)来取消相应任务的执行(因此此时已经不再需要该任务的处理结果了)。

2-2 线程池监控

表2-2-1 ThreadPoolExecutor类提供了对线程池进行监控的相关方法
方法 用途
getPoolSize() 获取当前线程池大小
getQueue() 返回工作队列实例,通过该实例可获取工作队列的当前大小
getLargestPoolSize() 获取工作者线程数曾经达到过的最大数,该数值有助于确认最大线程池大小设置是否合理
getActiveCount() 获取线程池中当前正在执行任务的工作者线程数(近似值)
getTaskCount() 获取线程池到目前为止所接收到的任务数(近似值)
getCompletedTaskCount() 获取线程池到目前为止已经处理完毕的任务数(近似值)

 

 此外,ThreadPoolExecutor提供的两个钩子方法(Hook Method):beforeExecute(Thread t, Runnable r)和afterExecute(Thread t, Runnable r)也能够用于实现监控。设executor为任意一个ThreadPoolExecutor实例,在任意一个任务r被线程池executor中的任意一个工作者线程t执行前,executor.beforeExecute(t, r)会被执行;当t执行完r之后,不管r的执行是否是成功的还是抛出了异常,executor.afterExecute(t, r)始终会被执行。因此,如果有必要的话可以通过创建ThreadPoolExecutor的子类并在子类的beforeExecute/afterExecute方法实现监控逻辑,比如计算任务执行的平均耗时。

2-3 线程池死锁

 如果线程池中执行的任务在其执行过程中又会向同一个线程池提交另外一个任务,而前一个任务的执行结束又依赖于后一个任务的执行结果,那么就有可能出现这样的情形:线程池中所有工作者线程都处于等待其他任务的处理结果而这些任务仍在工作队列中等待执行,这使由于线程池中已经没有可以对工作队列中的任务进行处理的工作者线程,这种等待就会一直持续下去从而形成死锁。

 因此,适合提交给同一线程池实例执行的任务是相互独立的任务,而不是彼此有依赖关系的任务。对于彼此存在依赖关系的任务,可以考虑使用不同的线程池实例来执行这些任务。

2-4 工作者线程的异常终止

 如果任务是通过ThreadPoolExecution.submit调用提交给线程池的,那么这些任务在其执行过程中即便是抛出了未捕获的异常不会导致对其执行的工作者线程异常终止。此时可通过Future.get()所抛出的ExecutionException来获取异常。

 如果任务是通过ThreadPoolExecution.execute调用提交给线程池的,那么这些任务在其执行过程中一旦抛出了未捕获的异常,则对其进行执行的工作者线程就会异常终止。尽管ThreadPoolExecutor能够侦测到这种情况并在工作者线程异常终止的时候创建并启动新的替代工作者线程,但是由于线程的创建与启动都有其开销,因此会尽量避免任务在其执行过程中抛出未捕获的异常。

 可以通过ThreadPoolExecutor的构造器参数或者ThreadPoolExecutor.setThreadFactory方法为线程关联一个线程工厂。在这个线程工厂里可以为其创建的线程关联一个UncaughtExceptionHandler,通过这个关联的UncaughtExceptionHandler,可以侦测到任务执行过程中抛出的未捕获的异常。

由于ThreadPoolExecutor内部实现的原因,只有通过ThreadPoolExecutor.execute调用(而不是ThreadPoolExecutor.submit调用)提交给线程池执行的任务,其执行过程中抛出的未捕获的异常才会导致UncaughtExceptionHandler.uncaughtException方法被调用。

 

posted @ 2023-02-01 14:50  certainTao  阅读(49)  评论(0编辑  收藏  举报