**


前一节我们讲述了如何通过读源码,查询 StackOverFlow,写 DEMO 方式学习线程池。
然而线程池在使用过程中会遇到很多问题,本节将通过几个案例研究 Java 虚拟机关闭的问题。

**


本节重点学习 JVM 关闭时机相关问题,那么 JVM 在何时正常退出呢(不包含通过 kill 指令杀死进程等情况)?
根据《 Java 虚拟机规范 (Java SE 8 版)》 第 228 页,对应英文版为 5.7 Java Virtual Machine Exit 的相关描述我们可知:
Java 虚拟机退出的条件是,某个线程调用了

  1. Runtime


类或

  1. System


类的

  1. exit


方法,或

  1. Runtime


类的

  1. halt


方法,并且 Java 安全管理器也允许这次

  1. exit


  1. halt


操作。
除此之外, JNI (Java Native Interface) 规范描述了用 JNI Invocation API 来加载或卸载 Java 虚拟机时,Java 虚拟机的退出情况 1

根据《Java 并发编程实践》 164 页相关论述 ,我们还了解到:
也可以通过一些其他平台相关的手段(比如发送 SIGINT, 或键入 Ctrl-C), 都可以实现 JVM 的正常关闭。还可以调用 “杀死” JVM 的操作系统进程而强制关闭 JVM 2

另外根据《Java Language Specification : Java SE 8 Edition》12.8 Program Exit 的相关描述 3 我们可知:
当下面两种情况发生时,程序将会结束所有活动并退出:

  • 只剩下守护线程( daemon thread)时。
  • 某个线程调用了
  1. Runtime


类或

  1. System


  1. exit


方法,并且 Java 安全管理器也允许这次

  1. exit


操作。

了解这个背景知识,接下来我们将开始分析相关的案例。

**

**


本案例涉及两个类,一个是自定义线程类,一个是测试类。
自定义线程类:

  1. import java.util.concurrent.TimeUnit;
  2. public class DemoThread extends Thread {
  3. public DemoThread() {
  4. }
  5. @Override
  6. public void run() {
  7. for (int i = 0; i < 4; i++) {
  8. System.out.println(Thread.currentThread().getName() + "-->" + i);
  9. try {
  10. TimeUnit.SECONDS.sleep(1);
  11. } catch (InterruptedException ignore) {
  12. }
  13. }
  14. }
  15. }

对应的单元测试:

  1. public class ThreadDemoTest {
  2. @Test
  3. public void test() throws InterruptedException {
  4. DemoThread demoThread1 = new DemoThread();
  5. DemoThread demoThread2 = new DemoThread();
  6. demoThread1.start();
  7. demoThread2.start();
  8. }
  9. }

预期结果为,每个线程分别执行 4 次打印语句。
但是实际运行结果为:
Thread-0–>0
Thread-1–>0

打印两行文字后程序退出。
通过观察现象,我们看出 JUnit 单元测试 “不支持多线程” 测试,换句话说两个线程可能还没没执行完,程序就退出了。
我们首先尝试使用 * 断点调试大法 来寻找线索。
16 虚拟机退出时机问题研究 - 图1
我们通过查看左侧的调用栈,可以清晰地看到顶层的为

  1. com.intellij.rt.execution.junit.JUnitStarter#main


的 70 行,通过一系列的调用,启动当前测试方法。
按照惯例,我们可以双击左侧的调用进入源码。
但是,令人吐血的是,双击没反应,崩溃中…
既然 IDEA 可以使用该类,那么显然此类可以被 IDEA 加载,根据最外层的入口包名(com.intellij.rt.execution.junit),我们断定不是 JDK 中的类,也不是我们 pom.xml 中引入的 jar 包中的类,应该是 idea 自己的类库。
我们去 IDEA 的安装目录去寻找线索。排查了 lib 文件夹下的所有 jar 包,发现和名称相匹配的 jar 包。
16 虚拟机退出时机问题研究 - 图2
我们如何查看这几个 jar 中有没有源码和上面的匹配呢?
可以使用前面介绍的 Java 反编译工具: JD-GUI,查看这些包的源码。
由于我们使用的是 JUnit4 我们首先查看 junit-rt.jar 的反编译代码。
16 虚拟机退出时机问题研究 - 图3
我们在此处找到了 IDEA 调试时顶层的类!
从此反编译的代码可以看到,

  1. main


函数的 70 行。

  1. int exitCode = prepareStreamsAndStart(array, agentName, listeners, name[0]);

该函数调用准备流和开始函数,并获得返回值作为退出码,然后调用

  1. System.exit(exitCode);


退出 JVM。
因此问题就迎刃而解了。
我们重新梳理执行流程:
IDEA 运行 JUnit 4 时,

  1. 先执行
  1. com.intellij.rt.execution.junit.JUnitStarter#main


