写给用过
Runtime.exec()但没认真看过ProcessBuilder文档的你。每节先讲一个你会遇到的麻烦,再给解法,最后用两句收个尾。
"Java 调外部命令"搜出来两种写法:Runtime.exec() 和 ProcessBuilder。大部分人照抄能跑,但这两个类到底各管什么、哪些配置是必填的,心里没数。
其实就两步。先用 ProcessBuilder 描述"怎么启动":命令是什么、工作目录在哪、环境变量什么值、标准流往哪个源头走。然后调 start() 把子进程真正拉起来,拿回一个 Process 对象。读写三路流、等退出、销毁,都靠这个对象。
真正让人犯迷糊的是主进程跟子进程之间那三根管道的方向:
主进程 子进程
getOutputStream() ────────────▶ 标准输入 stdin
getInputStream() ◀─────────── 标准输出 stdout
getErrorStream() ◀─────────── 标准错误 stderr
getInputStream() 是把子进程的输出读回来,getOutputStream() 是把数据喂给子进程,名字跟直觉正好相反。我看这张图时绕了好一会。箭头往哪指,数据就往哪流,照这个记就不会错。
ProcessBuilder 管"怎么启动",Process 管"启动之后干什么"。想让 Java 程序去编译并运行另一个 main,写了 new ProcessBuilder("java Main"),一跑就 error=2。可同样的命令在终端里好好的。
先看核心写法(QuickStart.java):
// 用绝对路径指向 java 可执行文件 + 类名,directory() 指定工作目录
Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
.directory(workDir)
.start();
// 读子进程的标准输出
new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))
.lines()
.forEach(line -> log.info("子进程标准输出: {}", line));
// 等子进程结束,拿退出码
int exitStatus = process.waitFor();
看起来简单,里面其实埋了坑。
ProcessBuilder 不走 shell,命令要拆成参数列表。写 "java -version" 这种带空格的字符串,它会把整个东西当成一个可执行文件名去找,自然找不到。
子进程的 PATH 里也经常没有 JDK 的 bin 目录,javac/java 要用 java.home 拼绝对路径,裸写命令名就是 error=2:
// 工具方法,返回 java 可执行文件的绝对路径
static String java() {
return Paths.get(System.getProperty("java.home"), "bin", "java").toString();
}
directory() 不设的话,子进程继承当前目录。目录给错,报错还是 error=2,跟命令找不到是一模一样的错,排查时别只盯着前半截。另外子进程程序得先编译好,案例里用 Jdk.compileFixture 统一处理了这件事。
shell 的功能一条也别指望:引号展开、$VAR、通配符、管道符、重定向,全都不生效。确实需要的时候,显式走 sh -c "...":
List.of("sh", "-c", "echo $HOME | wc -c")
error=2。ProcessBuilder 不等于 shell,参数列表就是子进程收到的参数。要调的命令行工具需要从标准输入收数据,处理完往标准输出写结果,出错了往标准错误写。到了 Java 这边,第一反应是 getInputStream() 怎么读不到东西,程序时不时还卡死。
子进程 code/io/Main.java 干的事:启动打一行欢迎语,逐行回显 stdin,读到 EOF 往 stderr 打统计。三个 API 各演示一次,正好对上。
读标准输出:
Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
.directory(WORK_DIR).start();
Thread stdoutDrain = drain("stdout", process.getInputStream());
process.getOutputStream().close(); // 不写任何 stdin,直接关,子进程读到 EOF
stdoutDrain.join();
读标准错误:
Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
.directory(WORK_DIR).start();
Thread stderrDrain = drain("stderr", process.getErrorStream());
process.getOutputStream().close(); // 触发子进程 EOF 后打 stderr
stderrDrain.join();
写标准输入:
Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
.directory(WORK_DIR).start();
Thread stdoutDrain = drain("stdout", process.getInputStream());
Thread stderrDrain = drain("stderr", process.getErrorStream());
try (OutputStream stdin = process.getOutputStream()) {
stdin.write("第一行输入\r\n".getBytes(StandardCharsets.UTF_8));
stdin.write("第二行输入\r\n".getBytes(StandardCharsets.UTF_8));
}
// try-with-resources 关流 = 发送 EOF
对应关系还是第一节那张图:
| 子进程侧 | 主进程 API | 说明 |
|---|---|---|
| stdin | process.getOutputStream() |
主进程往里写 |
| stdout | process.getInputStream() |
子进程 System.out.println 的内容 |
| stderr | process.getErrorStream() |
子进程 System.err.println 的内容 |
这里两个坑,方向相反。一个是 lines().forEach(...) 是阻塞的,一直读到 EOF 才返回,而 EOF 要等子进程退出才有。在主线程这么读,等于把自己钉在子进程的整个生命周期上,后面的 waitFor(2, SECONDS) 根本轮不到执行。读输出的活得交给后台线程。另一个反过来:你完全不读 stdout/stderr,输出一旦超过管道缓冲区(Linux 约 64KB),子进程写不进去就卡死,永不退出。
getOutputStream() 就是发 EOF,是让子进程进入"输入结束、收尾输出"的标准动作。不是每个场景都想抱着管道读。输出太多,管道缓冲区不顶用;想留日志,让结果直接落盘;stderr 压根不在乎,懒得再起个线程。管道方案在这里都嫌多余。
ProcessBuilder 给你三种脱手的方式。
stderr 合并到 stdout,只读一路:
Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
.directory(WORK_DIR)
.redirectErrorStream(true) // stderr 并进 stdout 管道
.start();
Thread merged = drain("merged", process.getInputStream());
process.getOutputStream().close();
merged.join();
三个流全部重定向到文件,适合"跑完再看日志":
Path input = dir.resolve("input.txt");
Path stdout = dir.resolve("stdout.txt");
Path stderr = dir.resolve("stderr.txt");
Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
.directory(WORK_DIR)
.redirectInput(input.toFile()) // stdin 从文件读
.redirectOutput(stdout.toFile()) // stdout 写到文件
.redirectError(stderr.toFile()) // stderr 写到文件
.start();
process.waitFor();
// 事后读文件校验
log.info("stdout: {}", Files.readAllLines(stdout));
log.info("stderr: {}", Files.readAllLines(stderr));
Redirect 枚举则是三种默认态度:
| 枚举值 | 行为 | 什么时候用 |
|---|---|---|
PIPE |
输出进管道,父进程用 getInputStream() 读 |
要程序化处理 |
INHERIT |
直接打父进程终端,不进管道 | 不想碰,看着就行 |
DISCARD |
直接扔 | 完全不需要 |
Process p = new ProcessBuilder("ls", "-la")
.redirectOutput(ProcessBuilder.Redirect.INHERIT)
.redirectError(ProcessBuilder.Redirect.INHERIT)
.start();
p.waitFor();
Redirect.to(File) 覆盖写、appendTo(File) 追加写,配 redirectInput/Output/Error(Redirect) 用。
INHERIT/DISCARD 让你完全不用管输出。设了 waitFor(5, SECONDS),指望 5 秒超时,主线程却永远到不了那行。找问题找了一会,发现是前面读子进程输出的同步代码把主线程钉死了。
两条路:多线程,或者 CompletableFuture。
多线程最直接,一个流一个线程,主线程不堵:
Process process = startChild();
Thread stdoutDrain = drain("thread-stdout", process.getInputStream());
Thread stderrDrain = drain("thread-stderr", process.getErrorStream());
try (OutputStream stdin = process.getOutputStream()) {
stdin.write("hello thread\r\n".getBytes(StandardCharsets.UTF_8));
}
// 等两个读取线程把 EOF 读完,也就是子进程退出
stdoutDrain.join();
stderrDrain.join();
process.waitFor();
drain 是个通用小工具,随手放工具类里:
static Thread drain(String tag, InputStream in) {
Thread thread = new Thread(
() -> new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))
.lines()
.forEach(line -> log.info("[{}] {}", tag, line)),
"drain-" + tag);
thread.setDaemon(true);
thread.start();
return thread;
}
CompletableFuture 适合想顺手做点编排的场合。JDK 9 加了 process.onExit(),进程一退出 Future 自动完成,自己不用再 waitFor 一遍:
Process process = startChild();
CompletableFuture<Void> stdoutFuture = CompletableFuture.runAsync(() ->
new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))
.lines().forEach(line -> log.info("[cf-stdout] {}", line)));
CompletableFuture<Void> stderrFuture = CompletableFuture.runAsync(() ->
new BufferedReader(new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))
.lines().forEach(line -> log.info("[cf-stderr] {}", line)));
CompletableFuture<Void> exitFuture = process.onExit()
.thenAccept(p -> log.info("onExit 回调: 退出码 {}", p.exitValue()));
try (OutputStream stdin = process.getOutputStream()) {
stdin.write("hello future\r\n".getBytes(StandardCharsets.UTF_8));
}
CompletableFuture.allOf(stdoutFuture, stderrFuture, exitFuture).join();
Process 用法里最省事也最容易省错的一步。onExit() 配合 CompletableFuture,能省掉一整套手动等退出的样板代码。子进程可能很快跑完,也可能一直挂着。想限制等待时间,用了 waitFor(2, SECONDS),返回 false,接着想读退出码,exitValue() 直接抛 IllegalThreadStateException。一个早上被 waitFor 的两个版本轮流折磨。
waitFor() 无参版返回退出码,会阻塞到子进程结束,没什么可说的。带超时的版本返回的是 boolean,true/false 表示"超时时间内退没退出",不是退出码。超时返回 false 时子进程还活着,这时候 exitValue() 必抛异常。正确做法是先 destroy() 发终止信号,再阻塞等它真正结束,然后才能读退出码:
Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
.directory(WORK_DIR).start();
boolean finished = process.waitFor(2, TimeUnit.SECONDS);
log.info("2 秒内是否退出: {}", finished);
if (!finished) {
process.destroy(); // Linux 发 SIGTERM
process.waitFor(); // 等它把信号处理完
}
int exitCode = process.exitValue(); // 被 SIGTERM 终止常见 143
log.info("退出码: {}", exitCode);
destroy() 是优雅终止,给子进程机会收拾残留。真赖着不走的,destroyForcibly() 直接发 SIGKILL。
几个容易绕错的点,放一起看:
| 场景 | 错误做法 | 正确做法 |
|---|---|---|
| 超时后直接读退出码 | process.exitValue() 抛异常 |
destroy() + waitFor() 再读 |
| 想强制杀 | destroy() 只发 SIGTERM |
destroyForcibly() 发 SIGKILL |
| 不读流就 waitFor | 管道写满、子进程卡死 | 先后台线程 drain |
Windows 提一句:destroy() 走 TerminateProcess,没有 143 这种信号退出码,行为跟 Linux 不一样。
waitFor(timeout) 给的是 boolean;超时后读退出码,destroy() → waitFor() → exitValue(),三步一个都不能省。攒到最后,需求通常是这样的:Java 程序调一个外部 CLI,喂一批数据进去,把处理结果拿回来,还要确认工具到底成功没有。前几节那些散装知识,怎么拼成一套完整流程?
ProcessPipelineExample 就是一条完整的调用管道:
// 1. 启动子进程(外部工具)
Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
.directory(WORK_DIR)
.start();
// 2. 异步把 stdout 收成结果列表,异步把 stderr 消费掉
CompletableFuture<List<String>> resultFuture = CompletableFuture.supplyAsync(() ->
new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))
.lines().collect(Collectors.toList()));
CompletableFuture<Void> stderrDrain = CompletableFuture.runAsync(() ->
new BufferedReader(new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))
.lines().forEach(line -> log.info("[stderr] {}", line)));
// 3. 向 stdin 喂数据,关流表示 EOF
try (OutputStream stdin = process.getOutputStream()) {
for (int i = 1; i <= 3; i++) {
stdin.write(("待处理数据-" + i + "\r\n").getBytes(StandardCharsets.UTF_8));
}
}
// 4. 等退出码,取回异步收集的结果
int exit = process.waitFor();
List<String> result = resultFuture.get();
// 5. 校验回显行数
long echoed = result.stream().filter(line -> line.startsWith("child: echo")).count();
log.info("退出码: {}", exit);
log.info("回显结果: {}", result);
log.info("回显行数: {},符合预期: {}", echoed, echoed == 3);
预期输出:
[stderr] child: stdin EOF after 3 line(s)
退出码: 0
回显结果: [child: started, waiting for stdin, child: echo 待处理数据-1, child: echo 待处理数据-2, child: echo 待处理数据-3]
回显行数: 3,符合预期: true
放到真实项目里,要替换的地方很清楚:第 2 步的 collect(toList()) 换成你的业务消费(逐行解析、透传日志),第 3 步的循环换成从数据库、队列读数据,第 5 步的校验换成业务断言。骨架不变。
这套骨架其实就是前几节的组合:启动(第一节)、后台采流(第四节)、喂 stdin(第二节)、取退出码(第五节)。单看每一步都不难,难点在别把顺序搞乱,尤其是关流发 EOF 要在取结果之前。
javac/java 报 error=2 → 用 java.home 拼绝对路径。error=2 → 查 directory() 的工作目录。waitFor(timeout) 超时、exitValue() 抛异常 → 先 destroy() + waitFor()。"java -version" 报找不到文件 → 命令拆成 List.of("java", "-version")。$VAR、通配符、管道这类 shell 功能 → 包一层 sh -c "..."。getOutputStream() 是写给子进程的,不是读它的。方向先对一遍。