【读书笔记】《Java并发编程实战》第七章 取消与关闭

任务取消

1.什么是任务取消?

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

取消某个操作的原因可能有:

  • 用户请求取消
  • 有时间限制的操作
  • 触发应用程序事件
  • 发生错误
  • 程序或任务关闭

2.取消任务的方式有几种?

取消任务的方式大体上有一下两种:

  1. 设置取消标志
  2. 中断

2.1设置取消标志

可以在程序中设置某个用于判断任务是否已取消的标志位,然后定期查看该标志。如果标志表明任务已取消那么执行程序中定义的取消策略。

public class PrimeGenerator implements Runnable {
    private final List<BigInteger> primes = new ArrayList<BigInteger>();
    private volatile boolean cancelled;
 
    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);
    }
}

class Test {

	//一个仅运行一秒钟的素数生成器
	List<BigInteger> aSecondOfPrimes() throws InterruptedException {
	PrimeGenerator generator = new PrimeGenerator();
	new Thread(generator).start();
	try {
		SECONDS.sleep(1);
	} finally {
		generator.cancel();//cancel由finally调用,从而确保即使在调用sleep时被中断也能取消素数生成器的执行
	}
	return generator.get();
    }
    
}

上面代码使用了这项技术,其中的 PrimeGenerator 持续地枚举素数,直到它被取消。cancel 方法将设置 cancelled 标志,并且主循环在搜索下一个素数之前会首先检查这个标志(为了使这个过程能可靠的工作,标志 cancelled 必须为 volatile 类型)。

PrimeGenerator 使用了一种简单的取消策略:客户代码通过调用 cancel 来请求取消,PrimeGenerator 在每次搜索素数前首先检查是否存在取消请求,如果存在则退出。

2.2中断

PrimeGenerator 中的取消机制最终会使得搜索素数的任务退出,但在退出过程中需要花费一定的时间。然而,如果使用这种方法的任务调用了一个阻塞方法,例如 BlockingQueue.put,那么可能会产生一个更严重的问题——任务可能永远不会检查取消标志位,因此永远不会结束。

线程中断是一种协作机制,线程可以通过这种机制来通知另一个线程,告诉它在合适的或者可能的情况下停止当前工作,并转而执行其他的工作。

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

//Thread中的中断方法
    public class Thread{
        public void interrupt() { ... }
        public boolean isInterrupted() { ... }
        public static boolean interrupted(){ ... }
        ......
    }
调用阻塞方法时被中断

阻塞库方法,例如 Thread.sleep 和 Object.wait 等,都会检查线程何时中断,并且在发现中断时提前返回。它们在响应中断时执行的操作包括:清除中断状态,抛出 InterruptedException,表示阻塞操作由于中断而提前结束。

在非阻塞状态下被中断

当线程在非阻塞状态下中断时,它的中断状态将被设置,然后根据将被取消的操作来检查中断状态以判断发生了中断。通过这样的方法,中断操作将变得“有黏性”——如果不触发 InterruptedException,那么中断状态一直保持,直到明确地清除中断状态

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

对中断操作的正确理解是:它并不会真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己(这些时刻也被称为取消点)。

在使用静态的 interrupted 时应该小心,因为它会清除当前线程的中断状态。如果调用 interrupted 时返回了 true,那么除非你想屏蔽这个中断,否则必须对它进行处理——可以抛出 InterruptedException,或者通过再次调用 interrupt 来恢复中断状态。

3.什么是中断策略?

直白点说中断策略就是,当线程发现中断请求时,应该做哪些工作(就是响应中断)并以多快的速度响应中断请求(就是设置延迟时间)。

当检查到中断请求时,任务并不需要放弃所有的操作——它可以推迟处理中断请求,并直到某个更合适的时刻。因此需要记住中断请求,并在完成当前任务后抛出 InterruptedException 或者表示已经收到中断请求。

4.如何响应中断?

4.1处理可中断的阻塞

当调用可中断的阻塞库函数时,例如 Thread.sleep 或 BlockingQueue.put 等,有两种使用策略可用来处理 InterruptedException:

  • 传递异常(可能在执行某个特定于任务的清除操作之后),从而使你的方法也成为可中断的阻塞方法。
  • 恢复中断状态,从而使调用栈中的上层代码能够对其进行处理。——再次调用 interrupt 来恢复中断状态

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

	//不可取消的任务在退出前恢复中断
    public BigInteger getNextInteger(BlockingQueue<BigInteger> queue) {
        boolean interrupted = false;
        try {
            while (true) {
                    return queue.take();
            }
        } catch (InterruptedException e) {
        	interrupted = true;
        } finally {
            if(interrupted)
                Thread.currentThread().interrupt();
        }
    }

