**


前一节我们讲述了如何通过读源码,查询 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代码块可以被执行到。