,此函数中调用

  1. prepareStreamsAndStart


子函数;

  1. 子函数最终调用到

    1. ThreadDemoTest#test

    的代码。
    1. ThreadDemoTest#test

    创建两个新线程并依次开启后结束,函数返回退出码,最终调用
    1. System.exit(exitCode);

    退出 JVM。
    那么如何避免两个子线程尚未执行完单元测试函数,就被主线程调用
    1. System.exit

    导致 JVM 退出呢?
    方案 1:可以将代码写在 main 函数中;
    还记得开头说的吗,只要有一个非守护线程还在运行,虚拟机就不会退出(正常情况下)。
    使用 main 函数代码非常简单,这里就不再提供。
    方案 2:可以使用 CountDownLatch;
    改造自定义的线程类:
    1. import java.util.concurrent.CountDownLatch;
    2. import java.util.concurrent.TimeUnit;
    3. public class DemoThread extends Thread {
    4. private CountDownLatch countDownLatch;
    5. public DemoThread(CountDownLatch countDownLatch) {
    6. this.countDownLatch = countDownLatch;
    7. }
    8. @Override
    9. public void run() {
    10. for (int i = 0; i < 4; i++) {
    11. System.out.println(Thread.currentThread().getName() + "-->" + i);
    12. try {
    13. TimeUnit.SECONDS.sleep(10);
    14. } catch (InterruptedException ignore) {
    15. }
    16. }
    17. countDownLatch.countDown();
    18. }
    19. }

修改单元测试函数:

  1. @Test
  2. public void test() throws InterruptedException {
  3. CountDownLatch countDownLatch = new CountDownLatch(2);
  4. DemoThread demoThread1 = new DemoThread(countDownLatch);
  5. DemoThread demoThread2 = new DemoThread(countDownLatch);
  6. demoThread1.start();
  7. demoThread2.start();
  8. countDownLatch.await();
  9. }

由于使用了

  1. countDownLatch.await();


主线程会阻塞到两个线程都执行完毕。
具体原理大家可以查看

  1. java.util.concurrent.CountDownLatch#await()


源码。
方案 3:可以在测试函数最后调用

  1. join


函数:

  1. @Test
  2. public void test() throws InterruptedException {
  3. DemoThread demoThread1 = new DemoThread();
  4. DemoThread demoThread2 = new DemoThread();
  5. demoThread1.start();
  6. demoThread2.start();
  7. demoThread1.join();
  8. demoThread2.join();
  9. }

join 函数会等待当前线程执行结束再继续执行。

**


大家可以猜想一下下面代码的执行结果是啥?

  1. public class CompletableFutureDemo {
  2. public static void main(String[] args) {
  3. CompletableFuture.runAsync(() -> {
  4. try {
  5. TimeUnit.SECONDS.sleep(2L);
  6. } catch (InterruptedException ignore) {
  7. }
  8. System.out.println("异步任务");
  9. });
  10. }
  11. }

可能出乎很多人的意料,如果运行此段代码,大概率会发现:打印语句并没有被执行程序就退出了。
What? 前面不是说多线程问题可以通过将代码写在 main 函数中来避免的吗? 怎么瞬间打脸?
别急,我们来研究一下这个问题:

  1. /**
  2. * Returns a new CompletableFuture that is asynchronously completed
  3. * by a task running in the given executor after it runs the given
  4. * action.
  5. *
  6. * @param runnable the action to run before completing the
  7. * returned CompletableFuture
  8. * @param executor the executor to use for asynchronous execution
  9. * @return the new CompletableFuture
  10. */
  11. public static CompletableFuture<Void> runAsync(Runnable runnable,
  12. Executor executor) {
  13. return asyncRunStage(screenExecutor(executor), runnable);
  14. }

通过源码注释,我们可知该函数是使用给定的

  1. executor


来异步执行任务。
那么使用的线程池类型是什么呢?

  1. /**
  2. * Null-checks user executor argument, and translates uses of
  3. * commonPool to asyncPool in case parallelism disabled.
  4. */
  5. static Executor screenExecutor(Executor e) {
  6. if (!useCommonPool && e == ForkJoinPool.commonPool())
  7. return asyncPool;
  8. if (e == null) throw new NullPointerException();
  9. return e;
  10. }

我们查看

  1. asyncPool


的具体类型:

  1. /**
  2. * Default executor -- ForkJoinPool.commonPool() unless it cannot
  3. * support parallelism.
  4. */
  5. private static final Executor asyncPool = useCommonPool ?
  6. ForkJoinPool.commonPool() : new ThreadPerTaskExecutor();
  7. /** Fallback if ForkJoinPool.commonPool() cannot support parallelism */
  8. static final class ThreadPerTaskExecutor implements Executor {
  9. public void execute(Runnable r) { new Thread(r).start(); }
  10. }