通常,可中断的方法会在阻塞或进行重要的工作前首先检查中断,从而尽快地响应中断。

如果代码不会调用可中断的阻塞方法,那么仍然可以通过在任务代码中轮询当前线程的中断状态来响应中断

4.2处理不可中断的阻塞

并非所有的可阻塞方法或者阻塞机制都能响应中断;如果一个线程由于执行同步的Socket I/O或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。

以下是不可中断阻塞的情况:

  1. java.io包中的同步Socket I/O
  2. java.io包中的同步I/O
  3. Selector的异步I/O
  4. 获取某个锁
4.2.1java.io包中的同步Socket I/O

在服务器应用程序中,最常见的阻塞I/O形式就是对套接字进行读取和写入。虽然InputStream和OutputStream中的read和write等方法都不会响应中断,但通过关闭底层的套接字,可以使得由于执行read和write等方法而阻塞的线程抛出一个SocketException

4.2.2java.io包中的同步I/O

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

4.2.3Selector的异步I/O

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

4.2.4获取某个锁

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

4.3通过Future实现取消

现有类库中的Future可以实现任务取消。ExcetorService.submit将返回一个Future来描述任务。Future拥有一个cancel方法,该方法带有一个boolean类型的参数mayInterruptIfRunning。如果mayInterruptIfRunningtrue并且任务当前正在某个线程中运行,那么这个线程将被中断。如果这个参数为false,那么意味着“若任务还没有启动,就不要运行它”,这种方式应该用于不处理中断的任务中。

Future.cancel的使用案例如下:

//通过Future来取消任务
class Test {
	ExecutorService taskExec = Executors.newFixedThreadPool(100);
	
	public static void timeRun(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);
		}
		
	}
}

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

5.如何停止基于线程的服务?

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

线程池是工作者线程的所有者,如果要中断这些线程,那么应该使用线程池。

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

5.1关闭ExecutorService

ExecutorService提供了两种关闭方法:

  • 使用shutdown正常关闭
  • 使用shutdownNow强制关闭——在进行强制关闭时,shutdownNow会首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单。

这两种关闭方式的差别在于各自的安全性和响应性:shutdownNow的速度更快,但风险也更大,因为任务很可能在执行到一半时被结束;而shutdown虽然速度慢,但却更安全,因为ExecutorService会一直等到队列中所有任务都执行完成后才关闭。

使用shutdown关闭任务的示例如下:

//使用ExecutorService的日志服务
public class LogService {
	private final ExecutorService exec = Executors.newSigngleThreadExecutor();
	...
	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(masg));
		} catch (RejectedExecutionException) {
			...
		}
	}
}

shutdownNow的局限性
当通过shutdownNow来强行关闭ExecutorService时,它会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务。你可以将这些任务写入日志或者保存起来以便之后进行处理。

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

可以通过封装ExecutorService并使得execute或者submit记录哪些任务是在关闭后取消的。

5.2设置“已请求关闭”标志

一种关闭生产者-消费者服务的方法是:设置某个“已请求关闭”标志,以避免进一步程序进行。

//可靠的取消操作
public class LogService {
	private final BlockingQueue<String> queue;
	private final LoggerThread loggerThread;
	private final PritWriter;
	private boolean isShutdown;
	private int reservations;

	public void start() {
		loggerThread.start();
	}

	public void stop() {
		synchronized (this) {
			isShutdown = true;
		}
		loggerThread.interrupt();
	}

	public void log(String msg) throws InterruptedException {
		synchronized (this) {
			if (isShutdown)
				throw new IllegalStateException(...);
			++reservations;
		}
		queue.put(msg);
	}

	private class LoggerThread extends Thread {
		public void run() {
			try {
				while(true) {
					try {
						synchronized (LogService.this) {
							if (isShutdown && reservations == 0)
								break;
						}
						String msg = queue.take();
						synchronized (LogService.this) {
							--reservations;
						}
						writer.println(msg);
					} catch(InterrruptedException e) {
						/*重试*/
					}
				}
			} finally {
				write.close();
			}
		}
	}
}

为LogService提供可靠关闭操作的方法是解决竞态条件问题,因而要使日志消息的提交操作成为原子操作。然而,我们不希望在消息加入队列时去持有一个锁,因为put方法本身就可以阻塞。我们采用的方法是:通过原子方式来检查关闭请求,并且有条件地递增一个计数器来“保持”提交消息的权利。

5.3使用“毒丸”对象

