# 用 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 // 用绝对路径指向 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 // 工具方法,返回 java 可执行文件的绝对路径 static String java() { return Paths.get(System.getProperty("java.home"), "bin", "java").toString(); } ``` `directory()` 不设的话,子进程继承当前目录。目录给错,报错还是 `error=2`,跟命令找不到是一模一样的错,排查时别只盯着前半截。另外子进程程序得先编译好,案例里用 `Jdk.compileFixture` 统一处理了这件事。 shell 的功能一条也别指望:引号展开、`$VAR`、通配符、管道符、重定向,全都不生效。确实需要的时候,显式走 `sh -c "..."`: ```java List.of("sh", "-c", "echo $HOME | wc -c") ``` ### 总结 - 命令拆成列表、可执行文件用绝对路径、工作目录显式指定,能挡掉大部分 `error=2`。 - `ProcessBuilder` 不等于 shell,参数列表就是子进程收到的参数。 --- ## 二、给子进程喂数据、读结果 ### 场景问题 要调的命令行工具需要从标准输入收数据,处理完往标准输出写结果,出错了往标准错误写。到了 Java 这边,第一反应是 `getInputStream()` 怎么读不到东西,程序时不时还卡死。 ### 解决方案 子进程 `code/io/Main.java` 干的事:启动打一行欢迎语,逐行回显 stdin,读到 EOF 往 stderr 打统计。三个 API 各演示一次,正好对上。 读标准输出: ```java 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(); ``` 读标准错误: ```java 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(); ``` 写标准输入: ```java 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,只读一路: ```java 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(); ``` 三个流全部重定向到文件,适合"跑完再看日志": ```java 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` | 直接扔 | 完全不需要 | ```java 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`。 多线程最直接,一个流一个线程,主线程不堵: ```java 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` 是个通用小工具,随手放工具类里: ```java 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` 一遍: ```java Process process = startChild(); CompletableFuture stdoutFuture = CompletableFuture.runAsync(() -> new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) .lines().forEach(line -> log.info("[cf-stdout] {}", line))); CompletableFuture stderrFuture = CompletableFuture.runAsync(() -> new BufferedReader(new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8)) .lines().forEach(line -> log.info("[cf-stderr] {}", line))); CompletableFuture 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()` 发终止信号,再阻塞等它真正结束,然后才能读退出码: ```java 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` 就是一条完整的调用管道: ```java // 1. 启动子进程(外部工具) Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main") .directory(WORK_DIR) .start(); // 2. 异步把 stdout 收成结果列表,异步把 stderr 消费掉 CompletableFuture> resultFuture = CompletableFuture.supplyAsync(() -> new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) .lines().collect(Collectors.toList())); CompletableFuture 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 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`/`java` 报 `error=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()` 是写给子进程的,不是读它的。方向先对一遍。