默认是

  1. ForkJoinPool.commonPool()


,如果不支持并行则会构造一个新的

  1. ThreadPerTaskExecutor


线程池对象。
我们再次回到正题,我们可以查看调用链:

  1. java.util.concurrent.CompletableFuture#runAsync(java.lang.Runnable)
  1. java.util.concurrent.CompletableFuture#asyncRunStage
  1. java.util.concurrent.ForkJoinPool#execute(java.lang.Runnable)
  1. java.util.concurrent.ForkJoinPool#externalPush


最终调用到:

  1. java.util.concurrent.ForkJoinPool#registerWorker

如下图所示,大家可以在

  1. registerWorker


函数的设置守护线程代码的地方打断点,然后调试,通过查看左侧 “Debugger” 选项卡的 “Frames” 调用栈来研究整个调用过程,也可以切换到 “Threads” 来查看线程的运行状态。
16 虚拟机退出时机问题研究 - 图4
接下来我们看源码:

  1. /**
  2. * Callback from ForkJoinWorkerThread constructor to establish and
  3. * record its WorkQueue.
  4. *
  5. * @param wt the worker thread
  6. * @return the worker's queue
  7. */
  8. final WorkQueue registerWorker(ForkJoinWorkerThread wt) {
  9. UncaughtExceptionHandler handler;
  10. // 第 1 处
  11. wt.setDaemon(true); // configure thread
  12. // 省略中间代码
  13. wt.setName(workerNamePrefix.concat(Integer.toString(i >>> 1)));
  14. return w;
  15. }

从这里可知

  1. ForkJoinPool


的工作线程类型为守护者线程。
根据前面背景知识的介绍,我们可知如果只有守护线程,程序将退出。
另外,我们也可以从设置守护线程的函数中找到相关描述:

  1. /**
  2. * Marks this thread as either a {@linkplain #isDaemon daemon} thread
  3. * or a user thread. The Java Virtual Machine exits when the only
  4. * threads running are all daemon threads.
  5. *
  6. * <p> This method must be invoked before the thread is started.
  7. *
  8. * @param on
  9. * if {@code true}, marks this thread as a daemon thread
  10. *
  11. * @throws IllegalThreadStateException
  12. * if this thread is {@linkplain #isAlive alive}
  13. *
  14. * @throws SecurityException
  15. * if {@link #checkAccess} determines that the current
  16. * thread cannot modify this thread
  17. */
  18. public final void setDaemon(boolean on) {
  19. checkAccess();
  20. if (isAlive()) {
  21. throw new IllegalThreadStateException();
  22. }
  23. daemon = on;
  24. }

因此我们重新分析上面的案例:

  1. public static void main(String[] args) {
  2. // 第 1 处
  3. CompletableFuture.runAsync(() -> {
  4. try {
  5. TimeUnit.SECONDS.sleep(2L);
  6. } catch (InterruptedException ignore) {
  7. }
  8. System.out.println("异步任务");
  9. });
  10. // 第 2 处
  11. }

主线程为普通用户线程,执行到第 1 处,使用默认的

  1. ForkJoinPool


来异步执行传入的任务。
此时工作线程(守护线程)如果得到运行机会,调用

  1. TimeUnit.SECONDS.sleep(2L);


导致该线程

  1. sleep


2 秒钟。
主线程执行到第 2 处 (无代码),然后主线程执行完毕。
此时已经没有非守护线程,还不等工作线程从 Time waiting 睡眠状态结束,虚拟机发现已经没有非守护线程,便退出了。

*


有了上面的介绍,想必大家对虚拟机的退出时机有了一个不错的了解,那么我们看下面的代码片段:
请问程序执行后是否一定执行到 finally 代码块,为什么?

  1. public class Demo {
  2. public static void main(String[] args) {
  3. // 省略一些代码 (第 1 处)
  4. try {
  5. BufferedReader br = new BufferedReader(new FileReader("file.txt"));
  6. System.out.println(br.readLine());
  7. br.close();
  8. } catch (Exception e) {
  9. // 省略一些代码 (第 2 处)
  10. } finally {
  11. System.out.println("Exiting the program");
  12. }
  13. }
  14. }

结合今天所学内容,很多朋友可能会想到,在第 2 处如果让当前虚拟机退出,那么 finally 代码块就不会再执行。
因此可以添加

  1. System.exit(2)


来实现。
当然还有其他的方法能够实现,大家可以在评论区畅所欲言。

**


本节重点讲述了虚拟机退出的条件,举了几个案例让大家能够对此有深刻的理解。
本节使用了读源码法,官方文档法,断点调试法等来分析这两个案例。
下一节我们将讲述如何解决多条件语句和条件语句的多层嵌套问题。

