在前面,我们了解了使用Runtime.exec()来启动外部程序的基本操作,虽然子进程能够被顺利拉起来,但是我们很快就会感觉到,这种方式过于粗糙:想要调整子进程的工作目录、设定环境变量、控制标准流的去向,都显得心有余而力不足。也正是因为这种种不方便,从JDK 5开始,java.lang.ProcessBuilder被带到了我们的面前,相比传统的Runtime.exec(),我们对子进程的控制,有了更多的选择。
JDK 5 — 悄悄改变Java的一次升级
2004年推出的Tiger(即JDK 5),大概是大伙最熟悉的一个版本了:泛型、自动装箱、枚举、可变参数、注解……一大堆现已成为Java语法基座的新特性,都是从这一次升级开始的。而在这一大票耀眼的新特性当中,有一个不起眼的小类
java.lang.ProcessBuilder,它正是我们今天的主角。可以说,Java对外部进程的精细化控制,正是从这次升级正式拉开序幕的。
那么,从本部分开始,就让我们来感受一下,ProcessBuilder为我们带来了什么。
在使用一个陌生的类之前,我们首先应该弄清楚:它到底帮我们解决了什么问题,以及它是如何融入我们现有的代码的。这里的核心类有两个,我们分别来看。
从名字上就可以看出来,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的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统一处理了这件事。
引号展开、$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给了我们几种"脱手"的方式。
如果只想养一个线程、读一路,可以把标准错误合并进标准输出:
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枚举还提供了三种最常用的"默认态度":
| 枚举值 | 行为 | 什么时候用 |
|---|---|---|
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是更优雅的选择。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()无参版本返回的是退出码,它会一直阻塞到子进程结束,没什么可说的。而带超时的版本,返回的是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提一句: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,一定要放在取结果之前。
前面我们一直在用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为我们带来了什么。
我们最熟悉的场景,还是刚刚通过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用。
这才是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()还会返回false,destroy()走的是TerminateProcess,也就没有 143 这种信号退出码了。
至此,从"启动进程"到"管理进程",这一整条链路我们就都走通了:ProcessBuilder负责拉起,Process负责交互,ProcessHandle负责以句柄的视角去查询与监控。你可以发现,它解决的核心问题,正是我们文章开头那句"只凭一个 PID 管理外部进程"。
最后,我们把全篇踩过的坑汇总在一起,方便日后查阅:
javac/java 报 error=2 → 用 java.home 拼绝对路径。error=2 → 查 directory() 的工作目录。waitFor(timeout) 超时、exitValue() 抛异常 → 先 destroy() + waitFor()。"java -version" 报找不到文件 → 命令拆成 List.of("java", "-version")。$VAR、通配符、管道这类 shell 功能 → 包一层 sh -c "..."。getOutputStream() 是写给子进程的,不是读它的。方向先对一遍。ProcessHandle.allProcesses() 是静态方法 → 用 ProcessHandle.allProcesses(),别写成 handle.allProcesses()。supportsNormalTermination() 为 false,destroy() 走 TerminateProcess,无 143 信号退出码。关于子进程调用,能讲的还有很多。相信掌握了上面的内容,各位已经能够在自己的项目里自如地调度外部进程了。如果再往后看,还有net模块的远程进程部署、Future异步编排等话题等着我们——但那就是另一个故事了。