Java并发编程实战之 取消与关闭、线程池的使用

第六章 取消与关闭

6.1 任务取消

协作机制能设置某个标志位,任务会定期查看这个标志,如果设置了标志,那么任务将提前结束。

如果外部代码能在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以称为可取消的。

如何取消一般有两种方法:

  1. 使用状态变量。线程中循环遍历状态变量,检测是否需要结束当前线程。
  2. 使用中断。系统提供的大多数阻塞方法会相应中断Thread.interrupt:清除中断状态Thread.isinterrupted;抛出InterruptedException异常;表示阻塞操作由于中断而提前结束。

通常,中断是实现取消的最合理方式。

6.2 Java 中断机制

当我们点击某个杀毒软件的取消按钮来停止查杀病毒时,当我们在控制台敲入quit命令以结束某个后台服务时……都需要通过一个线程去取消另一个线程正在执行的任务。

Java没有提供一种安全直接的方法来停止某个线程,但是Java提供了中断机制。

  • 中断机制是如何工作的?
  • 捕获或检测到中断后,是抛出InterruptedException还是重设中断状态以及在方法中吞掉中断状态会有什么后果?
  • Thread.stop与中断相比又有哪些异同?什么情况下需要使用中断?

6.2.1 中断的原理

Java中断机制是一种协作机制,也就是说通过中断并不能直接终止另一个线程,而需要被中断的线程自己处理中断

Java中断模型也是这么简单,每个线程对象里都有一个boolean类型的标识,代表着是否有中断请求(该请求可以来自所有线程,包括被中断的线程本身)。

java.lang.Thread类提供了几个方法来操作这个中断状态,这些方法包括:

  • public static boolean interrupted

    清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法

  • public boolean isInterrupted()
    返回目标线程的中断状态。线程的中断状态不受该方法的影响。

  • public void interrupt()
    中断线程。

调用 interrupt 并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。

此外,类库中的有些类的方法也可能会调用中断,如FutureTask中的cancel方法,如果传入的参数为true,它将会在正在运行异步任务的线程上调用interrupt方法,如果正在执行的异步任务中的代码没有对中断做出响应,那么cancel方法中的参数将不会起到什么效果。

又如ThreadPoolExecutor中的shutdownNow方法会遍历线程池中的工作线程并调用线程的interrupt方法来中断线程,所以如果工作线程中正在执行的任务没有对中断做出响应,任务将一直执行直到正常结束。

6.2.2 中断的处理

既然Java中断机制只是设置被中断线程的中断状态,那么被中断线程该做些什么?

处理时机

显然,作为一种协作机制,不会强求被中断线程一定要在某个点进行处理。实际上,被中断线程只需在合适的时候处理即可,如果没有合适的时间点,甚至可以不处理。“合适的时候”与线程正在处理的业务逻辑紧密相关,例如,每次迭代的时候,进入一个可能阻塞且无法中断的方法之前等,但多半不会出现在某个临界区更新另一个对象状态的时候,因为这可能会导致对象处于不一致状态。

频繁的检查中断状态可能会使程序执行效率下降,而检查的较少可能使中断请求得不到及时响应。如果发出中断请求之后,被中断的线程继续执行一段时间不会给系统带来灾难,那么就可以将中断处理放到方便检查中断,同时又能从一定程度上保证响应灵敏度的地方。当程序的性能指标比较关键时,可能需要建立一个测试模型来分析最佳的中断检测点,以平衡性能和响应灵敏性。

处理方式

1、 中断状态的管理

一般说来,当可能阻塞的方法声明中有抛出InterruptedException则暗示该方法是可中断的,如BlockingQueue#put、BlockingQueue#take、Object#wait、Thread#sleep等,如果程序捕获到这些可中断的阻塞方法抛出的InterruptedException或检测到中断后,这些中断信息该如何处理?一般有以下两个通用原则:

  • 如果遇到的是可中断的阻塞方法抛出InterruptedException,可以继续向方法调用栈的上层抛出该异常,如果是检测到中断,则可清除中断状态并抛出InterruptedException,使当前方法也成为一个可中断的方法。
  • 若有时候不太方便在方法上抛出InterruptedException,比如要实现的某个接口中的方法签名上没有throws InterruptedException,这时就可以捕获可中断方法的InterruptedException并通过Thread.currentThread.interrupt() 来重新设置中断状态。如果是检测并清除了中断状态,亦是如此。