另一种关闭生产者-消费者服务的方式就是使用“毒丸”对象:“毒丸”是指一个放在队列上的对象。其含义是:“当得到这个对象时,立即停止”。在FIFO(先进先出)队列中,“毒丸”对象将确保消费者在关闭之前首先完成队列中的所有工作,在提交“毒丸”对象之前提交的所有工作都会被处理,而生产者在提交“毒丸”对象后,将不会再提交任何工作

//通过“毒丸”对象来关闭服务
public class IndexingService {
	private static final File POISON = new File("");
	private final IndexerThread consumer = new IndexerThread();
	private final CrawlerThread producer = new CrawlerThread();
	private final BlockingQueue<File> queue;
	private final FileFilter fileFilter;
	private final File root;

	//生产者线程
	class CrawlerThread extends Thread {
		@Override
		public void run() {
			try {
				crawl(root);
			} catch (InterruptedException e) {
				//发生异常
			} finally {
				while (true) {
					try {
						queue.put(POISON);
						break;
					} catch (InterruptedException e1) {
						//重新尝试
					}
				}
			}
		}

		private void crawl(File root) throws InterruptedException {
			...
		}
	}

	//消费者线程
	class IndexerThread extends Thread {
		@Override
		public void run() {
			try {
				while(true) {
					File file = queue.take();
					if (file == POISON)
						break;
					else
						indexFile(file);
				}
			} catch (InterruptedException consumed) {
				...
			}
		}
	}
	
	public void start() {
		producer.start();
		consumer.start();
	}

	public void stop() {
		producer.interrupt();
	}

	public void awaitTermination() throws InterruptedException {
		consumer.join();
	}
}

只有在生产者和消费者的数量都已知的情况下,才可以使用“毒丸”对象。在IndexingService中采用的解决方案可以拓展到多个生产者:只需每个生产者都向队列中放入一个“毒丸”对象,并且消费者仅当在接收到N个生产者的“毒丸”对象时才停止。这种方法也可以拓展到多个消费者的情况,只需生产者将N个消费者的“毒丸”对象放入队列。

当生产者和消费者的数量较大时,这种方法将变得难以使用。只有在无界队列中,“毒丸”对象才能可靠地工作

6.如何处理非正常的线程终止?

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

在Thread API中提供了UncaughtExceptionHandler,它能检测出某个线程由于未捕获的异常而终结的情况。

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

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

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

//将异常写入日志的UncaughtExceptionHandler
public class UEHLogger implements Thread.UncaughtExceptionHandler {
	public void uncaughtException(Thread t, Throwable e) {
		Logger logger = Logger.getAnonymousLogger();
		logger.log("...");
	}
}

7.JVM关闭

JVM既可通过正常手段来关闭,也可强行关闭

  • 正常关闭:当最后一个“正常(非守护)”线程结束时、当有人调用了System.exit时、或者通过其他特定于平台的方法关闭时
  • 强行关闭:Runtime.halt,这种强行关闭方式将无法保证是否将运行关闭钩子

1.关闭钩子

  • 关闭钩子是指通过Runnable.addShutdownHook注册的但尚未开始的线程
  • JVM并不能保证关闭钩子的调用顺序
  • 当所有的关闭钩子都执行结束时,如果runFinalizersOnExit为true,那么JVM将运行终结器(finalize),然后再停止
  • VM并不会停止或中断任何在关闭时仍然运行的应用程序线程。当JVM最终结束时,这些线程将被强行结束。如果关闭钩子或终结器没有执行完成,那么正常关闭进程“挂起”并且JVM必须被强行关闭。当被强行关闭时,只是关闭JVM,而不会运行关闭钩子
  • 关闭钩子应该是线程安全的
  • 关闭钩子必须尽快退出,因为它们会延迟JVM的结束时间
public void start()//通过注册关闭钩子,停止日志服务
{
    Runnable.getRuntime().addShutdownHook(new Thread(){
        public void run()
        {
            try{LogService.this.stop();}
            catch(InterruptedException ignored){}
        }
    });
}

2、守护线程——一个线程来执行一些辅助工作,但有不希望这个线程阻碍JVM的关闭
线程可分为两种:普通线程和守护线程。在JVM启动时创建的所有线程中,除了主线程以外,其他的线程都是守护线程。

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

3、终结器(清理文件句柄或套接字句柄等)——避免使用
垃圾回收器对那些定义了finalize方法的对象会进行特殊处理:在回收器释放它们后,调用它们的finalize方法,从而确保一些持久化的资源被释放。

通过使用finally代码块和显式的close方法,能够比使用终结器更好地管理资源。

例外:当需要管理对象时,并且该对象持有的资源是通过本地方法获得的。

猜你喜欢

转载自blog.csdn.net/Handsome_Le_le/article/details/107620601