🎬 第十二章 各种开发语言调用FFmpeg命令行
📄 一句话概括

Java 里有两个入口:Runtime.exec 与 ProcessBuilder。 前者会把字符串按空格切开,引号完全失效;所以只用后者。

📝 本节和前面几节的关系

C++ 那五节讲的三件事(传参、等结束、收输出),Java 里一件没少。 好处是标准库把管道包好了,坏处是它包得不够彻底——流还是要你自己读。

一、两个入口,只该用后面那个

Runtime.exec 有两个重载:一个收字符串,一个收字符串数组。 收字符串那个会用 StringTokenizer 按空白切开, 它不认识引号——"a b.mp4" 会被切成两段。

ProcessBuilder 只收 List<String>,从接口上就杜绝了这个误会。 附带的好处是能设工作目录、能改环境变量、能合并两路输出。 所以结论很干脆:新代码一律用 ProcessBuilder。

老代码里如果见到 Runtime.exec("ffmpeg -i ..."), 而路径里恰好没有空格,它就跑得好好的—— 这种「碰巧能跑」的代码最危险:换个目录名就崩, 而崩溃现象是「找不到文件」,跟引号八竿子打不着。

二、三处必须自己动手的地方

标准库把「起进程」做完了,剩下三件事还是你的:

要做的事 不做会怎样


读完 stdout 与 stderr 缓冲满,子进程阻塞 给 waitFor 加超时 无限等,线程被占死 等结束再取 exitValue 抛 IllegalThreadStateException

第一条是最容易漏的:Process 的流必须读到 EOF。 只 waitFor() 不读,缓冲区一满子进程就写不动, 两边互等——跟 Qt、跟 C++ 是同一个死锁。 本机实测那个临界点还是 4 KB 到 4.5 KB 之间,跟语言无关。

三、redirectErrorStream 是最省事的一个开关

pb.redirectErrorStream(true) 把 stderr 并进 stdout。 之后只读一个流就够了,死锁风险直接消失一半。

这个开关在三个语言里各有一个名字:Java 叫 redirectErrorStream, Qt 叫 MergedChannels,Python 叫 stderr=STDOUT。 它们做的事一模一样——把两根管子拧成一根。 代价也一样:两路的先后顺序不再可靠。

如果确实需要分开,那就要么开两个线程各读一路, 要么用 ProcessBuilder.Redirect 把某一路重定向到文件。 两者都比「先等后读」安全。

四、超时与强制结束

p.waitFor(10, TimeUnit.SECONDS) 返回布尔值: true 表示在超时前结束了,false 表示还没结束。 返回 false 之后必须自己处理——destroy() 是优雅终止,destroyForcibly() 是强杀。

顺序跟 Qt 那节一样:先 destroy(),等一会儿,还在就 destroyForcibly()。 ffmpeg 被强杀时 mp4 的尾写不完,文件是坏的; 优雅终止它会尽量写完索引再退出。

五、一份能用的 Java 骨架

把上面几件事拼起来:

List<String> cmd = Arrays.asList("ffmpeg", "-y", "-nostdin", "-i", in);
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);        // 两路合一
Process p = pb.start();
StringBuilder log = new StringBuilder();
try (BufferedReader r = new BufferedReader(
         new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
    String line;
    while ((line = r.readLine()) != null) log.append(line).append('\n');
}                                    // try-with-resources 保证读到底
if (!p.waitFor(30, TimeUnit.MINUTES)) {
    p.destroy();
    if (!p.waitFor(5, TimeUnit.SECONDS)) p.destroyForcibly();
    throw new RuntimeException("ffmpeg 超时");
}
if (p.exitValue() != 0)
    throw new RuntimeException("ffmpeg 失败: " + tail(log.toString(), 800));

三处细节:-nostdin 写在参数里(防它去读标准输入)、 StandardCharsets.UTF_8 显式指定编码(不然中文日志会乱)、 try-with-resources 保证流读到 EOF(这是防死锁的关键结构)。

六、动手 Demo:三个坑,逐个复现

Java 本机没装 JDK,所以下面用 Python 复现同一层的三个现象。 它们跟语言无关:切参数、管道满、流不读,都是操作系统那一层的事。

import subprocess, sys

PY = sys.executable
args = ["a b.mp4", "-c:v", "libx264"]
print("数组传:", len(args), "个")
print("按空格切:", len(" ".join(args).split()), "个")

for n in (4096, 4608):
    p = subprocess.Popen(
        [PY, "-c", "import sys; sys.stdout.write('x'*%d)" % n],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    try:
        p.wait(timeout=1.5)
        print("%d 字节: 出来了" % n)
    except subprocess.TimeoutExpired:
        print("%d 字节: 卡住" % n)
        p.kill(); p.wait()

实跑输出:

--------------------------------------------------------------------
12.2 Java 调用 FFmpeg 命令行
--------------------------------------------------------------------
**① `Runtime.exec(String)` 会按空格切,引号不算数**

**Java 里 `Runtime.exec("ffmpeg -i a b.mp4")` 不会把整串交给系统,**
**它自己先用 `StringTokenizer` 切开——所以带空格的参数在 Java 里就散了。**
**下面的实验用「按空格切」复现它的行为,再跟「按数组传」对照:**

  怎么传                              切出几个参数
  ------------------------------------------------------
  按数组传(ProcessBuilder 的写法)   3 个
  按空格切(Runtime.exec 的做法)     4 个

**3 个变 4 个,`a b.mp4` 被劈成两半——这就是 Java 里那句著名的**
**「`Runtime.exec` 不支持引号」的实测版。**
**结论很干脆:一律用 `new ProcessBuilder(List<String>)`,别用字符串重载。**

**② 起了进程不读流,`waitFor()` 会把你吊死**

  子进程往 stdout 写    只调 waitFor 的结果
  ----------------------------------------------------
  4096 字节             正常返回
  4608 字节             卡住
  65536 字节            卡住

**跟 Qt 那节测出来的是同一个数:4 KB 到 4.5 KB 之间就满了。**
**因为卡人的不是 Qt、也不是 Java,是操作系统的管道。**
**语言换了三层,卡的位置一个字节都没变——这也是这一章最想让你看见的事:**
**坑在系统那一层,不在语法那一层。**

**③ 合并两路是最省事的解法**

  怎么收                收到的顺序
  ----------------------------------------------------------------
  两路分开收            stdout=O0O1O2  stderr=E0E1E2
  合并成一路收          E0O0E1O1E2O2

**合并之后时间是连续的,两路分开收则是「先 E0E1E2 再 O0O1O2」——**
**因为你读完一路才去读另一路。**
**Java 里对应的是一个布尔参数:`builder.redirectErrorStream(true)`。**
**它就是 Qt 那个 `MergedChannels`,也是 Python 的 `stderr=STDOUT`——**
**三个语言,同一个开关。**

**④ Java 骨架(本机没有 JDK,这段是写给你抄的)**

    List<String> cmd = Arrays.asList("ffmpeg", "-y", "-i", "a b.mp4",
                                     "-c:v", "libx264", "out.mp4");
    ProcessBuilder pb = new ProcessBuilder(cmd);
    pb.redirectErrorStream(true);          // 两路合一,最省事
    Process p = pb.start();
    StringBuilder log = new StringBuilder();
    try (BufferedReader r = new BufferedReader(
             new InputStreamReader(p.getInputStream(), "UTF-8"))) {
        String line;
        while ((line = r.readLine()) != null) log.append(line).append('\n');
    }
    if (!p.waitFor(10, TimeUnit.SECONDS)) p.destroyForcibly();
    int rc = p.exitValue();

**这段里三处关键:`Arrays.asList` 传数组(引号问题消失)、**
**`redirectErrorStream(true)`(死锁问题消失)、**
**`waitFor(10, TimeUnit.SECONDS)`(无限期等待消失)。**
**而 `exitValue()` 必须在 `waitFor` 之后调,否则抛 `IllegalThreadStateException`——**
**它只肯在你「已经确定进程结束了」之后才回答。**

第一段 3 个变 4 个,就是 Runtime.exec(String) 干的事。 第二段 4096 出得去、4608 卡住,就是「不读流就等」的下场。

换成 Java、换成 Qt,这两个数字都不会变。

30 秒实验

  1. 把 redirectErrorStream(true) 去掉,只读 stdout:看 stderr 藏到哪儿去了。
  2. 把 waitFor 的超时改成 1 毫秒:看返回 false 之后 exitValue() 会抛什么。
  3. 把 -nostdin 删掉:在后台跑一次,看进程会不会挂起。

常见误区

  1. 用 Runtime.exec("ffmpeg -i a b.mp4")。字符串会被按空格切开,引号无效。
  2. 只 waitFor() 不读流。缓冲区满就永久阻塞。
  3. 在 waitFor 之前调 exitValue()。抛 IllegalThreadStateException。
自测三问(点开对答案) 1. 为什么必须用 `ProcessBuilder` 而不是 `Runtime.exec(String)`? —— 后者按空白切分字符串,引号不生效,带空格的参数会被拆开。 2. `redirectErrorStream(true)` 的代价是什么? —— 两路合并后先后顺序不再可靠。 3. `waitFor` 返回 false 说明什么? —— 超时了,进程还在跑;此时不能调 `exitValue()`。

相关笔记