深入了解Java并发——《Java Concurrency in Practice》7.取消与关闭

Java没有提供任何机制来安全的终止线程。(Thread.stop 和 suspend 等方法存在着一些严重缺陷,应该避免使用)。但它提供了中断 Interruption,中断是一种协作机制,能够使一个进程终止另一个进程的当前工作。

一个行为良好的软件与勉强运行的软件之间的最主要区别就是,行为良好的软件能很完善的处理失败、关闭和取消等过程。

7.1 任务取消

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

在Java中没有一种安全的抢占方法来停止线程,因此没有安全的抢占式方法停止任务,只有协作式的机制。

其中一种协作机制是设置某个 已取消请求 Cancellation requested 标志,任务定期的查看该标志。

package com.dreamer.jcip.chapter7.demo_7_01;

import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;

public class PrimeGenerator implements Runnable {

    private final List<BigInteger> primes = new ArrayList<>();

    private volatile boolean cancelled;

    @Override
    public void run() {
        BigInteger p = BigInteger.ONE;
        while (!cancelled) {
            p = p.nextProbablePrime();
            synchronized (this) {
                primes.add(p);
            }
        }
    }

    public void cancel() {
        cancelled = true;
    }

    public synchronized List<BigInteger> get() {
        return new ArrayList<BigInteger>(primes);
    }

}

完整代码

一个可取消的任务必须拥有取消策略 Cancellation Policy, 在这个策略中详细的定义:
- 其他代码如何请求取消任务
- 任务在合适检查是否已经请求了取消
- 在响应请求时应该执行哪些操作

轮询判断方法的问题在于,如果使用这种方法的任务调用了一个阻塞方法,那么任务可能永远不会检查取消标志,永远不会结束。

7.1.1 中断

中断的应用场景

在Java的API或语言规范中,并没有将中断与任何取消语义关联起来,但实际上,如果在取消之外的其他操作中使用中断,都是不合适的,并且很难支撑起更大的应用。

什么是中断

每个线程都有一个boolean类型的中断状态。当中断线程时,这个线程的中断状态将被设置为true。在Thread中包含了中断线程以及查询线程中断状态的方法。interrupt方法能中断目标线程,isInterrupted方法能返回目标线程的中断状态。static 的 interrupted方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法

阻塞方法对于中断的支持

阻塞库方法都会检查线程合适中断,并且在发现中断时提前返回。它们在响应中断时执行的操作包括:清除中断状态,抛出InterruptedException,表示阻塞操作由于中断而提前结束。JVM并不能保证阻塞方法检测到中断的速度,但通常很快

非阻塞方法对于中断的支持

当线程在非阻塞状态下中断时,它的中断状态将被设置,然后根据将被取消的操作来检查中断状态以判断发生了中断。如果不触发InterruptedException,那么中断状态将一直保持,直到明确的清除中断状态。

调用interrupt并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。相当于发出了一个中断请求,然后由线程在下一个合适的时刻中断自己。设计良好的方法应严格的处理这种请求,如果方法能使调用代码对中断请求进行某种处理,那么完全可以忽略这种请求。但在无法处理的时候,应该抛出InterruptedException,以便其他代码可以正确的处理中断请求。

使用 interrupted时应注意。如果在调用interrupted时返回了true,那么除非想屏蔽这个中断,否则必须对它进行处理。可以抛出InterruptedException,或者通过再次调用interrupt来恢复中断状态。

7.1.2 中断策略

线程应当包含中断策略,中断策略规定线程如何解释某个中断的请求。最合理的中断策略是某种形式的线程级 Thread-Level 取消操作或服务级 Service-Level取消操作:尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。

对于非线程所有者的代码来说,应该小心的保存中断状态,这样拥有线程的代码才能对中断做出响应。这也是为什么大多数可阻塞的库函数都只是抛出InterruptedException作为中断响应的原因。

如果出了将InterruptedException传递给调用者外还需要执行其操作,那么应该在捕获InterruptedException之后恢复中断状态

Thread.currentThread().interrupt();

由于每个线程拥有各自的中断策略,所以除非知道中断对该线程的含义,否则就不应该中断这个线程

7.1.3 响应中断

调用可中断的阻塞函数时,有两种实用策略可用于处理InterruptedException
- 传递异常 从而使自己的方法也成为可中断的阻塞方法
- 恢复中断状态,从而使调用栈中的上层代码能够对其进行处理。

不想或无法传递InterruptedException时,标准做法是通过再次调用interrupt来恢复中断状态。除了实现线程中断策略的代码,常规的任务和库代码中都不应该屏蔽中断请求。

