在编写Java程序时,有时候我们需要调用其他的诸如exe,shell这样的程序或脚本。

Java虚拟机执行Runtime.getRuntime().exec()方法的过程是:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统消耗会很大,不仅是CPU,内存负担也很大。

在Java中提供了两种方法来启动其他程序:

使用Runtime的exec()方法

使用ProcessBuilder的start()方法 。

Runtime和ProcessBulider提供了不同的方式来启动程序,设置启动参数、环境变量和工作目录。但是这两种方法都会返回一个用于管理操作系统进程的Process对象,再调用Process.waitFor()来等待命令执行结束,获取执行结果。

然而这样简单的调用也是有坑的,有几个地方需要小心留意

###1、命令拼接空格问题(推荐使用ProcessBuilder) ####1.1 通过Runtime.getRuntime().exec()方式

String htmlName = CONTRACT_PATH + File.separator + cid + HTML_POSTFIX;
String pdfName = CONTRACT_PATH + File.separator + cid + PDF_POSTFIX;
String command = wkhtmltopdf + " " + htmlName + " " + pdfName;
Process proc = Runtime.getRuntime().exec(command);

这种方式下,可以通过字符串直接拼接命令,命令中可以包含有空格。

####1.2 通过ProcessBuilder(command).start()方式

让我们先来看一下ProcessBuilder类的方法,这里传入的command为可变参数类型

public ProcessBuilder(String... command) {
this.command = new ArrayList<>(command.length);
for (String arg : command)
this.command.add(arg);
}

当通过这种方式调用的时候,必须注意,不能将命令拼接成字符串传入,需要将各个参数分别传入,如:

ProcessBuilder pb = new ProcessBuilder(wkhtmltopdf, htmlName, pdfName);
Process process = pb.start();

如果在这种方式下,你传的command是经过拼接的字符串命令(一般情况下是含有空格的),会报错:

java.io.IOException: Cannot run program "C:/getapkinfo/aapt.exe d": CreateProcess error=2, ϵͳÕҲ»µ½ָ¶

###2、调用系统命令的阻塞问题 假如现在我需要在java中调用本地系统的wkhtmltopdf,将html文档转化为pdf文档,并对pdf文档做其他处理。那么在对pdf文档做其他处理之前,我必须要等系统命令对html转pdf的完成,这里就存在一个阻塞的问题。

JDK对waitFor方法的解释是:

public abstract int waitFor() throws InterruptedException

导致当前线程等待,如有必要,一直要等到由该 Process 对象表示的进程已经终止。如果已终止该子进程,此方法立即返回。如果没有终止该子进程,调用的线程将被阻塞,直到退出子进程。

返回: 进程的出口值。根据惯例,0 表示正常终止。

抛出: InterruptedException - 如果当前线程在等待时被另一线程中断,则停止等待,抛出 InterruptedException。

因为本地的系统对标准输入和输出所提供的缓冲池有限,所以错误的对标准输出快速的写入和从标准输入快速的读入都有可能造成子进程死锁。问题的关键在缓冲区这个地方:可执行程序的标准输出比较多,而运行窗口的标准缓冲区不够大,所以发生阻塞。接着来分析缓冲区,当Runtime对象调用exec(cmd)后,JVM会启动一个子进程,该进程会与JVM进程建立三个管道连接:标准输入,标准输出和标准错误流。假设该程序不断在向标准输出流和标准错误流写数据,而JVM不读取的话,当缓冲区满之后将无法继续写入数据,最终造成阻塞在waitfor()这里。

需要注意读取程序的stdout和stderr都是阻塞的操作,这意味着必须在两个线程里分别读取,而不是在一个线程里一次读取,否则还是有可能出现阻塞的情况:比如先读取stdout再读取stderr,如果程序的stderr输出已经填满了缓冲区,程序就会阻塞不继续执行,但是java线程又阻塞在读取stdout上,只有stdout结束了才会去读取stderr。结果就是互相等待着的过程中哦给你程序卡死了。 为了不让程序变得过于复杂,我们可以把程序的stderr重定向到stdout中,这样只要读取stdout一个就好了。缺点就是没有区分出错的输出信息和正常的输出信息。

** 但是在其中过程中真正起关键作用的缓冲区是getErrorStream()对应的那个缓冲区没有被清空,意思就是说其实只要及时读取标准错误流缓冲区的数据程序就不会被block。**

这里提供一个系统调用的工具类,代码如下:

import org.apache.commons.io.IOUtils;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.TimeoutException;
/**
* 一个进程调用工具.
* Created by chenyh on 2016/8/5.
*/
public class ProcessUtils {
/**
* 运行一个外部命令,返回状态.若超过指定的超时时间,抛出TimeoutException
*
*/
public static ProcessStatus execute(final long timeout, final String... command)
throws IOException, InterruptedException, TimeoutException {
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
Process process = pb.start();
Worker worker = new Worker(process);
worker.start();
ProcessStatus ps = worker.getProcessStatus();
try {
worker.join(timeout);
if (ps.exitCode == ProcessStatus.CODE_STARTED) {
// not finished
worker.interrupt();
throw new TimeoutException();
} else {
return ps;
}
} catch (InterruptedException e) {
// canceled by other thread.
worker.interrupt();
throw e;
} finally {
process.destroy();
}
}
private static class Worker extends Thread {
private final Process process;
private ProcessStatus ps;
private Worker(Process process) {
this.process = process;
this.ps = new ProcessStatus();
}
public void run() {
try {
InputStream is = process.getInputStream();
try {
ps.output = IOUtils.toString(is);
} catch (IOException ignore) { }
ps.exitCode = process.waitFor();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public ProcessStatus getProcessStatus() {
return this.ps;
}
}
public static class ProcessStatus {
public static final int CODE_STARTED = -257;
public volatile int exitCode;
public volatile String output;
}
}