在 java 中通过 Runtime 启动一个外部进程是一个常见的做法,但是如果外部进程的输出流没有被正确的处理,往往会带来一些意想不到的结果。最近我就遇到过一次这样的问题。
在我的程序中,有这么一个功能,启动一个外部进程,获取该外部进程的输入流和输出流。这里权且将我的程序称为主程序,在主程序中启动起来的程序称做 子程序 。在主程序中,我们会拿到子程序的标准输入流和标准输出流,基于这两个流,主程序和子程序进行通信(不用问我为什么不用 socket ,这里有一堆理由~)。为了避免子程序空闲太长时间,子程序在空闲一段时间后,调用 System.exit(-1) 结束进程。
后来我们遇到什么问题呢?我们发现在某些环境上,程序跑一段时间后, CPU 会发生spike。经过一些定位后,我们发现CPU的SPIKE总是发生在子程序调用 System.exit(-1) 后,且子程序并没有退出,反而开始占用大量的CPU,单个进程大概占20%左右(想象我们会启动多个子进程,结果就是系统的CPU程序阶梯状的跳跃)。这时候我们顺理成章地上了jstack进行堆栈dump,通过对堆栈的分析,我们发现程序hang住在 java.util.logging.LogManager 的一个 shutdown hooker上,该hooker其实只做了一件事情,就是刷新标准错误流的buffer:
System.err.flush();
到这里,很明显,是因为子程序的标准错误流被遗忘了,我们虽然消耗了其标准输出流,却没有处理其错误流。所以,赶紧fix,fix的方法就是在主程序中添加一个错误流消耗线程。在关闭子程序的时候,我们会先关闭对应的错误流。
static class ErrStreamTask extends Thread {
private final InputStream is;
ErrStreamTask(InputStream is) {
this.is = is;
}
@Override
public void run() {
try {
int len = 0; byte [] buffer = new byte[1024];
while ((len = is.read(buffer)) != 0) { // may block is.close() in windows.
// handle out
}
}
catch (Throwable e) {
// log
}
}
public void close() {
try {
is.close();
}
catch (IO Exception e) {
e.printStackTrace();
}
}
}
这个问题的解决有两种方法:
- 在我们这个场景中,可以不用显示的去关闭输出流。我们在掉process.destory()时error stream也会被关闭
- 采用类似 EPOLL 轮训策略,在调用阻塞的 read() 方法钱,调用非阻塞的 available() 方法,该方法会得到流总可读的字节数(这个结果不精确),确保流中有数据了再调用阻塞的 read() 方法。
我们选取的是方案2。
经过这么一次折腾,还是有这么一些启发和思考:
- 对于程序内启动的外部程序,一定要记得消耗外部进程的标准输出流,切记还有标准错误流(有些三方库会利用标准错误流打日志啊!!!!)
- 开发过程中,关键路径的日志很重要,遇到问题时,日志是定位问题的一大利器,对快速分析和定位有很大的帮助
- 最后嘛,windows系统是一个让 程序员 又爱又恨的系统。好在现在大部分的服务都是泡在 linux 平台上。