对于不支持取消但仍可以调用可中断阻塞方法的操作,它们必须在循环中调用这些方法,并在发现中断后重新尝试。应当在本地保存中断状态,并在返回前恢复状态。

package com.dreamer.jcip.chapter7.demo_7_07;

import java.util.concurrent.BlockingQueue;

import com.dreamer.jcip.chapter5.demo_5_10.Task;

public class RegainInterupt {

    public Task getNextTask(BlockingQueue<Task> queue) {
        boolean interrupted = false;

        try {
            while (true) {
                try {
                    return queue.take();
                } catch (InterruptedException e) {
                    interrupted = true;
                }
            }
        } finally {
            if (interrupted) {
                Thread.currentThread().interrupt();
            }
        }
    }

}

完整代码

7.1.5 通过Future来实现取消

ExecutorService.submit返回一个Future来描述任务。Future拥有一个cancel方法,该方法带有一个boolean类型的参数 mayInterruptIfRunning,表示任务是否能够接收中断。如果mayInterruptIfRunning任务为true并且任务当前正在某个线程中运行,那么这个任务能被中断。如果这个参数为false,那么意味着任务还没有启动,就不要运行它,这种方式应该用于那些不处理中断的任务中。

执行任务的线程是由标准的Executor创建的,它实现了一种中断策略使得任务可以通过中断被取消,所以如果任务在标准Executor中运行,并通过它们的Future来取消任务,那么可以设置mayInterruptIfRunning。尝试取消任务时,不宜直接中断线程池。

package com.dreamer.jcip.chapter7.demo_7_10;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class TimedRun {

    static ExecutorService taskExec = Executors.newSingleThreadExecutor();

    public static void timedRun(Runnable r, long timeout, TimeUnit unit) throws InterruptedException {
        Future<?> task = taskExec.submit(r);
        try {
            task.get(timeout, unit);
        } catch (TimeoutException e) {
            // 超时,取消任务

        } catch (ExecutionException e) {
            // 任务中抛出了异常

            throw launderThrowable(e.getCause());
        } finally {
            // 任务结束,执行取消

            // 如果任务正在执行,那么执行中断

            task.cancel(true);
        }
    }

    private static InterruptedException launderThrowable(Throwable cause) {
        return null;
    }

}

完整代码

当Future.get抛出InterruptedException或TimeoutException时,如果知道不再需要结果,那么就可以调用Future.cancel来取消

7.1.6 处理不可中断的阻塞

并非所有的可阻塞的方法或者阻塞机制都能相应中断,对于那些由于执行不可中断操作而被阻塞的线程,可以使用类似于中断的手段来停止这些线程,但要求必须知道线程阻塞的原因。

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

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

Java.io包中的同步I/O

中断一个正在InterruptibleChannel上等待的线程时,将抛出ClosedByInterruptedException并关闭链路,并使其他在这条链路上阻塞的线程同样抛出ClosedByInterruptedException。关闭一个InterruptibleChannel时,将导致所有在操作上阻塞的线程都抛出AsynchronousCloseException。大多数标准的Channel都实现了InterruptibleChannel。

Selector的异步I/O

如果一个线程在调用Selector.select方法时阻塞了,那么调用close或wakup方法会使线程抛出ClosedSelectorException并提前返回。

获取某个锁

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

可以通过改写interrupt方法,使其既能处理标准的中断,又能同时处理其他不可中断内容的关闭。这样无论方法是在是否可中断的阻塞方法中阻塞,都可以被中断并停止执行当前的工作。

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

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

通过定制表示任务的Future可以改变Future.cancel的行为。如实现日志记录、统计信息,取消不响应中断的操作等。

通过newTaskFor将非标准的取消操作封装在一个任务中


    protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
        if (callable instanceof CancellableTask) {
            return ((CancellableTask<T>) callable).newTask();
        } else {
            return super.newTaskFor(callable);
        }
    }

完整代码

7.2 停止基于线程的服务

应用程序通常会创建拥有多个线程的服务,这些服务的生命周期通常比创建它们的方法的声明周期更长。如果应用程序准备退出,那么这些服务所拥有的线程也需要结束。由于无法通过抢占式的方法来停止线程,因此它们需要自行结束。

正确的封装原则是:除非拥有某个线程,否则不能对该线程进行操控

与其他封装对象一样,线程的所有权是不可传递的:应用程序可以拥有服务,服务也可以拥有工作者线程,但应用程序不能拥有工作者线程,因此应用程序不能直接停止工作者线程。相反,服务应该提供生命周期方法(Lifecycle Method)来关闭它自己以及它所拥有的线程。在ExecutorService中提供了shutdown和shutdownNow等方法。在其他拥有线程的服务中也应该提供类似的关闭机制。

