一次 JVM 进程退出的原因分析

最近千锋重庆Java的小编在测试把 APM 平台迁移到 ES APM,有同学反馈了一个有意思的现象,部署在 docker 中 jar 包项目,在新版 APM 里进程启动完就退出了,被 k8s 中无限重启。

这篇文章写了一下排查的思路,主要包含了下面这些内容。

  • 一个 JVM 进程什么时候会退出
  • 守护线程、非守护线程
  • 从源码角度看 JVM 退出的过程

APM 底层就是使用一个 javaagent 的 jar 包来做字节码改写,那为什么两者会有这么大的差异呢?我一开始想当然认为是新版的 ES APM 代码有毒,导致服务起不来。后面我在本地非 docker 环境中跑了一次,发现没有任何问题,暂时排除了新版 APM 的问题。接下来看看代码是怎么写的。

@SpringBootApplication(scanBasePackages = ["com.masaike.**"])open class MyRpcServerApplicationfun main(args: Array<String>) {
    runApplication<MyRpcServerApplication>(*args)
    logger.info("#### Cla***pcServerApplication start success")
    System.`in`.read()
}
复制代码

在之前的文章《关于 /dev/null 差点直播吃鞋的一个小问题》中,我们分析过容器中的 stdin 指向 /dev/null。/dev/null 是一个特殊的设备文件,所有接收到的数据都会被丢弃。有人把 /dev/null 比喻为 “黑洞”,从 /dev/null 读数据会立即返回 EOF, System.in.read() 调用会直接退出。这篇文章的链接在这里:
mp.weixin.qq.com/s/lYajWCb-o…

所以执行 main 函数以后,main 线程就退出了,新旧 APM 都一样。接下来就是要弄清楚一个常见的问题:一个 JVM 进程什么时候会退出。

JVM 进程什么时候会退出

关于这个问题,Java 语言规范《12.8. Program Exit》小节里有写,链接在这里:
docs.oracle.com/javase/spec… ,我把内容贴在了下面。

A program terminates all its activity and exits when one of two things happens:

  • All the threads that are not daemon threads terminate.
  • Some thread invokes the exit method of class Runtime or class System, and the exit operation is not forbidden by the security manager.

翻译过来也就是导致 JVM 的退出只有下面这 2 种情况:

  • 所有的非 daemon 进程退出
  • 某个线程调用了 System.exit( ) 或 Runtime.exit() 显式退出进程

第二种情况当然不符合我们的情况,那嫌疑就放在了第一个上面,也就是换了新版本的 APM 以后,没有非守护进程在运行,所以 main 线程一退出,整个 JVM 进程就退出了。

接下来我们来验证这个想法,方法就是使用 jstack,为了不让接入了新版 APM 的 JVM 退出,先手动加上一个长 sleep。重新打包编译运行镜像,使用 jstack dump 出线程堆栈,可以直接阅读,或者使用「你假笨大神 PerfMa」公司的线程分析 XSheepdog 工具来分析。

一次 JVM 进程退出的原因分析


可以看到,新版 APM 里,只有一个阻塞在 sleep 上的 main 线程是非守护线程,如果这个线程也退出了,那就是所有的非守护线程都退出了。这里的 main 没有退出还是后来加了 sleep 导致的。

接下来对比一下旧版 APM,XSheepdog 分析结果如下所示。

一次 JVM 进程退出的原因分析


可以看到旧版 APM 里有 5 个非守护线程,其中 4 个非守护线程正是旧版 APM 内部的常驻线程。

到这里,原因就比较清楚了,在 docker 环境中 System.in.read() 调用不会阻塞,会立即退出,main 线程会结束。在旧版里,因为有常驻的非守护的 APM 处理线程在运行,所有整个 JVM 进程不会退出。在新版里,因为没有这些常驻的非守护线程,main 线程退出以后,就不存在非守护线程了,整个 JVM 就退出了。

源码分析

接下的源码分析以下面这段 Java 代码为例,

public class MyMain {    public static void main(String[] args) {
        System.out.println("in main");
    }
}
复制代码

接下来我们来调试源码看看,JVM 运行以后会进入 java.c 的 JavaMain 方法,

int JNICALL JavaMain(void * _args) {    // ...
    /* Initialize the virtual machine */
    InitializeJVM();    // 获取 public static void main(String[] args) 方法
    mainID = (*env)->GetStaticMethodID(env, mainClass, "main",                                       "([Ljava/lang/String;)V");    // 调用 main 方法
    (*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs);    // main 方法结束以后接下来调用下面的代码
    LEAVE();
}
复制代码

JavaMain 方法内部就是做了 JVM 的初始化,然后使用 JNI 调用了入口类的 public static void main(String[] args) 方法,如果 main 方法退出,则会调用后面的 LEAVE 方法。

#define LEAVE() \
    do { \
        if ((*vm)->DetachCurrentThread(vm) != JNI_OK) { \
            JLI_ReportErrorMessage(JVM_ERROR2); \
            ret = 1; \
        } \
        if (JNI_TRUE) { \
            (*vm)->DestroyJavaVM(vm); \
            return ret; \
        } \
    } while (JNI_FALSE)复制代码

LEAVE 方法调用了 DestroyJavaVM(vm); 来触发 JVM 退出,这个退出当然是有条件的。destroy_vm 的源码如下所示。

一次 JVM 进程退出的原因分析


可以看到,JVM 会一直等待 main 线程成为最后一个要退出的非守护线程,否则也没有退出的必要。这使用了一个 while 循环等待条件的发生。如果自己是最后一个,就可以准备整个 JVM 的退出了。

也可以把代码稍作修改,新建一个常驻的非守护线程 t,隔 3s 轮询 /tmp/test.txt 文件是否存在。main 线程在 JVM 启动后马上就退出了。

public class MyMain {    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {
            @Override            public void run() {
                File file = new File("/tmp/test.txt");                while(true) {                    if (file.exists()) {                        break;
                    }
                    System.out.println("not exists");                    try {
                        TimeUnit.SECONDS.sleep(3);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }

            }
        });
        t.setDaemon(false);
        t.start();
        System.out.println("in main");
    }
}
复制代码

这个例子中,main 函数退出时,会进入 destroy_vm 方法,在这个方法中,会 while 循环等待自己是最后一个非守护线程。如果非守护线程的数量大于 1,则一直阻塞等待,JVM 不会退出,如下所示。

一次 JVM 进程退出的原因分析


另外值得注意的是,java 的 daemon 线程概念是自己设计的,在 linux 原生线程中,并没有对应的特性。

小结

为了保证程序常驻运行,Java 中可以使用 CountDownLatch 等并发的类,等待不可能发生的条件。在 Go 中可以使用一个 channel 等待数据写入,但永远不会有数据写入即可。不要依赖外部的 IO 事件,比如本例中的读取 stdin 等。

【免责声明:本文图片及文字信息均由千锋重庆Java培训小编转载自网络,旨在分享提供阅读,版权归原作者所有,如有侵权请联系我们进行删除。】


上一篇:apm性能监控系统,程序员怎样优雅度过35岁中年危机?大厂内部资料


下一篇:Elasticsearch 用APM进行程序性能监控