blog2.md 28 KB

使用 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对象:

ProcessBuilder builder = new ProcessBuilder("java", "Main");  //只描述"怎么启动"
Process process = builder.start();   //真正拉起子进程,返回值用于后续的一切操作

子进程启动之后,读写它的三路标准流、等待它结束、把它销毁,这些操作都发生在Process对象上。简单来说,ProcessBuilder管"怎么启动",Process管"启动之后干什么"。既然搞清楚了分工,我们接着来看看,Process类上都提供了哪些方法供我们调遣:

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 可执行文件 + 类名,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"整个当作一个可执行文件名去找,自然是找不到的。

正确的做法,是把命令拆成一个参数列表:

new ProcessBuilder("java", "-version");   //而不是 "java -version"

子进程的PATH里没有JDK

还有一个非常隐蔽的坑:子进程的环境变量往往是继承自父进程的,而我们的PATH里经常缺了JDK的bin目录,这时候裸写javac/java命令名,启动就报error=2。所以,可执行文件要用java.home拼出绝对路径:

// 工具方法,返回 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

List.of("sh", "-c", "echo $HOME | wc -c")

与子进程交换数据

跑通了第一个子进程,接下来我们就要真正和它对话了:往它的标准输入里喂数据,从标准输出读回结果,还要顺便收下标准错误。我们的子进程程序code/io/Main.java干的事正好对应三个点:启动时打一行欢迎语,逐行回显stdin,读到EOF往stderr打统计。

读回标准输出

想要把子进程System.out.println的内容拿回来,就用前面那张图的对应关系,取getInputStream()

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()

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()

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

如果只想养一个线程、读一路,可以把标准错误合并进标准输出:

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));

这样省内存、方便事后查询,唯一要留意的是:重定向之后getInputStream()/getErrorStream()就取不到数据了。

Redirect枚举:三种默认态度

除了指定文件,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)使用。


异步读写:别让主线程卡死

前面我们在"数据交互"一节反复强调"输出交给后台线程",这里我们就正式系统地解决这个问题。

思考: 现在设想这样的场景——你在主线程里先同步读了子进程的stdout(就像前面"坑一"那样),然后再调用waitFor(5, TimeUnit.SECONDS)想让它5秒超时。想一想,这个超时还有机会生效吗?为什么?

使用多线程

最直接的办法,就是一个流配一个后台线程,让主线程只管干正事:

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

想顺手做点编排的话,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用法里最省事、却也最容易省错的一步。


等待与退出码:等得完,跟等得起

子进程可能很快就跑完,也可能一直赖着不走。所以我们通常不会傻等,而是要等得"有耐心"——给等待加个期限。

waitFor的两个版本

waitFor()无参版本返回的是退出码,它会一直阻塞到子进程结束,没什么可说的。而带超时的版本,返回的是boolean,表示"在超时时间内退没退出",而不是退出码——这是最容易踩的一个坑。

超时返回false时子进程还活着,此时如果手贱去调exitValue(),直接抛IllegalThreadStateException。正确做法是先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的一点点区别

最后给Windows提一句:destroy()走的是TerminateProcess,没有143这种信号退出码,行为跟Linux不一样,跨平台代码别把退出码写死。


综合实战:把子进程当一条管道

前面我们讲解的,都是零散的知识点。攒到最后,需求往往长这样: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("数据交互")、取退出码("等待与退出码")。单看每一步都不难,难点在于别把顺序搞乱——尤其是关流发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()

Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main")
        .directory(WORK_DIR)
        .start();

ProcessHandle handle = process.toHandle();   //Process -> 进程句柄

拿到句柄之后,第一件事自然是确认"它到底是不是同一个进程":

log.info("process.pid() = {}", process.pid());
log.info("handle.pid() = {}", handle.pid());   //两者应当完全一致
log.info("handle.isAlive() = {}", handle.isAlive());   //子进程还在睡眠,应为 true

是的,从 JDK 9 起Process自己也带上了pid()方法,不再需要子进程打印你猜。接下来更有意思的是info(),它一次性把进程的"身世"给我们摊开来看:

ProcessHandle.Info info = handle.info();
info.command();             // 可执行文件路径
info.commandLine();         // 完整的命令行
info.startInstant();        // 启动时间
info.totalCpuDuration();    // 累计 CPU 耗时
info.user();                // 所属用户

注意: info()返回的所有字段都是Optional,因为进程退出之后,这些信息很可能就查不到了;别直接拿它当String用。

没有Process,也能靠PID找回句柄

这才是ProcessHandle真正拉开差距的地方。哪怕不是我们启动的进程,只要手上有一个 PID,就可以通过静态方法of()把它"找回来":

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 出发,顺着进程树上下浏览:

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:

ProcessHandle.of(childPid).ifPresent(child -> {
    Optional<ProcessHandle> parent = child.parent();
    log.info("parent pid = {}", parent.get().pid());   //与 current.pid() 一致
});

如果想把整台机器上"我可见"的进程都列出来,还有allProcesses()

long total = ProcessHandle.allProcesses().count();
log.info("当前用户可见进程总数: {}", total);

注意: allProcesses()是个静态方法,必须写成ProcessHandle.allProcesses(),不能用handle.allProcesses()——否则编译直接报错(静态接口方法不能通过实例调用),我们就在这栽过跟头。另外它可能受系统权限限制,Linux 非 root 用户是看不到其他用户进程的。

用句柄来"收拾"进程

最后,也是最解气的能力:通过句柄直接销毁进程。ProcessHandle上也有destroy()destroyForcibly(),语义和Process上的一模一样——Linux 下前者发 SIGTERM,后者发 SIGKILL:

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()还会返回falsedestroy()走的是TerminateProcess,也就没有 143 这种信号退出码了。

至此,从"启动进程"到"管理进程",这一整条链路我们就都走通了:ProcessBuilder负责拉起,Process负责交互,ProcessHandle负责以句柄的视角去查询与监控。你可以发现,它解决的核心问题,正是我们文章开头那句"只凭一个 PID 管理外部进程"。


附录:踩坑速查

最后,我们把全篇踩过的坑汇总在一起,方便日后查阅:

  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() 是写给子进程的,不是读它的。方向先对一遍。
  9. ProcessHandle.allProcesses() 是静态方法 → 用 ProcessHandle.allProcesses(),别写成 handle.allProcesses()
  10. Windows 下 supportsNormalTermination()falsedestroy()TerminateProcess,无 143 信号退出码。

关于子进程调用,能讲的还有很多。相信掌握了上面的内容,各位已经能够在自己的项目里自如地调度外部进程了。如果再往后看,还有net模块的远程进程部署、Future异步编排等话题等着我们——但那就是另一个故事了。