对于持有线程的服务,只要服务的存在时间大于创建线程的方法的存在时间,那么就应该提供声明周期方法

7.2.1 示例:日志服务

当取消一个生产者-消费者操作时,需要同时取消生产者和消费者。

7.2.2 关闭ExecutorService

ExecutorService提供了两种关闭方式:shutdown正常关闭,shutdownNow强行关闭。强行关闭的速度更快,风险更大。正常关闭速度慢,更安全。

简单的程序可以直接在main函数中启动和关闭全局的ExecutorService。复杂程序中,通常将ExecutorService封装在某个更高级别的服务中心,并且该服务能提供其自己的声明周期方法。

public class LogService {

    private final ExecutorService exec = new SingleThreadExecutor();

    public void start() {}

    public void stop() throws InterruptedException() {
        try {
            exec.shutdown();
            exec.awaitTermination(TIMEOUT,UNIT);
        } finally {
            writer.close();
        }
    }

    public void log(String msg) {
        try {
            exec.execute(new WriteTask(msg));
        } catch (RejectedExecutionException ignored) {
            // ignored
        }
    }
}

7.2.3 毒丸对象

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

只有在生产者和消费者的数量都已知的情况下,才可以使用 毒丸对象。该解决方案同样可以扩展到多个生产者,消费者仅当在接受到所有毒丸对象时才停止。同样可以扩展到多个消费者的情况,生产者将对应消费者数量的毒丸对象放入队列即可。

生产者和消费者的数量较大时,这种方法将变得难以使用。

7.2.5 shutdownNow的局限性

shutdownNow强行关闭ExecutorService时,会尝试取消正在执行的任务,返回所有已提交但尚未开始的任务。

但是,我们无法通过常规方法来找出那些任务已经开始但尚未结束。这意味着无法在关闭过程中知道正在执行的任务的状态,除非任务本身会执行某种检查。要知道那些任务还没完成,不仅需要知道哪些任务还没开始,还需要知道Executor关闭时哪些任务正在执行。

在ExecutorService中跟踪在关闭之后被取消的任务

public class TrackingExecutor extends AbstractExecutorService {

    private final ExecutorService exec;

    private final Set<Runnable> tasksCancelledAtShutdown = 
            Collections.synchronizedSet(new HashSet<Runnable>());

    public List<Runnable> getCancelledTasks() {
        if (!exec.isTerminated()) {
            throw new IllegalStateException();
        }
        return new ArrayList<Runnable>(tasksCancelledAtShutdown);
    }

    public void execute(final Runnable runnable) {
        exec.execute(new Runnable() {
            public void run() {
                try {
                    runnable.run();
                } finally {
                    if (isShutdown() && Thread.currentThread().isInterrupted()) {
                        tasksCancelledAtShutdown.add(runnable);
                    }
                }
            }
        });
    }

}

完整代码

TrackingExecutor中存在一个不可避免的竞态条件,从而产生 误报 问题。在任务执行最后一条指令以及线程池将任务记录为“结束”两个时刻之间,线程池可能被关闭。如果任务是幂等的,那么这不会存在问题。否则,应用程序中必须考虑这种问题。

幂等 Idempotent 将任务执行两次与执行一次会得到相同的结果

7.3 处理非正常的终止

任何代码都可能抛出一个RuntimeException。每当调用另一个方法时,都要对它的行为保持怀疑,不要盲目的任务它一定会正常返回,或者一定会抛出在方法原型中声明的某个已检查异常。

在任务处理线程的中,应该在try-catch代码块中调用有可能抛出异常的任务,这样就能捕获那些非受检异常,或者也可以使用try-finaly块来确保框架能够知道线程非正常退出的情况,并做出正确的响应。

典型的线程池工作者线程结构

public void run() {
    Throwable thrown = null;
    try {
        while(!isInterrupted) {
            runTask(getTaskFromWorkQueue());
        }
    } catch (Throwable e) {
        thrown = e;
    } finally {
        threadExited(this, thrown);
    }
}

未捕获异常的处理

ThreadAPI中提供了UncaughtExceptionHandler,它能检测出某个线程由于未捕获异常而终结的情况。它与以上方法是互补的,通过将二者结合在一起,能够有效防止线程泄露问题。

当一个线程由于未捕获异常而退出时,JVM会把这个事件报告给应用程序提供的UncaughtExceptionHandler异常处理器。如果没有提供任何异常处理器,那么默认的行为是将堆栈追踪信息输出到System.err。

public interface UncaughtExceptionHandler {
    void uncaughtException(Thread t, Throwable e);
}

