|
|
@@ -0,0 +1,548 @@
|
|
|
+# 使用 ProcessBuilder 调用子进程
|
|
|
+
|
|
|
+在前面,我们了解了使用`Runtime.exec()`来启动外部程序的基本操作,虽然子进程能够被顺利拉起来,但是我们很快就会感觉到,这种方式过于粗糙:想要调整子进程的工作目录、设定环境变量、控制标准流的去向,都显得心有余而力不足。也正是因为这种种不方便,从JDK 5开始,`java.lang.ProcessBuilder`被带到了我们的面前,相比传统的`Runtime.exec()`,我们对子进程的控制,有了更多的选择。
|
|
|
+
|
|
|
+> JDK 5 — 悄悄改变Java的一次升级
|
|
|
+>
|
|
|
+> 2004年推出的Tiger(即JDK 5),大概是大伙最熟悉的一个版本了:泛型、自动装箱、枚举、可变参数、注解……一大堆现已成为Java语法基座的新特性,都是从这一次升级开始的。而在这一大票耀眼的新特性当中,有一个不起眼的小类`java.lang.ProcessBuilder`,它正是我们今天的主角。可以说,Java对外部进程的精细化控制,正是从这次升级正式拉开序幕的。
|
|
|
+
|
|
|
+那么,从本部分开始,就让我们来感受一下,ProcessBuilder为我们带来了什么。
|
|
|
+
|
|
|
+***
|
|
|
+
|
|
|
+## 认识ProcessBuilder与Process
|
|
|
+
|
|
|
+在使用一个陌生的类之前,我们首先应该弄清楚:它到底帮我们解决了什么问题,以及它是如何融入我们现有的代码的。这里的核心类有两个,我们分别来看。
|
|
|
+
|
|
|
+### 一切从start()开始
|
|
|
+
|
|
|
+从名字上就可以看出来,ProcessBuilder是负责"构建"一个进程的,它主要描述"怎么启动":命令是什么、工作目录在哪、环境变量是什么值、标准流往哪个源头走。而真正把子进程拉起来,则要靠它提供的`start()`方法,拿回的是一个`Process`对象:
|
|
|
+
|
|
|
+```java
|
|
|
+ProcessBuilder builder = new ProcessBuilder("java", "Main"); //只描述"怎么启动"
|
|
|
+Process process = builder.start(); //真正拉起子进程,返回值用于后续的一切操作
|
|
|
+```
|
|
|
+
|
|
|
+子进程启动之后,读写它的三路标准流、等待它结束、把它销毁,这些操作都发生在`Process`对象上。简单来说,**ProcessBuilder管"怎么启动",Process管"启动之后干什么"**。既然搞清楚了分工,我们接着来看看,Process类上都提供了哪些方法供我们调遣:
|
|
|
+
|
|
|
+```java
|
|
|
+public abstract class Process {
|
|
|
+ public abstract InputStream getInputStream(); //子进程的标准输出,注意是"读回来"
|
|
|
+ public abstract OutputStream getOutputStream(); //子进程的标准输入,注意是"写进去"
|
|
|
+ public abstract InputStream getErrorStream(); //子进程的标准错误
|
|
|
+ public abstract int waitFor() throws InterruptedException; //阻塞等待结束,返回退出码
|
|
|
+ public boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException; //限时等待,返回boolean
|
|
|
+ public abstract void destroy(); //优雅终止(Linux下发送SIGTERM)
|
|
|
+ public abstract Process destroyForcibly(); //强制终止(Linux下发送SIGKILL)
|
|
|
+ public abstract int exitValue(); //读取退出码,进程未结束会抛异常
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+我们可以看到,整套API其实相当简洁,剩下的问题,就是搞清楚它们各自是怎么用的。其中,最让人犯迷糊的,是三根管道。我们先来看这个。
|
|
|
+
|
|
|
+### 三根管道,方向别搞反
|
|
|
+
|
|
|
+主进程和子进程通信,靠的其实就是三根"管道"。真正让新手绕晕的,是这三根管道的方向——因为`getOutputStream()`和`getInputStream()`的名字,和直觉正好相反:
|
|
|
+
|
|
|
+```
|
|
|
+ 主进程 子进程
|
|
|
+ getOutputStream() ────────────▶ 标准输入 stdin
|
|
|
+ getInputStream() ◀─────────── 标准输出 stdout
|
|
|
+ getErrorStream() ◀─────────── 标准错误 stderr
|
|
|
+```
|
|
|
+
|
|
|
+注意这里有个极易搞反的点:`getInputStream()`是**把子进程的输出读回来**,`getOutputStream()`是**把数据喂给子进程**。开发者不需要自己动手去创建管道,只要认准一个原则就够了:**箭头往哪指,数据就往哪流**。
|
|
|
+
|
|
|
+**思考:** 如果方向真的被记反了,我们压根没法编译通过——因为`getOutputStream()`返回的是`OutputStream`,根本没有`read()`方法,接口设计本身就在编译期逼着你把方向搞对。那么问题来了:假如我们用`getInputStream()`读到的东西一直是空的、而且程序好像卡住了,最可能的原因是什么?想清楚这个问题,下一篇内容你就走在了别人的前面。
|
|
|
+
|
|
|
+我们接着就动手,来写第一段能够真正运行的代码。
|
|
|
+
|
|
|
+***
|
|
|
+
|
|
|
+## 启动第一个子进程
|
|
|
+
|
|
|
+前面我们只是认识了这两个类,那么实际用起来,又会遇到什么样的问题呢?我们直接来写第一段代码。
|
|
|
+
|
|
|
+### 核心写法
|
|
|
+
|
|
|
+我们的目标,是让Java程序去编译并运行另一个`main`。快速开始的写法是这样的(`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**。它把第一个参数当作可执行文件,后面的参数原封不动地作为参数传给它。很多人习惯性地写出`new ProcessBuilder("java Main")`这种带空格的字符串,结果子进程把`"java Main"`整个当作一个可执行文件名去找,自然是找不到的。
|
|
|
+
|
|
|
+正确的做法,是把命令拆成一个参数列表:
|
|
|
+
|
|
|
+```java
|
|
|
+new ProcessBuilder("java", "-version"); //而不是 "java -version"
|
|
|
+```
|
|
|
+
|
|
|
+### 子进程的PATH里没有JDK
|
|
|
+
|
|
|
+还有一个非常隐蔽的坑:子进程的环境变量往往是继承自父进程的,而我们的PATH里经常缺了JDK的bin目录,这时候裸写`javac`/`java`命令名,启动就报`error=2`。所以,可执行文件要用`java.home`拼出绝对路径:
|
|
|
+
|
|
|
+```java
|
|
|
+// 工具方法,返回 java 可执行文件的绝对路径
|
|
|
+static String java() {
|
|
|
+ return Paths.get(System.getProperty("java.home"), "bin", "java").toString();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+以我们的案例为例,`Jdk.java()`就封装了这个过程,这样就不用再担心PATH的问题。
|
|
|
+
|
|
|
+**注意:** `directory()`如果不设置,子进程会继承当前目录。而工作目录一旦给错,报的错也是`error=2`,跟命令找不到完全一样。排查时别只盯着命令那一半,工作目录也是重点怀疑对象。此外,子进程的程序得先编译好,案例里的`Jdk.compileFixture`统一处理了这件事。
|
|
|
+
|
|
|
+### shell的功能,一条也别指望
|
|
|
+
|
|
|
+引号展开、`$VAR`变量、通配符、管道符、重定向……这些shell提供的便利在ProcessBuilder这里统统不生效。真的需要的时候,就显式地包一层`sh -c`:
|
|
|
+
|
|
|
+```java
|
|
|
+List.of("sh", "-c", "echo $HOME | wc -c")
|
|
|
+```
|
|
|
+
|
|
|
+***
|
|
|
+
|
|
|
+## 与子进程交换数据
|
|
|
+
|
|
|
+跑通了第一个子进程,接下来我们就要真正和它对话了:往它的标准输入里喂数据,从标准输出读回结果,还要顺便收下标准错误。我们的子进程程序`code/io/Main.java`干的事正好对应三个点:启动时打一行欢迎语,逐行回显stdin,读到EOF往stderr打统计。
|
|
|
+
|
|
|
+### 读回标准输出
|
|
|
+
|
|
|
+想要把子进程`System.out.println`的内容拿回来,就用前面那张图的对应关系,取`getInputStream()`:
|
|
|
+
|
|
|
+```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();
|
|
|
+```
|
|
|
+
|
|
|
+这里出现了一个"关流即EOF"的动作:子进程的程序往往要读到标准输入的EOF才会进入"收尾输出"的环节,所以我们在写之前先把输入流关掉,等它自己把该打印的打印完。
|
|
|
+
|
|
|
+### 捕获标准错误
|
|
|
+
|
|
|
+类似地,标准错误对应`getErrorStream()`:
|
|
|
+
|
|
|
+```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();
|
|
|
+```
|
|
|
+
|
|
|
+### 向标准输入喂数据
|
|
|
+
|
|
|
+真正往子进程里喂数据,用的是`getOutputStream()`:
|
|
|
+
|
|
|
+```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` 的内容 |
|
|
|
+
|
|
|
+### 两个方向相反的坑
|
|
|
+
|
|
|
+看到这里,细心的你一定发现了:前面读stdout、stderr的时候,我们都用了一个`drain`方法在后台做。为什么不能直接在主线程里一个`lines().forEach(...)`就完事?这里有着两个方向完全相反的坑。
|
|
|
+
|
|
|
+**坑一:`lines().forEach(...)`是阻塞的。** 它一直要读到EOF才返回,而EOF要等子进程退出才有。在主线程这么读,等于把自己钉在子进程的整个生命周期上,后面就算写了`waitFor(2, SECONDS)`,也根本轮不到执行——超时被前面这行同步代码彻底废掉了。
|
|
|
+
|
|
|
+**坑二:完全不去读,又会卡死。** 如果你把主线程的活干完之后就傻等着`waitFor()`,而子进程的输出却没人接管,一旦输出量超过了管道缓冲区(Linux下大约是64KB),子进程就再也写不进去,于是永远卡在那里不退出。
|
|
|
+
|
|
|
+**注意:** 子进程的三路输出,一律交给后台线程去读,没有例外。
|
|
|
+
|
|
|
+***
|
|
|
+
|
|
|
+## 重定向:把输出丢给文件或终端
|
|
|
+
|
|
|
+不是每个场景都想抱着管道一点点读。输出太多、想留档、或者压根不在乎,这时候管道方案就显得多余了。好在`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));
|
|
|
+```
|
|
|
+
|
|
|
+这样省内存、方便事后查询,唯一要留意的是:重定向之后`getInputStream()`/`getErrorStream()`就取不到数据了。
|
|
|
+
|
|
|
+### Redirect枚举:三种默认态度
|
|
|
+
|
|
|
+除了指定文件,`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)`使用。
|
|
|
+
|
|
|
+***
|
|
|
+
|
|
|
+## 异步读写:别让主线程卡死
|
|
|
+
|
|
|
+前面我们在"数据交互"一节反复强调"输出交给后台线程",这里我们就正式系统地解决这个问题。
|
|
|
+
|
|
|
+**思考:** 现在设想这样的场景——你在主线程里先同步读了子进程的stdout(就像前面"坑一"那样),然后再调用`waitFor(5, TimeUnit.SECONDS)`想让它5秒超时。想一想,这个超时还有机会生效吗?为什么?
|
|
|
+
|
|
|
+### 使用多线程
|
|
|
+
|
|
|
+最直接的办法,就是一个流配一个后台线程,让主线程只管干正事:
|
|
|
+
|
|
|
+```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
|
|
|
+
|
|
|
+想顺手做点编排的话,`CompletableFuture`是更优雅的选择。JDK 9特意为`Process`加了`onExit()`方法,进程一退出Future自动完成,省去了自己再`waitFor`一遍的样板代码:
|
|
|
+
|
|
|
+```java
|
|
|
+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`用法里最省事、却也最容易省错的一步。
|
|
|
+
|
|
|
+***
|
|
|
+
|
|
|
+## 等待与退出码:等得完,跟等得起
|
|
|
+
|
|
|
+子进程可能很快就跑完,也可能一直赖着不走。所以我们通常不会傻等,而是要等得"有耐心"——给等待加个期限。
|
|
|
+
|
|
|
+### waitFor的两个版本
|
|
|
+
|
|
|
+`waitFor()`无参版本返回的是退出码,它会一直阻塞到子进程结束,没什么可说的。而带超时的版本,返回的是**boolean**,表示"在超时时间内退没退出",而不是退出码——这是最容易踩的一个坑。
|
|
|
+
|
|
|
+超时返回`false`时子进程还活着,此时如果手贱去调`exitValue()`,直接抛`IllegalThreadStateException`。正确做法是先`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的一点点区别
|
|
|
+
|
|
|
+最后给Windows提一句:`destroy()`走的是`TerminateProcess`,没有143这种信号退出码,行为跟Linux不一样,跨平台代码别把退出码写死。
|
|
|
+
|
|
|
+***
|
|
|
+
|
|
|
+## 综合实战:把子进程当一条管道
|
|
|
+
|
|
|
+前面我们讲解的,都是零散的知识点。攒到最后,需求往往长这样:Java程序调一个外部CLI,喂一批数据进去,把处理结果拿回来,还要确认工具到底成功没有。现在,我们就把前面这些散装知识,拼成一条完整的流水线。
|
|
|
+
|
|
|
+`ProcessPipelineExample`就是这条完整管道的实现:
|
|
|
+
|
|
|
+```java
|
|
|
+// 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("数据交互")、取退出码("等待与退出码")。单看每一步都不难,难点在于别把顺序搞乱——尤其是**关流发EOF,一定要放在取结果之前**。
|
|
|
+
|
|
|
+***
|
|
|
+
|
|
|
+## 进程管理:认识ProcessHandle(进阶)
|
|
|
+
|
|
|
+前面我们一直在用`Process`对象来操控子进程——读写三路流、等待退出、拿退出码,这些都是`ProcessBuilder`拉起来之后的事。但是你有没有想过这样一个问题:如果我只想**根据一个 PID 找到某个进程**,去查一查它跑了多久、占用多少 CPU,甚至把它结束掉,那我是不是非得先把它"启动"一遍才行?显然没必要。
|
|
|
+
|
|
|
+在JDK 9之前,这类需求几乎无从下手,Java提供的能力只有`Runtime.exec()`和`Process`这两个彼此绑定的对象。直到JDK 9,`ProcessHandle`出现了,它把"进程"本身变成了一种可以独立持有、查询、监控、销毁的**句柄**,终于让我们能够以"管理资源"的方式去管理进程了。
|
|
|
+
|
|
|
+> JDK 9 — 一次转向"I/O 与进程"的升级
|
|
|
+>
|
|
|
+> 如果说 JDK 5 给我们带来了`ProcessBuilder`,那么 JDK 9 就带来了`ProcessHandle`。从这一版起,Java 官方终于给出了一个不依赖`ProcessBuilder`也能操作"任意进程"的入口:查询 PID、查看命令行和启动时间、遍历整棵进程树、按句柄优雅地终止进程。这背后也反映了一个趋势——Java 正在把越来越多的 OS 原语,收纳进标准库的语言层。
|
|
|
+
|
|
|
+那么,从这一部分开始,就让我们来感受一下,ProcessHandle为我们带来了什么。
|
|
|
+
|
|
|
+### 从Process手里接过一句柄
|
|
|
+
|
|
|
+我们最熟悉的场景,还是刚刚通过`ProcessBuilder`启动的子进程。这种情况下,拿到`ProcessHandle`只需要一句`toHandle()`:
|
|
|
+
|
|
|
+```java
|
|
|
+Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
|
|
|
+ .directory(WORK_DIR)
|
|
|
+ .start();
|
|
|
+
|
|
|
+ProcessHandle handle = process.toHandle(); //Process -> 进程句柄
|
|
|
+```
|
|
|
+
|
|
|
+拿到句柄之后,第一件事自然是确认"它到底是不是同一个进程":
|
|
|
+
|
|
|
+```java
|
|
|
+log.info("process.pid() = {}", process.pid());
|
|
|
+log.info("handle.pid() = {}", handle.pid()); //两者应当完全一致
|
|
|
+log.info("handle.isAlive() = {}", handle.isAlive()); //子进程还在睡眠,应为 true
|
|
|
+```
|
|
|
+
|
|
|
+是的,从 JDK 9 起`Process`自己也带上了`pid()`方法,不再需要子进程打印你猜。接下来更有意思的是`info()`,它一次性把进程的"身世"给我们摊开来看:
|
|
|
+
|
|
|
+```java
|
|
|
+ProcessHandle.Info info = handle.info();
|
|
|
+info.command(); // 可执行文件路径
|
|
|
+info.commandLine(); // 完整的命令行
|
|
|
+info.startInstant(); // 启动时间
|
|
|
+info.totalCpuDuration(); // 累计 CPU 耗时
|
|
|
+info.user(); // 所属用户
|
|
|
+```
|
|
|
+
|
|
|
+**注意:** `info()`返回的所有字段都是`Optional`,因为进程退出之后,这些信息很可能就查不到了;别直接拿它当`String`用。
|
|
|
+
|
|
|
+### 没有Process,也能靠PID找回句柄
|
|
|
+
|
|
|
+这才是`ProcessHandle`真正拉开差距的地方。哪怕不是我们启动的进程,只要手上有一个 PID,就可以通过静态方法`of()`把它"找回来":
|
|
|
+
|
|
|
+```java
|
|
|
+Optional<ProcessHandle> child = ProcessHandle.of(pid); //返回 Optional
|
|
|
+child.isPresent(); //进程存在时为 true
|
|
|
+ProcessHandle.of(99999999L).isPresent(); //不存在的 PID 为 false
|
|
|
+```
|
|
|
+
|
|
|
+由于`of()`返回的是`Optional`,天然逼着我们先判空再使用,这一点和`info()`的思路是一致的——句柄指向的进程,随时可能已经消失。
|
|
|
+
|
|
|
+**思考:** 同样是拿到一个子进程的 PID,为什么`ProcessHandle.of(pid)`比传统的"在进程列表里翻找"要方便得多?结合上面`info()`的字段想一想:我们要做的,是不是只是把"查进程"从"捞数据"变成"拿句柄再查询"?
|
|
|
+
|
|
|
+### 探索进程树
|
|
|
+
|
|
|
+有了句柄,进程之间的关系也随之清晰起来。我们可以从当前 JVM 出发,顺着进程树上下浏览:
|
|
|
+
|
|
|
+```java
|
|
|
+ProcessHandle current = ProcessHandle.current(); //当前 JVM 的句柄
|
|
|
+current.pid(); // 当前 JVM 的 pid
|
|
|
+current.parent(); // Optional:当前 JVM 的父进程(通常是 shell)
|
|
|
+
|
|
|
+current.children().forEach(c -> log.info("子进程 pid: {}", c.pid())); //直接子进程
|
|
|
+current.descendants().count(); // 所有后代(含孙子进程)
|
|
|
+```
|
|
|
+
|
|
|
+我们的示例里,主进程只启动了一个睡眠子进程,所以`children()`里正好就是它,而它的`parent()`又能回溯回当前 JVM:
|
|
|
+
|
|
|
+```java
|
|
|
+ProcessHandle.of(childPid).ifPresent(child -> {
|
|
|
+ Optional<ProcessHandle> parent = child.parent();
|
|
|
+ log.info("parent pid = {}", parent.get().pid()); //与 current.pid() 一致
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+如果想把整台机器上"我可见"的进程都列出来,还有`allProcesses()`:
|
|
|
+
|
|
|
+```java
|
|
|
+long total = ProcessHandle.allProcesses().count();
|
|
|
+log.info("当前用户可见进程总数: {}", total);
|
|
|
+```
|
|
|
+
|
|
|
+**注意:** `allProcesses()`是个**静态方法**,必须写成`ProcessHandle.allProcesses()`,不能用`handle.allProcesses()`——否则编译直接报错(静态接口方法不能通过实例调用),我们就在这栽过跟头。另外它可能受系统权限限制,Linux 非 root 用户是看不到其他用户进程的。
|
|
|
+
|
|
|
+### 用句柄来"收拾"进程
|
|
|
+
|
|
|
+最后,也是最解气的能力:通过句柄直接销毁进程。`ProcessHandle`上也有`destroy()`和`destroyForcibly()`,语义和`Process`上的一模一样——Linux 下前者发 SIGTERM,后者发 SIGKILL:
|
|
|
+
|
|
|
+```java
|
|
|
+Process process = startChild(); //启动一个睡眠 30 秒的子进程
|
|
|
+ProcessHandle handle = process.toHandle();
|
|
|
+
|
|
|
+boolean initiated = handle.destroy(); //Linux 发 SIGTERM(优雅终止)
|
|
|
+handle.onExit().join(); //阻塞到进程真正退出
|
|
|
+log.info("进程已退出,isAlive() = {}", handle.isAlive()); // false
|
|
|
+log.info("退出码: {}", process.exitValue()); // 被 SIGTERM 终止常见 143
|
|
|
+```
|
|
|
+
|
|
|
+可以看到,`onExit()`在句柄上同样存在,返回`CompletableFuture<ProcessHandle>`,配合`join()`就能稳稳地等到进程落幕。
|
|
|
+
|
|
|
+**注意:** `ProcessHandle.onExit()`返回的是`CompletableFuture<ProcessHandle>`,而`Process.onExit()`返回的是`CompletableFuture<Process>`,两者返回类型不同,别混用。Windows 上`supportsNormalTermination()`还会返回`false`,`destroy()`走的是`TerminateProcess`,也就没有 143 这种信号退出码了。
|
|
|
+
|
|
|
+至此,从"启动进程"到"管理进程",这一整条链路我们就都走通了:`ProcessBuilder`负责拉起,`Process`负责交互,`ProcessHandle`负责以句柄的视角去查询与监控。你可以发现,它解决的核心问题,正是我们文章开头那句"只凭一个 PID 管理外部进程"。
|
|
|
+
|
|
|
+***
|
|
|
+
|
|
|
+## 附录:踩坑速查
|
|
|
+
|
|
|
+最后,我们把全篇踩过的坑汇总在一起,方便日后查阅:
|
|
|
+
|
|
|
+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()` 是写给子进程的,不是读它的。方向先对一遍。
|
|
|
+9. `ProcessHandle.allProcesses()` 是静态方法 → 用 `ProcessHandle.allProcesses()`,别写成 `handle.allProcesses()`。
|
|
|
+10. Windows 下 `supportsNormalTermination()` 为 `false`,`destroy()` 走 `TerminateProcess`,无 143 信号退出码。
|
|
|
+
|
|
|
+关于子进程调用,能讲的还有很多。相信掌握了上面的内容,各位已经能够在自己的项目里自如地调度外部进程了。如果再往后看,还有`net`模块的远程进程部署、`Future`异步编排等话题等着我们——但那就是另一个故事了。
|