一般的代码中,尤其是作为一个基础类库时,绝不应当吞掉中断,即捕获到InterruptedException后在catch里什么也不做,清除中断状态后又不重设中断状态也不抛出InterruptedException等。因为吞掉中断状态会导致方法调用栈的上层得不到这些信息。

总得来说,就是要让方法调用栈的上层获知中断的发生。假设你写了一个类库,类库里有个方法amethod,在amethod中检测并清除了中断状态,而没有抛出InterruptedException,作为amethod的用户来说,他并不知道里面的细节,如果用户在调用amethod后也要使用中断来做些事情,那么在调用amethod之后他将永远也检测不到中断了,因为中断信息已经被amethod清除掉了。如果作为用户,遇到这样有问题的类库,又不能修改代码,那该怎么处理?只好在自己的类里设置一个自己的中断状态,在调用interrupt方法的时候,同时设置该状态,这实在是无路可走时才使用的方法。

2、 中断的响应

程序里发现中断后该怎么响应?这就得视实际情况而定了。有些程序可能一检测到中断就立马将线程终止,有些可能是退出当前执行的任务,继续执行下一个任务……作为一种协作机制,这要与中断方协商好,当调用interrupt会发生些什么都是事先知道的,如做一些事务回滚操作,一些清理工作,一些补偿操作等。

不可取消的任务在退出前恢复中断

public Task getNextTask(BlockingQueue<Task> queue) {
     
    boolean interruptted = false;
    try {
     
        while (true) {
     
            try {
     
                return queue.take();
            } catch (InterrupttedException e) {
     
                interruptted = true;
            }
        }
    } finally {
     
        if (interruptted) Thread.currentThread().interrupt();
    }
}

通过 Future 来取消任务

public static void timedRun(Runnable r, long timeout, TimeUnit unit)
    						throws InterrupttedException {
     
    Future<?> task = taskExec.submit(r);
    try {
     
        task.get(timeout, unit);
    } catch (TimeoutException e) {
     
        // 接下来的任务将被取消
    } catch (ExecutionException e) {
     
        // 在任务中抛出异常,那么重新抛出该异常
        throw launderThrowable(e.getCause());
    } finally {
     
        // 如果任务已结束,那么执行取消操作也不会带来任何影响
        task.cancel(true);   // 如果任务正在运行,那么将被中断
    }
}

6.2.3 中断的使用

通常,中断的使用场景有以下几个:

  • 点击某个桌面应用中的取消按钮时;
  • 某个操作超过了一定的执行时间限制需要中止时;
  • 多个线程做相同的事情,只要一个线程成功其它线程都可以取消时;
  • 一组线程中的一个或多个出现错误导致整组都无法继续时;
  • 当一个应用或服务需要停止时。

6.2.4 处理不可中断的阻塞

并非所有的可阻塞方法或者阻塞机制都能响应中断;如果一个线程由于执行IO或者等待获得内置锁而阻塞,那么请求只能设置线程的中断状态,除此之外没有任何其他作用。对于那些执行不可中断操作而被阻塞的线程,可以使用类似于中断的手段来停止这些线程,但这要求我们必须知道线程阻塞的原因 。

Java.io包中的同步Socket I/O:

虽然InputStream和OutputStream中的read和write不会响应中断,但通过关闭底层的套接字,可以使得由于执行read或write方法而阻塞的线程抛出一个SocketException。

获取某个锁:

如果一个线程由于等待某个内置锁而阻塞,那么将无法响应中断,因为线程认为它肯定会获得锁,所以不会理会中断请求。但是Lock类中提供了lockInterruptibly方法,该方法允许在等待一个锁的同事仍能响应中断。

所以知道了线程阻塞的原因,还是可以通过捕获异常或者调用自定义中断方法来进行取消操作。

采用newTaskFor来封装非标准的取消

newTaskFor是Java 6在ThreadPoolExecutor中的新增功能。当把一个Callable提交给ExecutorService时,submit方法会返回一个Future,可以通过这个Future来取消任务。newTaskFor是一个工厂方法,它将创建Future来代表任务,newTaskFor还能返回一个RunnableFuture接口,该接口扩展了Future和Runnable,并由FutureTask实现。

处理非正常的线程终止