**


请看下面代码片段,回答问题。

  1. public class Demo {
  2. public static void main(String[] args) {
  3. // 省略一些代码 (第 1 处)
  4. try {
  5. BufferedReader br = new BufferedReader(new FileReader("file.txt"));
  6. System.out.println(br.readLine());
  7. br.close();
  8. } catch (Exception e) {
  9. System.exit(2);
  10. } finally {
  11. System.out.println("Exiting the program");
  12. }
  13. }
  14. }

问题:如果 try 代码块发生异常,如何在第 1 处代码添加几行代码,使得 finally 代码块可以被执行到呢?

**


  1. [美] Tim Lindholm, Frank Yellin, Gilad Bracha, Alex Buckley.《 Java 虚拟机规范 (Java SE 8 版)》. [译] 爱飞翔,周志明等。机械工业出版社:2018:228 ↩︎
  2. [美] Brian Goetz, Tim Peierls,etc.《Java 并发编程实践》. 韩锴,方妙译。北京。电子工业出版社. 2007.164 ↩︎
  3. Tim Lindholm, Frank Yellin, Gilad Bracha, Alex Buckley.《Java Language Specification: Java SE 8 Edition》. 2015.378 ↩︎


15 学习线程池的正确姿势
17 如何解决条件语句的多层嵌套问题?

精选留言 5
欢迎在这里发表留言,作者筛选后可公开显示


再更,已经可以运行出老师的效果了,需要通过-Djava.security.policy指定policy文件,在里面加上对应文件的io权限就可以了。 之前是我没整明白grant语句的语法,误以为给了全部权限,实际上该grant语句只对file:${{java.ext.dirs}}/*下的代码给力全部权限。所以我们自己的代码还是没有权限去读取文件,需要自己授权。 老师为啥不在手记里提一下啊 16 虚拟机退出时机问题研究 - 图5
1
回复
2019-12-11

回复letro

嗯,你给的答案很不错,今天更新到手记里。
回复
2019-12-13 20:43:50


又仔细看了下System.setSecurityManager()函数, java程序默认是没有安全管理器的,我们在创建一个之后,会根据policy授权。 但在Policy.getPolicyNoCheck中,直接就拿到了policy,我单步调试也没找到它是啥时候被赋值的,我看了如果policy为空的话,应该会sun.security.provider.PolicyFile获取。大概意思就是会先加载jre/security/下的java.security,然后通过里面的一个属性记录(java.policy文件地址)来初始化Policy对象。 百度有人说需要我们自己指定policy地址不然默认的policy为空可通过-Djava.security.policy指定。 但我看了默认的policy不为空,给了全部权限,所以就很疑惑,为啥我会出现这个异常:AccessControlExceptio
0
回复
2019-12-10


思考题我跟着一些写,但是结果不一样,会发生一下异常 Exception in thread “main” java.security.AccessControlException: access denied (“java.io.FilePermission” “log4j.xml” “read”) 我跟着源码进去看了,奈何没太看懂,SecurityManager#hasAllPermission返回的是false,可以理解自己创建的SecurityManager没有权限吗~
0
回复
2019-12-10

回复letro

应该是环境被“污染”了,建议新建一个空的maven项目,然后再写代码验证。 注意创建项目目录当前执行的用户是否有权限。 如果还有问题再联系我。
回复
2019-12-11 10:29:26

回复letro

通过错误提示来看,显然项目类型和代码不一样。不要脱离前提谈问题,建议新建一个空的项目,按照示例代码来写。
回复
2019-12-11 11:07:17

回复letro

嗯,这位同学很细心。 但是请看题目,没说不允许抛出异常,只是要让finally 执行,抛出异常也可以执行的。 另外如果不想出现这个异常,该如何修改第1处代码呢?
回复
2019-12-11 11:32:52
点击展开后面 1 条

此小节的思考题大家可以进入java.lang.Runtime#exit 源码来思考解决方案。 一定一定一定要自己先思考之后再核对下参考答案: https://www.imooc.com/article/296817
0
回复
2019-12-01


问题:如果要阻止finally代码块执行,需要在第1处添加哪些代码? 可以这么理解吗:如果要阻止finally代码块执行,除了在第2处添加System.exit(2),可不可以在第1处添加一些代码来阻止finally代码块执行?
1
回复
2019-11-29

回复OhhhhhSun

对,多谢这位同学补充。
回复
2019-11-29 22:22:05

回复OhhhhhSun

重新看了一下代码没有问题,不是”阻止“finally代码块执行,而是”允许“其执行。 即:源码不变,请在第1处添加代码,让finally代码块可以被执行到。