blog.md 16 KB

用 ProcessBuilder 调子进程:从入门到不踩坑

写给用过 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 要在取结果之前。

总结

  • 管道式调用的骨架:启动 → 后台采流 → 喂 stdin → 关流发 EOF → 取退出码和结果。
  • 每一步都是前几节单独讲过的,拼起来就是你程序里的一级生产消费管道。

附录:踩坑速查

  1. 裸写 javac/javaerror=2 → 用 java.home 拼绝对路径。
  2. 路径没问题也报 error=2 → 查 directory() 的工作目录。
  3. waitFor(timeout) 超时、exitValue() 抛异常 → 先 destroy() + waitFor()
  4. 主线程读管道卡死、超时失效 → 输出交给后台线程。
  5. 子进程不退出 → 多半是没读 stdout/stderr,管道写满了(约 64KB)。
  6. "java -version" 报找不到文件 → 命令拆成 List.of("java", "-version")
  7. 需要 $VAR、通配符、管道这类 shell 功能 → 包一层 sh -c "..."
  8. getOutputStream() 是写给子进程的,不是读它的。方向先对一遍。