异常处理区如何处理未捕获异常,取决于对服务质量的需求。最常见的响应方式是将一个错误信息以及相应的栈追踪信息写入应用程序日志中。异常处理器还可以采取更直接的响应,例如尝试重新启动线程,关闭应用程序,或执行其他修复或诊断操作。

在运行时间较长的应用中,通常会为所有线程的未捕获异常指定同一个异常处理器,并且该处理器至少会将异常信息记录到日志中

要为线程池中的所有线程设置一个UncaughtExceptionHandler,需要为ThreadPoolExecutor的构造函数提供一个ThreadFactory。标准线程池允许当发生未捕获异常时结束线程,但由于使用了一个try-finally块接收通知,因此线程结束是,将有新的线程来代替它。如果没有提供捕获异常处理器或者其他的故障通知机制,那么任务会悄悄失败。如果希望在任务由于发生异常而失败时获得通知,并且执行一些特定于任务的恢复操作,那么可以将任务封装在能捕获异常的Runnable或Callable中,或者改写ThreadPoolExecutor的aftereExecute方法。

只有通过execute提交的任务,才能将它抛出的异常交给未捕获异常处理器,而通过submit提交的任务,无论抛出的未检查异常还是已检查异常,都将被任务是任务返回状态的一部分。如果一个由submit提交的任务由于跑出了异常而结束,那么这个异常将被Future.get封装在ExecutionException中重新抛出。

7.4 JVM关闭

JVM既可以正常关闭,也可以强行关闭。

7.4.1 关闭钩子

在正常关闭中,JVM首先调用所有已注册的关闭钩子 Shutdown Hook。Shutdown Hook是指通过Runtime.addShutdownHook 注册的但尚未开始的线程。JVM并不能保证 Shutdown Hook的调用顺序。关闭应用程序线程时,如果有守护或非守护线程仍然在运行,那么这些线程接下来将于关闭进程并发执行。当所有的Shutdown Hook都执行结束时,如果runFinalizersOnExit为true,那么JVM将运行终结器,然后再停止。JVM并不会停止或中断任何在关闭时仍然运行的应用程序线程。当JVM最终结束时,这些线程将被强行结束。如果shutdown Hook 或终结器没有执行完成,那么正常关闭进程 挂起 并且JVM必须被强行关闭。强行关闭时,只是关闭JVM,而不会运行shutdown hook。

shutdown hook 应该是线程安全的,它们在访问共享数据时必须使用同步机制,并且小心的避免死锁。关闭狗仔不应该对应用程序的状态或者JVM的关闭原因做出任何假设,编写shutdown hook的代码时必须考虑周全。shutdown hook必须尽快退出,因为它们会延迟JVM的结束时间。

shutdown hook可以用于实现服务或应用程序的清理工作。shutdown hook不应该依赖于那些可能被应用程序或其他shutdown hook关闭的服务。实现这种功能的一种方式是对所有服务使用同一个 shutdown hook,并且在该shutdown hook中执行一系列的关闭操作。这确保了关闭操作在单个线程中串行执行,避免了关闭操作之间出现竞态条件或死锁。无论是否使用shutdown hook,都可以使用这项技术,通过将各个关闭操作串行执行而不是秉性执行,可以消除许多潜在的故障

通过注册一个shutdown hook来停止日志服务

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

7.4.2 守护线程

需要创建一个线程来执行一些辅助工作,但又不希望这个线程阻碍JVM的关闭时,需要使用守护线程 Daemon Thread

线程分为普通线程和守护线程。JVM启动时创建的所有线程中,除了主线程以外其他的线程都是守护线程。当创建一个新线程时,新线程将继承创建它的线程的守护状态,因此默认情况下,主线程创建的所有线程都是普通线程。

当一个线程退出时,JVM会检查正在运行的其他线程,如果这些线程都是守护线程,那么JVM会正常退出操作。当JVM停止时,所有仍然存在的守护线程都将被抛弃——既不会执行finally代码块,也不会执行回卷栈,JVM直接退出

应尽可能少的使用守护线程——很少有操作能够在不进行清理的情况下被安全的抛弃。特别是,如果守护线程中执行包含I/O的任务,那么将是一种危险的行为。守护线程最好用于执行 内部 任务。

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

7.4.3 终结器

当不再需要内存资源时,可通过GC来回收它们,但对于其他一些资源如文件句柄或套接字句柄,当不再需要它们时,必须显式的交还给操作系统。为了实现这个功能,GC对定义了finalize方法的对象会进行特殊的处理:在回收器释放他们之后,调用它们的finalize方法,从而保证一些持久化资源被释放。

由于种种种种原因,避免使用终结器

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值