Переглянути джерело

blog: 补充 ProcessHandle 章节(模板风格,接综合实战与附录之间)

yangyi 1 тиждень тому
батько
коміт
44935fa047
1 змінених файлів з 548 додано та 0 видалено
  1. 548 0
      blog2.md

+ 548 - 0
blog2.md

@@ -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`异步编排等话题等着我们——但那就是另一个故事了。