# JAVA Process 使用教程 本教程通过 6 个可运行的示例,讲解 `ProcessBuilder` / `Process` 的核心用法。所有示例都以 `src/main/resources/code/` 下的 `.java` 程序作为子进程,先编译再运行。 每个**案例点**由独立的 `static` method 演示,在 `main()` 中依次调用。 ## 运行示例 ```bash mvn -q compile java -cp "target/classes:$(mvn -q dependency:build-classpath -Dmdep.outputFile=/dev/stdout | tail -1)" space.anyi.process.<类名> ``` 各示例声明了 `static Logger`(slf4j),手工执行时 classpath 必须带上 logback/slf4j 依赖, 否则报 `NoClassDefFoundError: org/slf4j/LoggerFactory`。 示例的行为由 JUnit 5 测试守护:`mvn test` 直接驱动夹具子进程做端到端校验。 --- ## 零、概念铺垫(30 秒建立心智模型) - **进程(Process)**:操作系统里一个正在运行的程序实例。Java 代码可以启动一个外部程序(如 `java`、 `javac`,或任意可执行文件)作为**子进程**,用一个逻辑句柄来操控它。 - **ProcessBuilder**:**"如何启动"** 的配置器。它只管描述命令、工作目录、环境变量、三路标准流走哪个 源头;配置完成后调用 `start()` 才真正启动子进程,并返回一个 `Process` 句柄。 - **Process**:**"已启动的子进程"** 的句柄。用它可以等待结束、读退出码、write/read 三路标准流、销毁进程。 - 主进程与子进程通过**三路管道**交互,方向最容易搞反,记住这句: ``` 主进程 子进程 getOutputStream() ────────────▶ 标准输入 stdin getInputStream() ◀─────────── 标准输出 stdout getErrorStream() ◀─────────── 标准错误 stderr ``` > 子进程程序固定放在 `src/main/resources/code/<主题>/Main.java`,示例先编译再运行, > 公共类 `Jdk`(`javac()`/`java()` 绝对路径、`compileFixture()`)负责这两件事。 --- ## 一、通过 ProcessBuilder 构建 Process —— `QuickStart` 案例点:`buildProcess()`(示例类:`space.anyi.process.QuickStart`) ```java // 1. 构建命令并启动子进程:可执行文件绝对路径 + Main 类名,directory() 指定工作目录 Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main") .directory(workDir) .start(); // 2. 读取子进程的标准输出 new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) .lines() .forEach(line -> log.info("子进程标准输出: {}", line)); // 3. 等待子进程结束,返回退出状态码 int exitStatus = process.waitFor(); ``` 要点: - 命令以**字符串列表**传给构造器,`start()` 在子进程中执行该命令;可执行文件用 `System.getProperty("java.home")` 拼出的**绝对路径**(子进程的 PATH 常常没有 JDK 的 bin 目录,裸写 `javac`/`java` 会报 `error=2`)。 - 必须先编译子进程程序:公共类 `Jdk.compileFixture(workDir)` 会用绝对路径的 `javac` 编译工作目录下的 `Main.java`。 预期输出: ``` 子进程标准输出: Hello from child process! 子进程标准输出: child pid = 12345 子进程退出状态码: 0 ``` > **警告:ProcessBuilder 不经过 shell。** 列表里每一项就是子进程收到的**一个参数**: > - 不要整个命令写成一个带空格的字符串,如 `"java -version"`(会被当作一个叫 `java -version` 的可执行文件); > - 不要期待引号展开、`$VAR`、通配符、`|` 管道、重定向等 shell 语法生效; > - 确实需要 shell 能力时,显式走 `sh`/`cmd`:`List.of("sh", "-c", "echo $HOME | wc -c")`。 > > 平台差异:Windows 下 `javac`/`java` 的文件名带 `.exe`,目录分隔符是 `\`, > `process.destroy()` 走 `TerminateProcess`(不产生 143 这种退出码)。 --- ## 二、获取 Process 的输入和输出 —— `ProcessIOExample` 案例点:`standardOutput()` / `standardError()` / `standardInput()`(示例类:`space.anyi.process.ProcessIOExample`) 子进程(`code/io/Main.java`)启动时打印一行 stdout,从标准输入逐行读取并回显,读到 EOF 后向标准错误打印统计信息。三个案例点: - `standardOutput()`:启动子进程、后台读 `getInputStream()`,看到启动横幅即标准输出 ✓ - `standardError()`:关闭 stdin 后读 `getErrorStream()`,看到 EOF 统计行即错误输出 ✓ - `standardInput()`:向 `getOutputStream()` 写两行,子进程回显到 stdout ✓ ```java // 标准输出、标准错误分别在后台线程读取,避免阻塞主线程、也避免管道写满导致子进程卡死 Thread stdoutDrain = drain("stdout", process.getInputStream()); Thread stderrDrain = drain("stderr", process.getErrorStream()); // 向子进程标准输入写入数据;关闭输入流即表示 EOF try (OutputStream stdin = process.getOutputStream()) { stdin.write("第一行输入\r\n".getBytes(StandardCharsets.UTF_8)); stdin.write("第二行输入\r\n".getBytes(StandardCharsets.UTF_8)); } ``` 对应关系: | 子进程侧 | 主进程侧 API | 说明 | |---------------------|---------------------------------------|------| | 标准输入 (stdin) | `process.getOutputStream()` | 主进程向该流写入,即子进程的输入 | | 标准输出 (stdout) | `process.getInputStream()` | 子进程 `println` 的内容 | | 标准错误 (stderr) | `process.getErrorStream()` | 子进程 `err.println` 的内容 | 要点: - **标准输出/错误要用后台线程读取**。`BufferedReader(...).lines().forEach(...)` 是同步阻塞的, 会一直读到 EOF 为止,而 EOF 只有在子进程**退出**时才出现——在限时 `waitFor` 之前这样读, 会把主线程钉住整个子进程生命周期,静默破坏超时语义(本仓库的踩坑点)。 - 若不读子进程输出且输出量超过管道缓冲区(Linux 约 64KB),子进程会因管道写满而阻塞、永不退出。 后台异步读取 + 超时后 `destroy()` 的设计可以兜住这种情况。 预期输出(线程日志与主线程日志顺序可能交错): ``` [stdout] child: started, waiting for stdin [stdout] child: echo 第一行输入 [stdout] child: echo 第二行输入 [stderr] child: stdin EOF after 2 line(s) 子进程是否已退出: true 退出状态码: 0 ``` --- ## 二-1、标准输出与错误输出的重定向 —— `ProcessRedirectExample`(进阶) > **进阶章节**:掌握前几节后再看。重定向用于"子进程输出不进管道"的常见场景。 案例点:`errorStreamMerged()` / `redirectToFile()` / `redirectEnum()`(示例类:`space.anyi.process.ProcessRedirectExample`) 子进程复用 `code/io/Main.java`。三个案例点: ### 相互重定向 —— `errorStreamMerged()` ```java // stderr 合并写入 stdout 管道,合并后的整体从 getInputStream() 读取,不再有独立 stderr 流 Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main") .directory(WORK_DIR) .redirectErrorStream(true) .start(); ``` 演示结果里 `child: started...`(stdout)与 `child: stdin EOF...`(stderr)都出现在同一路 `[merged]` 日志中: ``` [merged] child: started, waiting for stdin [merged] child: stdin EOF after 0 line(s) 退出码: 0 ``` ### 重定向到文件 —— `redirectToFile()` ```java .redirectInput(input.toFile()) // stdin 从文件读 .redirectOutput(stdout.toFile()) // stdout 写到文件 .redirectError(stderr.toFile()) // stderr 写到文件 ``` 子进程结束后读回文件校验: ``` 退出码: 0 stdout 文件内容: [child: started, waiting for stdin, child: echo 来自文件的输入行, child: echo 第二行来自文件] stderr 文件内容: [child: stdin EOF after 2 line(s)] ``` 可用于把子进程输出落盘、日志归档等场景。 ### Redirect 枚举 —— `redirectEnum()` | 枚举值 | 含义 | 演示 | |---|---|---| | `ProcessBuilder.Redirect.PIPE` | 默认值,子进程输出进入管道,父进程用 `getInputStream()` 读取 | 逐行读回子进程输出 | | `ProcessBuilder.Redirect.INHERIT` | 子进程 stdout/stderr 直接打印到父进程的控制台,不进管道 | 两行内容直接出现在父控制台 | | `ProcessBuilder.Redirect.DISCARD` | 子进程输出被直接丢弃 | 进程正常退出但读不到任何输出 | > `Redirect` 还提供 `to(File)`(输出重定向到文件,覆盖)与 `appendTo(File)`(追加)两个工厂方法, > 可用于 `redirectInput/Output/Error(Redirect)`。 --- ## 二-2、异步处理输入和输出 —— `ProcessAsyncExample`(进阶) > **进阶章节**:需要掌握 JDK 8+ 的 `CompletableFuture` 再阅读。 案例点:`asyncByThread()` / `asyncByCompletableFuture()`(示例类:`space.anyi.process.ProcessAsyncExample`) ### 多线程 —— `asyncByThread()` ```java // 每个流一个独立线程读取,主线程不阻塞在读取上,也避免管道写满导致子进程卡死 Thread stdoutDrain = drain("thread-stdout", process.getInputStream()); Thread stderrDrain = drain("thread-stderr", process.getErrorStream()); ... stdoutDrain.join(); // 读到 EOF(子进程退出)后汇合 stderrDrain.join(); ``` ### CompletableFuture —— `asyncByCompletableFuture()` ```java // stdout / stderr 读取各封装成一个异步任务 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(() -> ...); // 进程结束时 onExit() 自动完成该 Future,注册回调即可在退出时拿到退出码 CompletableFuture exitFuture = process.onExit() .thenAccept(p -> log.info("onExit 回调: 退出码 {}", p.exitValue())); // 等待所有异步任务结束 CompletableFuture.allOf(stdoutFuture, stderrFuture, exitFuture).join(); ``` 要点:`process.onExit()` 返回 `CompletableFuture`,不依赖 `destroy()`/阻塞读取, 适合与函数式回调、并发编排(`allOf`/`thenAccept`)结合。 预期输出: ``` [thread-stdout] child: started, waiting for stdin [thread-stdout] child: echo hello thread [thread-stderr] child: stdin EOF after 1 line(s) 退出码: 0 == CompletableFuture 异步读取 == [cf-stdout] child: started, waiting for stdin [cf-stdout] child: echo hello future [cf-stderr] child: stdin EOF after 1 line(s) onExit 回调: 退出码 0 ``` --- ## 三、获取 Process 执行的状态码 —— `ExecuteStatusExample` 案例点:`blockingWaitFor()` / `timedWaitFor()`(示例类:`space.anyi.process.ExecuteStatusExample`) 子进程(`code/executeStatus/Main.java`)睡眠 10 秒后退出,用来对比两种获取退出码的方式: ```java // 方式一:阻塞式,一直等到子进程结束,返回退出码(0 正常,非 0 异常) int exitValue = process.waitFor(); // 方式二:限时等待,返回 boolean(超时时间内是否退出),不是退出码 boolean flag = process.waitFor(2, TimeUnit.SECONDS); if (!flag) { process.destroy(); // 超时后子进程仍存活,先发终止信号 process.waitFor(); // 再阻塞等待它真正结束 } int exitCode = process.exitValue(); // 被信号终止时通常为 143(SIGTERM) ``` 要点: - `waitFor()` 无参:返回 `int` 退出码。 - `waitFor(long timeout, TimeUnit unit)`:返回 `boolean`。超时返回 `false` 时子进程**仍然存活**, 此时调用 `process.exitValue()` 会抛 `IllegalThreadStateException`;必须先 `destroy()` + 阻塞 `waitFor()` 才能读取退出码。 - `process.destroy()` 在 Linux 上发送 SIGTERM,属于优雅终止;可用 `destroyForcibly()` 直接 SIGKILL。 预期输出: ``` == 方式一:waitFor() 阻塞等待 == 阻塞等待退出码: 0 # 大约 10 秒后出现(子进程睡眠 10 秒) == 方式二:waitFor(2, SECONDS) 限时等待 == 2 秒内是否退出: false 超时未退出,destroy 销毁子进程后阻塞等待 销毁后退出码: 143 # SIGTERM 信号终止的退出码 ``` --- ## 四、Process 的核心对象及专属 API 详解 ### ProcessBuilder 作用:**描述“如何启动一个进程”**,配置完成后调用 `start()` 生成 `Process`。 | 方法 | 参数 | 返回 | 说明 | |---|---|---|---| | `ProcessBuilder(List command)` | 命令及参数的字符串列表 | — | 构建器 | | `ProcessBuilder(List)` / `List command()` 变体 | 命令列表/单个命令+参数数组 | 构建器自身 | 设置或读取命令 | | `directory(File dir)` | 子进程工作目录 | 构建器自身 | 不设置则继承父进程目录 | | `environment()` | 无 | `Map` | 返回可变的子进程环境变量视图(增删改影响子进程) | | `redirectInput/Output/Error(File)` | 文件 | 构建器自身 | 把子进程标准流重定向到文件 | | `redirectInput/Output/Error(Redirect)` | `Redirect` 枚举 | 构建器自身 | `PIPE`/`INHERIT`/`DISCARD`/`to(file)`/`appendTo(file)` | | `redirectErrorStream(boolean)` | 是否合并 stderr 到 stdout | 构建器自身 | `true` 时子进程 stderr 写入 stdout 管道 | | `start()` | 无 | `Process` | 启动子进程;目录不存在/命令找不到抛 `IOException` | > 环境变量示例见 `ProcessBuilderExample`:`processBuilder.environment()` 与 `System.getenv()` > 内容一致,向返回的 Map 里 `put` 即可为子进程注入环境变量。 ### Process 作用:**表示一个已启动的子进程**,负责等待结束、读取退出码、与进程交互、销毁进程。 | 方法 | 参数 | 返回 | 说明 | |---|---|---|---| | `getOutputStream()` | 无 | `OutputStream` | 子进程的**标准输入** | | `getInputStream()` | 无 | `InputStream` | 子进程的**标准输出** | | `getErrorStream()` | 无 | `InputStream` | 子进程的**标准错误** | | `waitFor()` | 无 | `int` | 阻塞直到子进程结束,返回退出码 | | `waitFor(long, TimeUnit)` | 等待时长、单位 | `boolean` | 限时等待;超时未退出返回 `false` | | `exitValue()` | 无 | `int` | 退出码;进程未结束调用抛 `IllegalThreadStateException` | | `destroy()` | 无 | 无 | 终止进程(Linux 为 SIGTERM,优雅) | | `destroyForcibly()` | 无 | `Process` | 强制终止(Linux 为 SIGKILL) | | `isAlive()` | 无 | `boolean` | 进程是否仍在运行 | | `onExit()` | 无 | `CompletableFuture` | 进程结束时自动完成 | --- ## 五、综合运用:管道式调用外部工具 —— `ProcessPipelineExample` 案例点:`runPipeline()`(示例类:`space.anyi.process.ProcessPipelineExample`) 把子进程当作一条**处理管道**,完整走一遍真实调用的流程:启动 → 异步采集输出 → 喂入数据 → 取回结果 → 校验。 ```java // 1. 启动子进程(外部工具) Process process = new ProcessBuilder(Jdk.java(), "-Dfile.encoding=UTF-8", "Main") .directory(WORK_DIR) .start(); // 2. 异步把 stdout 收集成 List、异步消费 stderr CompletableFuture> resultFuture = CompletableFuture.supplyAsync(() -> new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) .lines().collect(Collectors.toList())); CompletableFuture stderrDrain = CompletableFuture.runAsync(() -> ...); // 3. 向子进程 stdin 喂入数据,关闭输入流表示 EOF try (OutputStream stdin = process.getOutputStream()) { for (int i = 1; i <= 3; i++) writeLine(stdin, "待处理数据-" + i); } // 4. 等待退出码,取回处理结果并校验行数 int exit = process.waitFor(); List result = resultFuture.get(); long echoed = result.stream().filter(line -> line.startsWith("child: echo")).count(); ``` 预期输出: ``` [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 ``` 要点:这一段把前面四章的知识串了起来——`ProcessBuilder` 构建(一)、三路管道(二)、 异步读取 stdout/stderr(二-2)、`waitFor()` 拿退出码(三)。实际项目中把第 2 步的"异步收集 stdout"换成业务处理(如逐行解析、透传到日志),子进程就成了你程序里的一级生产/消费管道。 --- ## 常见踩坑小结 1. 编译/运行子进程的 `javac`、`java` 用 `java.home` 的**绝对路径**,不要裸写命令名。 2. `user.dir` 相对的工作目录要跟 `src/main/resources/code/` 下的夹具目录保持一致,否则 `error=2`。 3. 同步 `lines().forEach(...)` 读子进程输出会阻塞到子进程退出,限时 `waitFor` 前要在后台线程读取。 4. `waitFor(timeout, unit)` 返回 boolean;超时后取退出码要 `destroy() + waitFor()` 再 `exitValue()`。 5. `ProcessBuilder` 不经 shell:参数数组每一项就是一个参数,引号/`$VAR`/通配符/`|` 都不生效,需要时用 `sh -c "..."`。