导致线程提前死亡的最主要原因是RuntimeException。由于这些异常表示出现了某种编程错误或者其他不可修复的错误,因此它们通常不会被捕获。它们不会在调用栈中逐层传递,而是默认地在控制台输出栈追踪消息,并终止线程。

6.3 停止基于线程的服务

除非拥有某个线程,否则不能对线程进行操控。

6.3.1 关闭 ExecutorService

shutdown 速度慢,但更安全;shutdownNow 首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单,速度快但风险更高,因为任务可能执行到一半时被结束。

shutdownNow 无法通过常规方法来找出哪些任务已经开始尚未结束。

6.3.2 “毒丸”对象

“毒丸”对象是指 当得到这个对象时,立即停止。在 FIFO 队列中,“毒丸”对象将确保消费者在关闭之前首先完成队列中的所有工作,在提交"毒丸"对象之前提交的所有工作都会被处理,而生产者在提交了“毒丸”对象之后,将不会在提交任何工作。

6.3.3 处理非正常的线程终止

导致线程提前死亡的最主要原因就是 RuntimeException。

6.4 JVM 关闭

当最后一个 “正常(非守护)”线程结束时,或调用 System.exit 时,或 Crtl-C 等方式,在操作系统后“杀死” JVM 进程。

6.4.1 钩子

在正常关闭中,JVM 首先调用所有已注册的关闭钩子(Shutdown Hook)。关闭钩子是指通过 Runtime.addShutdownHook 注册的但尚未开始的线程。JVM 不能保证关闭钩子的调用顺序。

JVM 并不会停止或中断仍和在关闭时仍然运行的应用程序线程。当 JVM 最终结束时,这些线程将被强行结束。

关闭钩子应当是线程安全的:它们在访问共享数据时必须使用同步机制,并且小心发生死锁。

通过注册一个关闭钩子来停止日志服务

public void start() {
     
    Runtime.getRuntime().addShutdownHook(new Thread() {
     
        @Override
        public void run() {
     
            try {
      LogService.this.stop();}
            catch (InterruptedException ignored) {
     }
        }
    });
}

6.4.2 守护线程

线程可分为两种:守护线程(例如 GC 线程及其他辅助工作的线程)和普通线程

当 JVM 停止时,所有仍然存在的守护线程都将被抛弃

尽可能少的使用守护线程

此外,守护线程通常不能用来代替应用程序管理程序中各个服务的生命周期

6.4.3 终结器

避免使用终结器

第七章 线程池的使用

7.1 在任务与执行策略之间的隐形耦合

7.1.1 线程饥饿死锁

在线程池中,如果任务依赖于其他任务,就可能产生死锁。例如在单线程的 Executor 中,如果一个任务将另一个任务提交到同一个 Executor 中,并等待任务完成的结果,那么将会发生死锁。这种现象被称为 线程饥饿死锁(Thread Starvation Deadlock)

如果线程池不够大,那么当多个任务通过 Barrier 机制来彼此协调,也很可能导致线程饥饿死锁

7.1.2 运行时间较长的任务

如果任务阻塞的时间过长,那么即使不出现死锁,线程池的响应性也会变得糟糕。

我们可以 限定任务等待资源的时间,在类库的大多数可阻塞方法中,都同时定义了限时和不限时版本,例如 Thread.joinBlockingQueue.putCountDownLatch.awaitSelector.select 等。如果超时,那么可以中止任务或重新放回队列。

7.2 设置线程池大小

线程池大小一般设置为 CPU 数量 + 1 时,通常能达到最优利用率。对于 I/O 操作或其他阻塞操作,线程池的规模应该更大。

7.3 配置 ThreadPoolExecutor

ThreadPoolExecutor 为一些 Executor 提供了基本的实现,这些 Executor 是由 Executors 中的 newCachedThreadPoopnewFixedThreadPoolnewScheduledThreadExecutor 等工厂方法返回的。

ThreadPoolExecutor 是一个灵活的、稳定的线程池,允许各种定制。

ThreadPoolExecutor 的通用构造函数

public ThreadPoolExecutor(int corePoolSize,   // 线程池的基本大小
                         int maximumPoolSize,   // 最大大小
                         long keepAliveTime,   // 存活时间
                         TimeUnit unit,
                         BlockingQueue<Runnable> workQueue,
                         ThreadFactory threadFactory,
                         RejectedExecutorHandler handler
                         ) {
     ...}

