[BUG 记录] Java Runtime.exec() 假死

282 阅读2分钟

这是一个 Java Web 程序,里面跑着一个定时任务,当任务触发时,程序需要调用一个外部的命令行工具,暂且叫它 external-tool, 示例代码如下:

public static void main(String[] args) {
        Process p = null;
        try {
            p = Runtime.getRuntime().exec("external-tool arg0 arg1 arg2");
            int exitValue = p.waitFor();
            if (exitValue != 0) {
                System.out.println("Exec failed");
                return;
            }
            System.out.println("Exec success");
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (p != null) {
                p.destroy();
            }
        }
    }

其中, arg* 在实际程序中是动态组装的,每次任务的参数列表不同。

测试过程中发现,某些特定的参数组合会造成程序 假死,表现为:Java web 程序不报错,不继续运行;通过查看系统进程,能够看到 external-tool 的进程,而且 CPU, 内存占用都很低。

初步调试发现:

  • 由于 external-tool 工具自己会输出日志到本地文件以及标准输出,通过查看日志文件,发现每次程序 假死, 工具的日志都是卡在一个固定的位置,也没有报错。
    • 磁盘 IO 也正常,不存在日志文件无法写入的情况
  • 单独把造成假死的参数 arg* 拿出来组成同样的命令,开启一个新的命令行手动执行,一切正常
    • 也验证了上一条,说明工具一切正常

继续调试,当程序假死的时候,把 external-tool 的进程强制杀死,此时 Java web 程序抛出异常, 说明刚才卡在了 p.waitFor() 这一行。

这说明通过 Runtime.exec() 开启的进程和通过命令手动执行的进程在某些地方是不一样的,最终通过搜索加测试,定位到根本原因在于:

  • 每个进程都有自己对应的的输出流,包括标准输出流和错误输出流
  • 如果主程序不主动接管子进程的输出流,那么子进程的输出流就会写到系统的缓冲区中
  • 系统的缓冲区是有固定大小的,这也是为什么这个 BUG 中会出现每次都会卡在同一个地方,因为每运行到这个地方,系统的缓冲区就被写满了

所以解决方案是主程序要接管子进程的输出流并不断读取,防止子程序的输出缓冲区被写满,修改后的示例如下:

public static void main(String[] args) {
        Process p = null;
        BufferedReader br = null;
        BufferedReader brError = null;
        try {
            p = Runtime.getRuntime().exec("external-tool arg0 arg1 arg2");
            String line = null;
            // read input stream
            br = new BufferedReader(new InputStreamReader(p.getInputStream()));
            while ((line = br.readLine()) != null) {}
            // read error stream
            brError = new BufferedReader(new InputStreamReader(p.getErrorStream()));
            while ((line = brError.readLine()) != null) {}
            int exitValue = p.waitFor();
            if (exitValue != 0) {
                System.out.println("Exec failed");
                return;
            }
            System.out.println("Exec success");
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (p != null) {
                p.destroy();
            }
        }
    }