Executors 中 newCachedThreadPool、newFixedThreadPool、newSingleThreadPool 的工厂方法

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

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

7.3.1 线程的创建与销毁

基本大小就是线程池的目标大小,即没有任务执行时线程池的大小

在工作队列满的时候允许线程池扩展至最大大小

如果线程池超出目标大小且有空闲线程超过了存活时间,那么它将被标记为可回收的

newFixedThreadPool 基本大小和最大大小为参数设定值,newCachedThreadPool 的线程池最大大小设置为 Integer.MAX_VALUE ,而将基本大小设为0,超时为 1分钟

7.3.2 管理队列任务

在线程池中,这些请求回在一个由 Executor 管理的 Runnable 队列中等待,而不会像线程一样去竞争 CPU 资源

ThreadPoolExecutor 也许提供一个 BlockingQueue 来保存等待执行的任务。如果所有工作者线程都处于忙碌状态,那么任务将在队列中等待。

一种更稳妥的资源管理策略是使用 有界队列,例如 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue。在队列填满后,会涉及到饱和策略。

对于非常大或无界的线程池,可以通过使用 SynchronousQueue 来避免任务排队。SynchronousQueue 不是一个真正的队列,而是一种在线程之间进行移交的机制。要将一个元素放入 SynchronousQueue 中,必须有另外一个线程正在等待接受这个元素。

在 newCachedThreadPool 的工厂方法中就使用了 SynchronousQueue

7.3.3 饱和策略

ThreadPoolExecutor 的饱和策略可以通过调用 setRejectedExecutionHandler 来修改(如果一个任务提交到一个已被关闭的 Executor 时,也会用到饱和策略)。

JDK 提供了几种不同的 RejectedExecutionHandler 实现,每种实现提供了不同的饱和策略:AbortPolicyCallerRunsPolicyDiscardPolicyDiscardOldestPolicy

  • AbortPolicy(中止):

    默认的饱和策略,该策略将抛出未检查的 RejectedExecutionException。调用者可以捕获这个异常

  • DiscardPolicy(抛弃):

    该策略会悄悄抛弃任务

  • DiscardOldestPolicy(抛弃最旧的):

    该策略会抛弃下一个将被执行的任务,然后尝试重新提交该任务(如果是优先队列,那么将抛弃优先级最高的任务,谨慎使用!)

  • CallersRunPolicy(调用者运行):

    该策略实用了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者。它不会在线程池中执行,而是在一个调用了 execute 的线程中执行该任务。

创建一个固定大小的线程池,并采用有界队列一级“调用者运行”饱和策略

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    8, 20, 0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingDeque<>(20));
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

通过 Semaphore 来控制任务的提交速率

public class BoundedExecutor {
     
  private final Executor exec;
  private final Semaphore semaphore;

  public BoundedExecutor(Executor exec, int bound) {
     
    this.exec = exec;
    this.semaphore = new Semaphore(bound);
  }
  
  public void submitTask(final Runnable task) throws InterruptedException {
     
    semaphore.acquire();
    try {
     
      exec.execute(new Runnable() {
     
        @Override
        public void run() {
     
          try {
     
            task.run();
          } finally {
     
            semaphore.release();
          }
        }
      });
    } catch (RejectedExecutionException e) {
     
      semaphore.release();
    }
  }
}

7.3.4 线程工厂

每当线程池需要创建一个线程时,都是通过线程工厂方法来完成的。默认的线程工厂方法将创建一个新的、非守护的线程。

ThreadFactory 接口

public interface ThreadFactory {
     
    Thread newThread(Runnable r);
}

也可以定制一个线程工厂方法,但不建议修改线程的优先级或者守护线程状态。

自定义的线程工厂

class MyThreadFactory implements ThreadFactory {
     
  private final String poolName;
  
  public MyThreadFactory(String poolName) {
     
    this.poolName = poolName;
  }

  @Override
  public Thread newThread(Runnable r) {
     
    return new MyAppThread(r, poolName);
  }
}

7.4 扩展 ThreadPoolExecutor

ThreadPoolExecutor 是可扩展的,它提供了几个可以在子类化中改写的方法:beforeExecuteafterExecuteterminated ,这些方法可以用于扩展 ThreadPoolExecutor 的行为

可以在这几个方法中添加例如记录日志、发送通知、统计信息等功能。

你可能感兴趣的:(多线程,Java,多线程,java)