Javaで外部コマンドを実行するには ProcessBuilder を使います。コマンドと引数を1語ずつ渡して start() で起動し、標準出力を読み切ってから waitFor() で終了コードを受け取る、というのが基本の流れです。

この記事では、標準出力・標準エラー・終了コード・タイムアウト・作業ディレクトリ・環境変数を1つにまとめたコピペ用ユーティリティを、ls や sleep を動かした出力つきで紹介します。さらに、出力を読まずに waitFor して止まる現象を大量出力のコマンドで再現し、直し方を比較表にしました。「ls | grep」を文字列1本で渡したときのエラー文、sh -c と startPipeline での書き方、Java 9 以降の ProcessHandle も扱います。

確認環境:Ubuntu 24.04 / OpenJDK 21.0.10(エラー文の比較でのみ OpenJDK 17.0.20.1 も使用)。コマンドは Linux のものです。出力例のユーザー名は、表示を環境に合わせて置き換えています(taro)。

ProcessBuilder の早見表

やりたいこと 書き方 追加された版
コマンドを起動する new ProcessBuilder("ls", "-l").start() 1.5
標準出力を読む p.inputReader()(BufferedReader) 17
標準出力をまとめて読む p.getInputStream().readAllBytes() 9
標準エラーを読む p.errorReader() / p.getErrorStream() 17 / 1.0
標準エラーを標準出力にまとめる pb.redirectErrorStream(true) 1.5
終了を待って終了コードを得る int code = p.waitFor() 1.0
時間を区切って待つ p.waitFor(5, TimeUnit.SECONDS)(終了したら true) 1.8
終了を要求する/強制終了する p.destroy() / p.destroyForcibly() 1.0 / 1.8
作業ディレクトリを指定 pb.directory(new File("work")) 1.5
環境変数を追加 pb.environment().put("APP_MODE", "batch") 1.5
出力を Java の画面にそのまま流す pb.inheritIO() 1.7
出力を捨てる pb.redirectOutput(Redirect.DISCARD) 9
出力をファイルへ pb.redirectOutput(new File("out.log")) 8 以前
パイプでつなぐ ProcessBuilder.startPipeline(List.of(pb1, pb2)) 9
プロセス ID を得る p.pid() 9
終了時に処理を動かす p.onExit().thenAccept(...) 9

「追加された版」は、Java 21 の API ドキュメント(Process・ProcessBuilder)の Since の記載と、javac の --release オプションで対象の版を変えてコンパイルした結果によります。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java 外部コマンド実行|ProcessBuilderで標準出力と終了コード」

最小の例|ls を実行して標準出力と終了コードを得る

準備として、work というフォルダに App.java・Util.java・memo.txt の3つの空ファイルを置いています。

mkdir work && touch work/App.java work/Util.java work/memo.txt
import java.io.BufferedReader;

public class Min {
    public static void main(String[] args) throws Exception {
        // コマンドと引数は「1語ずつ」別の文字列で渡す
        ProcessBuilder pb = new ProcessBuilder("ls", "-1", "work");
        pb.redirectErrorStream(true);              // 標準エラーを標準出力にまとめる
        Process p = pb.start();

        // 先に出力を読み切る → そのあとで終了を待つ
        try (BufferedReader r = p.inputReader()) { // Java 17 以降
            String line;
            while ((line = r.readLine()) != null) {
                System.out.println("> " + line);
            }
        }
        int code = p.waitFor();
        System.out.println("終了コード: " + code);
    }
}

java Min.java の実行結果です。

> App.java
> Util.java
> memo.txt
終了コード: 0

押さえておきたい点は3つです。

  • コマンドと引数は1語ずつ分けて渡す。 "ls -1 work" と1本にまとめると、その名前のプログラムを探しにいってしまいます(後述)。
  • 「子プロセスの標準出力」は、Java から見ると入力。 そのため取得するメソッドの名前は getInputStream / inputReader です。
  • 読み切ってから waitFor する。 順番を逆にすると、出力が多いコマンドで止まります(後述)。

コピペで使える実行ユーティリティ(標準出力・標準エラー・終了コード・タイムアウト)

毎回同じ手順を書かずに済むよう、ひとまとめにしたユーティリティです。標準ライブラリだけで動きます(Java 17 以降)。

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

public class CmdDemo {

    /** 外部コマンドの実行結果 */
    record Result(int exitCode, String stdout, String stderr, boolean timedOut) {
        boolean ok() { return !timedOut && exitCode == 0; }
    }

    /** 外部コマンド実行ユーティリティ(標準ライブラリのみ、Java 17 以降) */
    static final class Cmd {
        static Result run(List<String> command) throws IOException, InterruptedException {
            return run(command, null, Map.of(), Duration.ofSeconds(30));
        }

        /**
         * @param command コマンドと引数(1語ずつ)
         * @param dir     作業ディレクトリ(null なら現在のまま)
         * @param env     追加・上書きする環境変数
         * @param timeout この時間を過ぎたら打ち切る
         */
        static Result run(List<String> command, Path dir, Map<String, String> env, Duration timeout)
                throws IOException, InterruptedException {
            ProcessBuilder pb = new ProcessBuilder(command);
            if (dir != null) pb.directory(dir.toFile());
            pb.environment().putAll(env);
            Process p = pb.start();
            p.getOutputStream().close();                       // 標準入力は使わないので閉じる

            // 標準出力と標準エラーを、別スレッドで同時に読み進める
            CompletableFuture<String> out = CompletableFuture.supplyAsync(() -> read(p.getInputStream()));
            CompletableFuture<String> err = CompletableFuture.supplyAsync(() -> read(p.getErrorStream()));

            boolean finished = p.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
            if (!finished) {
                p.descendants().forEach(ProcessHandle::destroyForcibly);   // 孫プロセスも止める
                p.destroyForcibly();
                p.waitFor();                                   // 終了コードが確定するまで待つ
            }
            return new Result(p.exitValue(), out.join(), err.join(), !finished);
        }

        private static String read(InputStream in) {
            try (in) {
                return new String(in.readAllBytes(), StandardCharsets.UTF_8);   // readAllBytes は Java 9 以降
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }
    }

    static void show(String title, Result r) {
        System.out.println("=== " + title);
        System.out.println("exitCode=" + r.exitCode() + " timedOut=" + r.timedOut() + " ok=" + r.ok());
        System.out.print("stdout: " + (r.stdout().isEmpty() ? "(なし)\n" : "\n" + r.stdout()));
        System.out.print("stderr: " + (r.stderr().isEmpty() ? "(なし)\n" : "\n" + r.stderr()));
    }

    public static void main(String[] args) throws Exception {
        // 1) ls:標準出力と終了コード 0
        show("ls -1 work", Cmd.run(List.of("ls", "-1", "work")));

        // 2) 無いパス:標準エラーと終了コード 2
        show("ls no_such_dir", Cmd.run(List.of("ls", "no_such_dir")));

        // 3) 作業ディレクトリと環境変数を指定
        show("pwd と環境変数", Cmd.run(
                List.of("sh", "-c", "basename \"$(pwd)\"; echo \"APP_MODE=$APP_MODE\""),
                Path.of("work"), Map.of("APP_MODE", "batch"), Duration.ofSeconds(5)));

        // 4) タイムアウト:sleep 10 を 1 秒で打ち切る
        long s = System.nanoTime();
        Result r = Cmd.run(List.of("sleep", "10"), null, Map.of(), Duration.ofSeconds(1));
        show("sleep 10(1秒で打ち切り)", r);
        System.out.printf("かかった時間: %.1f 秒%n", (System.nanoTime() - s) / 1e9);

        // 5) 大量の出力でも止まらない
        Result big = Cmd.run(List.of("seq", "1", "200000"));
        System.out.println("=== seq 1 200000");
        System.out.println("exitCode=" + big.exitCode() + " stdout の文字数=" + big.stdout().length());
    }
}

java CmdDemo.java の実行結果です。

=== ls -1 work
exitCode=0 timedOut=false ok=true
stdout: 
App.java
Util.java
memo.txt
stderr: (なし)
=== ls no_such_dir
exitCode=2 timedOut=false ok=false
stdout: (なし)
stderr: 
ls: cannot access 'no_such_dir': No such file or directory
=== pwd と環境変数
exitCode=0 timedOut=false ok=true
stdout: 
work
APP_MODE=batch
stderr: (なし)
=== sleep 10(1秒で打ち切り)
exitCode=137 timedOut=true ok=false
stdout: (なし)
stderr: (なし)
かかった時間: 1.0 秒
=== seq 1 200000
exitCode=0 stdout の文字数=1288895

ユーティリティの設計のポイント

ポイント 理由
標準出力と標準エラーを別スレッドで同時に読む 片方だけを先に読み切ろうとすると、もう片方がいっぱいになって止まることがあるため
waitFor(timeout) の戻り値を見る true は「時間内に終了」、false は「まだ実行中」。false のときだけ打ち切る
destroyForcibly のあとにもう一度 waitFor 強制終了は一瞬では終わらないことがあり、終了コードが確定するのを待つため
descendants() も止める sh -c 経由で起動した場合、シェルの子(孫プロセス)が残ることがあるため
標準入力を閉じる 入力を待つコマンドが、入力の終わりを受け取って先へ進めるようにするため
文字コードを UTF-8 と明示 コマンドの出力の文字コードに合わせる。別の文字コードで出力するコマンドでは、その名前に変える

結果は record にまとめ、ok() で「タイムアウトせず、終了コードが 0」を判定できるようにしています。終了コード 0 が正常、0 以外が何らかのエラーというのがコマンドの共通の約束ごとなので、呼び出し側は ok() と stderr を見れば状況が分かります。

終了コードの読み方

終了コードがどんな値になるかを、いくつかのコマンドで確かめます。

import java.util.concurrent.TimeUnit;

public class Codes {
    static int code(String... cmd) throws Exception {
        return new ProcessBuilder(cmd).inheritIO().start().waitFor();
    }

    public static void main(String[] args) throws Exception {
        System.out.println("true            → " + code("true"));
        System.out.println("false           → " + code("false"));
        System.out.println("sh -c 'exit 3'  → " + code("sh", "-c", "exit 3"));
        System.out.println("grep 見つからず → " + code("grep", "-q", "java", "work/memo.txt"));
        System.out.println("sh -c 無い命令  → " + code("sh", "-c", "no_such_command 2>/dev/null"));

        Process a = new ProcessBuilder("sleep", "30").start();
        System.out.println("supportsNormalTermination: " + a.supportsNormalTermination());
        a.destroy();                 // 通常の終了要求
        System.out.println("destroy()         → " + a.waitFor());

        Process b = new ProcessBuilder("sleep", "30").start();
        b.destroyForcibly();         // 強制終了
        System.out.println("destroyForcibly() → " + b.waitFor());

        // 実行中に exitValue() を呼ぶと例外になる
        Process c = new ProcessBuilder("sleep", "30").start();
        try {
            c.exitValue();
        } catch (IllegalThreadStateException e) {
            System.out.println("実行中の exitValue(): " + e);
        }
        System.out.println("isAlive: " + c.isAlive());
        c.destroyForcibly();
        c.waitFor(1, TimeUnit.SECONDS);
        System.out.println("isAlive: " + c.isAlive());
    }
}
true            → 0
false           → 1
sh -c 'exit 3'  → 3
grep 見つからず → 1
sh -c 無い命令  → 127
supportsNormalTermination: true
destroy()         → 143
destroyForcibly() → 137
実行中の exitValue(): java.lang.IllegalThreadStateException: process hasn't exited
isAlive: true
isAlive: false
終了コード この実行での意味
0 正常終了
1 false、grep で一致なし
2 ls で存在しないパスを指定(前の章の例)
3 スクリプトが exit 3 で終了。自作のスクリプトは好きな値を返せる
127 sh -c の中のコマンドが見つからない
143 destroy() で終了を要求された
137 destroyForcibly() で強制終了された

終了コードの意味はコマンドごとに決まっています。 たとえば grep の 1 は「一致する行がなかった」で、異常とは限りません。0 以外をすべて例外にするか、値ごとに扱いを分けるかは、実行するコマンドに合わせて決めます。

exitValue() は、終了済みのプロセスからコードを取り出すメソッドです。実行中に呼ぶと IllegalThreadStateException になるので、待つときは waitFor()、状態を見るだけなら isAlive() を使います。

タイムアウトで打ち切る|waitFor(timeout) と destroyForcibly

外部コマンドが終わらないと、Java 側も待ち続けることになります。時間を区切る基本形は次のとおりです(ユーティリティの中でも同じ形を使っています)。

手順 メソッド 説明
1. 時間を区切って待つ p.waitFor(1, TimeUnit.SECONDS) 時間内に終了すれば true、まだ実行中なら false
2. 終了を要求する p.destroy() 通常の終了要求。この環境では終了コード 143
3. 強制終了する p.destroyForcibly() すぐに止めたいとき。この環境では終了コード 137
4. 終了を確かめる p.waitFor() または p.isAlive() 強制終了のあとも、終了の確定を待つ

先ほどの実行結果では、sleep 10 を1秒で打ち切り、timedOut=true、終了コード 137、かかった時間 1.0 秒になりました。API ドキュメントには、destroyForcibly を呼んでもプロセスがすぐに終了するとは限らず、isAlive() がしばらく true を返すことがある、と書かれています。そのため、打ち切りのあとに waitFor() を置いています。

後片付けの時間を与えたいコマンドでは、「まず destroy() → 数秒待つ → まだ動いていたら destroyForcibly()」の2段構えにすると、穏やかに止められます。

出力を読まずに waitFor すると止まる現象

ProcessBuilder でいちばんつまずきやすいのが、この現象です。出力を読まずに終了だけを待つコードで、出力の量を変えて試します。

import java.util.concurrent.TimeUnit;

public class Hang {
    public static void main(String[] args) throws Exception {
        int n = Integer.parseInt(args[0]);
        // 出力を読まずに、終了だけを待つ
        Process p = new ProcessBuilder("seq", "1", String.valueOf(n)).start();
        boolean finished = p.waitFor(3, TimeUnit.SECONDS);
        System.out.println("seq 1 " + n + " → 3秒以内に終了: " + finished
                + (finished ? "(終了コード " + p.exitValue() + ")" : "(まだ実行中)"));
        if (!finished) {
            p.destroyForcibly();
        }
    }
}

seq 1 N は 1 から N までの数字を1行ずつ出力するコマンドです。N を変えて実行しました。

java Hang.java 1000
java Hang.java 10000
java Hang.java 20000
java Hang.java 100000
seq 1 1000 → 3秒以内に終了: true(終了コード 0)
seq 1 10000 → 3秒以内に終了: true(終了コード 0)
seq 1 20000 → 3秒以内に終了: false(まだ実行中)
seq 1 100000 → 3秒以内に終了: false(まだ実行中)
コマンド 出力のバイト数 結果
seq 1 1000 3,893 終了する
seq 1 10000 48,894 終了する
seq 1 20000 108,894 止まる
seq 1 100000 588,895 止まる

seq は一瞬で終わるコマンドですが、出力が一定量を超えると、3秒待っても終了しません。 この環境では、約4.9万バイトでは終了し、約10.9万バイトでは止まりました。

なぜ止まるのか

子プロセスの標準出力は、パイプという「一時置き場」を通って Java に届きます。この置き場の大きさには限りがあります。Java 側が読まずにいると置き場がいっぱいになり、子プロセスは「空くまで待つ」状態になります。Java は子の終了を待ち、子は Java が読むのを待つ、というお互いに待ち合う形です。

sequenceDiagram
    participant J as Java
    participant P as パイプ(一時置き場)
    participant C as 子プロセス seq
    C->>P: 出力を書き込む
    C->>P: 出力を書き込む(いっぱいになる)
    Note over C: 空くまで待つ
    Note over J: waitFor で子の終了を待つ
    Note over J,C: お互いに待ち続ける

Process の API ドキュメントにも、標準入出力のバッファーの大きさが限られるプラットフォームがあるため、出力をすみやかに読み取らないと子プロセスがブロックしたり、デッドロックしたりすることがある、と書かれています。出力が少ないうちは動くので、テストでは通り、データが増えたときに初めて止まるのがこの現象の特徴です。

直し方の比較表

直し方は「読む」「流す」「捨てる」「ファイルに書く」の4系統です。同じ seq 1 100000 で確かめます。

import java.io.File;
import java.lang.ProcessBuilder.Redirect;
import java.util.concurrent.TimeUnit;

public class Fix {
    static ProcessBuilder seq() { return new ProcessBuilder("seq", "1", "100000"); }

    static void report(String label, Process p) throws Exception {
        boolean finished = p.waitFor(3, TimeUnit.SECONDS);
        System.err.println(label + " → 終了: " + finished + (finished ? "(終了コード " + p.exitValue() + ")" : ""));
        if (!finished) p.destroyForcibly();
    }

    public static void main(String[] args) throws Exception {
        // A) redirectErrorStream(true) で1本にまとめ、読み切ってから待つ
        Process a = seq().redirectErrorStream(true).start();
        long lines = a.inputReader().lines().count();
        report("A redirectErrorStream + 読み切る(" + lines + " 行)", a);

        // B) inheritIO():Java 自身の標準出力・標準エラーにそのまま流す
        report("B inheritIO", seq().inheritIO().start());

        // C) Redirect.DISCARD:出力を捨てる(Java 9 以降)
        report("C Redirect.DISCARD", seq().redirectOutput(Redirect.DISCARD).redirectError(Redirect.DISCARD).start());

        // D) ファイルへ書き出す
        File log = new File("seq.log");
        report("D ファイルへ", seq().redirectErrorStream(true).redirectOutput(log).start());
        System.err.println("  seq.log のサイズ: " + log.length() + " バイト");
        log.delete();
    }
}

java Fix.java > /dev/null の実行結果です(B で画面に流れる10万行は捨て、結果だけを標準エラーに出しています)。

A redirectErrorStream + 読み切る(100000 行) → 終了: true(終了コード 0)
B inheritIO → 終了: true(終了コード 0)
C Redirect.DISCARD → 終了: true(終了コード 0)
D ファイルへ → 終了: true(終了コード 0)
  seq.log のサイズ: 588895 バイト

4つとも、3秒以内に終了コード 0 で終わりました。

方法 書き方 出力を Java で使えるか 向いている場面
1本にまとめて読み切る pb.redirectErrorStream(true) + inputReader() で最後まで読む 使える(標準エラーと混ざる) 出力をログとして丸ごと扱いたい
2本を別スレッドで読む この記事の Cmd ユーティリティ 使える(別々に取れる) 標準出力を結果として使い、標準エラーは別に見たい
そのまま流す pb.inheritIO() 使えない(画面に出るだけ) 進み具合を利用者に見せたい、バッチの出力をそのまま残したい
捨てる pb.redirectOutput(Redirect.DISCARD) と redirectError(Redirect.DISCARD) 使えない 終了コードだけ分かればよい
ファイルに書く pb.redirectOutput(new File("out.log")) あとでファイルを読む 出力が大きい、あとで見返したい

redirectErrorStream(true) を指定した場合、標準エラーは標準出力に合流し、getErrorStream() からは何も読めなくなります。「標準出力だけを読み、標準エラーは放っておく」という形だと、標準エラーが多いコマンドで同じように止まるので、まとめる・捨てる・別スレッドで読む、のどれかを必ず選びます。

「ls | grep」を文字列1本で渡すとエラーになる理由

ターミナルで打つ ls work | grep java を、そのまま ProcessBuilder に渡すとどうなるかを確かめます。

import java.io.IOException;
import java.util.List;

public class Pipe {
    static void run(String title, ProcessBuilder pb) throws Exception {
        System.out.println("--- " + title);
        try {
            Process p = pb.redirectErrorStream(true).start();
            p.inputReader().lines().forEach(l -> System.out.println("> " + l));
            System.out.println("終了コード: " + p.waitFor());
        } catch (IOException e) {
            System.out.println("IOException: " + e.getMessage());
        }
    }

    public static void main(String[] args) throws Exception {
        // 1) 文字列1本で渡す → その名前のプログラムを探しにいく
        run("1本の文字列", new ProcessBuilder("ls work | grep java"));

        // 2) 語ごとに分けても、| は ls の引数になる
        run("語ごとに分ける", new ProcessBuilder("ls", "work", "|", "grep", "java"));

        // 3) sh -c に渡す:シェルが | を解釈する
        run("sh -c", new ProcessBuilder("sh", "-c", "ls work | grep java"));

        // 4) startPipeline(Java 9 以降):シェルを使わずにつなぐ
        System.out.println("--- startPipeline");
        List<String> names = List.of("ls", "grep", "wc");
        List<Process> ps = ProcessBuilder.startPipeline(List.of(
                new ProcessBuilder("ls", "work"),
                new ProcessBuilder("grep", "java"),
                new ProcessBuilder("wc", "-l")));
        Process last = ps.get(ps.size() - 1);        // 最後のプロセスの出力を読む
        last.inputReader().lines().forEach(l -> System.out.println("> " + l.trim()));
        for (int i = 0; i < ps.size(); i++) {
            System.out.println(names.get(i) + " の終了コード: " + ps.get(i).waitFor());
        }
    }
}
--- 1本の文字列
IOException: Cannot run program "ls work | grep java": Exec failed, error: 2 (No such file or directory) 
--- 語ごとに分ける
> ls: cannot access '|': No such file or directory
> ls: cannot access 'grep': No such file or directory
> ls: cannot access 'java': No such file or directory
> work:
> App.java
> Util.java
> memo.txt
終了コード: 2
--- sh -c
> App.java
> Util.java
終了コード: 0
--- startPipeline
> 2
ls の終了コード: 0
grep の終了コード: 0
wc の終了コード: 0
渡し方 結果 理由
"ls work | grep java" を1本で IOException(Cannot run program) 文字列全体が「プログラムの名前」として扱われる
"ls", "work", "|", "grep", "java" ls が終了コード 2 縦棒も grep も ls の引数(ファイル名)として渡される
"sh", "-c", "ls work | grep java" 期待どおり 縦棒を解釈するのはシェル。シェルに文字列を渡す
startPipeline 期待どおり Java がプロセス同士の出力と入力をつなぐ

パイプ(縦棒)、リダイレクト(不等号)、ワイルドカード(アスタリスク)、変数展開(ドル記号)は、どれもシェルの機能です。 ProcessBuilder はシェルを通さずにプログラムを直接起動するので、これらの記号はただの文字として渡されます。次の例で確かめられます。

import java.util.List;

public class Split {
    public static void main(String[] args) throws Exception {
        // 空白を含む引数も、1要素として渡せばそのまま届く
        Process p = new ProcessBuilder(List.of("printf", "[%s]\n", "hello world", "a b  c", "*.java", "$HOME"))
                .redirectErrorStream(true).start();
        p.inputReader().lines().forEach(System.out::println);
        System.out.println("終了コード: " + p.waitFor());
    }
}
[hello world]
[a b  c]
[*.java]
[$HOME]
終了コード: 0

空白を含む引数は引用符なしでそのまま届き、アスタリスクやドル記号も展開されずに届いています。これは利点でもあります。利用者が入力した文字列を引数として渡すときは、1要素として渡せばシェルに解釈されないので、sh -c の文字列に連結するよりも安全に扱えます。

sh -c と startPipeline の使い分け

観点 sh -c "..." startPipeline
書きやすさ ターミナルのコマンドをそのまま書ける ProcessBuilder をコマンドの数だけ並べる
使える機能 パイプ、リダイレクト、ワイルドカード、変数など パイプのみ
外から受け取った文字列 連結するとシェルに解釈されるので、連結を避ける 引数として1要素で渡せる
各コマンドの終了コード シェルの終了コード(通常は最後のコマンド) プロセスごとに取得できる
追加された版 ― Java 9

API ドキュメントによると、startPipeline は各プロセスの標準出力を次のプロセスの標準入力へつなぎ、起動の途中で例外が起きた場合は、起動済みのプロセスをすべて強制終了します。

Cannot run program(error=2)の意味と対処

コマンドを起動できなかったときは、start() が IOException を投げます。代表的な3つを再現します。

import java.io.File;
import java.io.IOException;
import java.util.List;

public class Errors {
    static void tryStart(String title, ProcessBuilder pb) {
        try {
            pb.start().waitFor();
            System.out.println(title + ": 起動できた");
        } catch (IOException | InterruptedException e) {
            System.out.println(title + ":\n  " + e.getClass().getName() + ": " + e.getMessage());
        }
    }

    public static void main(String[] args) throws Exception {
        tryStart("存在しないコマンド", new ProcessBuilder("no_such_command", "-v"));
        tryStart("存在しない作業ディレクトリ", new ProcessBuilder("ls").directory(new File("no_such_dir")));
        new File("work/memo.txt").setExecutable(false);
        tryStart("実行権限のないファイル", new ProcessBuilder("work/memo.txt"));
        try {
            new ProcessBuilder(List.of()).start();
        } catch (IndexOutOfBoundsException e) {
            System.out.println("空のコマンド:\n  " + e.getClass().getName() + ": " + e.getMessage());
        }
    }
}

OpenJDK 21.0.10 での実行結果です。

存在しないコマンド:
  java.io.IOException: Cannot run program "no_such_command": Exec failed, error: 2 (No such file or directory) 
存在しない作業ディレクトリ:
  java.io.IOException: Cannot run program "ls" (in directory "no_such_dir"): Exec failed, error: 2 (No such file or directory) 
実行権限のないファイル:
  java.io.IOException: Cannot run program "work/memo.txt": Exec failed, error: 13 (Permission denied) 
空のコマンド:
  java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0

同じプログラムを OpenJDK 17.0.20.1 で動かすと、エラー文の書式が少し違いました。

存在しないコマンド:
  java.io.IOException: Cannot run program "no_such_command": error=2, No such file or directory
存在しない作業ディレクトリ:
  java.io.IOException: Cannot run program "ls" (in directory "no_such_dir"): error=2, No such file or directory
実行権限のないファイル:
  java.io.IOException: Cannot run program "work/memo.txt": error=13, Permission denied
空のコマンド:
  java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0

「error=2, No such file or directory」と「Exec failed, error: 2 (No such file or directory)」は、書式が違うだけで同じ内容です。 番号と説明(2 は「そのファイルやディレクトリがない」、13 は「権限がない」)を見て対処します。

エラー文に含まれる番号 意味 確かめること
2(No such file or directory) プログラム、または作業ディレクトリが見つからない コマンド名のつづり、1本の文字列にまとめていないか、PATH にあるか、(in directory ...) のパスが存在するか
13(Permission denied) 実行する権限がない ファイルの実行権限(chmod +x)、実行ファイルではないものを指定していないか

error=2 で特に多いのが、次の2つです。

  • コマンドと引数を1本の文字列にしている。 new ProcessBuilder("ls -l") は「ls -l」という名前のプログラムを探します。"ls", "-l" と分けます。
  • 作業ディレクトリが存在しない。 エラー文に (in directory "...") が付いていたら、コマンドではなくディレクトリ側を確かめます。上の2番目の例では、ls 自体は存在するのに error 2 になっています。

コマンドが PATH から見つからないときは、/usr/bin/ls のように絶対パスで指定すると、起動する環境の違いに左右されにくくなります。

Java 9 以降の ProcessHandle|pid・情報・終了通知

Java 9 で、プロセスの情報を扱う ProcessHandle が追加されました。プロセス ID、コマンド名、親子関係、終了時の通知を標準 API で扱えます。

import java.time.Duration;
import java.time.Instant;

public class Handle {
    public static void main(String[] args) throws Exception {
        // 自分自身(この JVM)の情報
        ProcessHandle me = ProcessHandle.current();
        System.out.println("自分の pid は正の数: " + (me.pid() > 0));
        System.out.println("自分のコマンド: " + me.info().command().orElse("?"));

        // 子プロセスを起動して情報を見る
        Process p = new ProcessBuilder("sh", "-c", "sleep 2; exit 7").start();
        ProcessHandle h = p.toHandle();
        ProcessHandle.Info info = h.info();
        System.out.println("pid() と toHandle().pid() は同じ: " + (p.pid() == h.pid()));
        System.out.println("command  : " + info.command().orElse("?"));
        System.out.println("arguments: " + String.join(" | ", info.arguments().orElse(new String[0])));
        System.out.println("user     : " + info.user().orElse("?"));
        System.out.println("開始は今から1分以内: "
                + info.startInstant().map(t -> Duration.between(t, Instant.now()).toSeconds() < 60).orElse(false));
        System.out.println("親は自分: " + h.parent().map(pp -> pp.pid() == me.pid()).orElse(false));
        Thread.sleep(300);
        System.out.println("子の子(sleep)の数: " + h.children().count());
        h.children().forEach(c -> System.out.println("  " + c.info().command().orElse("?")));
        System.out.println("isAlive: " + h.isAlive());

        // 終了したら動く処理を登録する(待たずに先へ進める)
        p.onExit().thenAccept(done -> System.out.println("onExit: 終了コード " + done.exitValue()));
        System.out.println("(待たずに別の処理を続けられる)");
        p.onExit().join();           // デモ用にここで終了を待つ
        Thread.sleep(100);
        System.out.println("isAlive: " + h.isAlive());
    }
}
自分の pid は正の数: true
自分のコマンド: /usr/lib/jvm/java-21-openjdk-amd64/bin/java
pid() と toHandle().pid() は同じ: true
command  : /usr/bin/dash
arguments: -c | sleep 2; exit 7
user     : taro
開始は今から1分以内: true
親は自分: true
子の子(sleep)の数: 1
  /usr/bin/sleep
isAlive: true
(待たずに別の処理を続けられる)
onExit: 終了コード 7
isAlive: false

command が /usr/bin/dash になっているのは、この環境の sh の実体が dash だからです。

メソッド 分かること・できること
ProcessHandle.current() 自分自身(実行中の JVM)のハンドル
p.pid() / h.pid() プロセス ID
h.info() command・arguments・user・startInstant など(Optional で返る)
h.parent() 親プロセス
h.children() / h.descendants() 子プロセス/子孫すべて
h.isAlive() 実行中かどうか
h.destroy() / h.destroyForcibly() 終了要求/強制終了
p.onExit() 終了時に完了する CompletableFuture

info() の各項目が Optional なのは、OS や権限によっては取得できないことがあるためです。終了したプロセスでは command が空になる場合もあるので、orElse で既定値を付けておくと扱いやすくなります。

onExit() を使うと、waitFor でスレッドを待たせずに「終わったらこれをする」と登録できます。 複数のコマンドを並行して動かすときに便利です。また、ユーティリティで使った descendants() は、sh -c で起動したシェルの先にいるプロセスまでまとめて止めるためのものです。

子プロセスの標準入力に書き込む

最後に、Java から子プロセスへデータを渡す例です。sort に3行を渡して、並べ替えた結果を受け取ります。

import java.io.BufferedWriter;

public class Stdin {
    public static void main(String[] args) throws Exception {
        Process p = new ProcessBuilder("sort").redirectErrorStream(true).start();
        // 子プロセスの標準入力へ書き込む(閉じると入力の終わりが伝わる)
        try (BufferedWriter w = p.outputWriter()) {
            w.write("cherry\napple\nbanana\n");
        }
        p.inputReader().lines().forEach(System.out::println);
        System.out.println("終了コード: " + p.waitFor());
    }
}
apple
banana
cherry
終了コード: 0

「子プロセスの標準入力」は Java から見ると出力なので、メソッド名は getOutputStream / outputWriter(Java 17 以降)です。書き終えたら必ず閉じるのがポイントで、閉じないと sort は入力の続きを待ち続けます。渡すデータが大きいときは、書き込みと読み取りを別スレッドに分けると、出力の章と同じ「待ち合い」を避けられます。

つまずきやすい点のチェックリスト

症状 原因 こうするとよい
Cannot run program … error 2 コマンドと引数を1本の文字列で渡している/コマンドやディレクトリが存在しない 1語ずつ分ける。パスを確かめる
waitFor から戻ってこない 出力を読んでいない 読み切ってから待つ、inheritIO、DISCARD、ファイルへ
標準エラーが多いコマンドで止まる 標準出力だけを読んでいる redirectErrorStream(true) か、別スレッドで両方読む
縦棒やアスタリスクが効かない シェルを通していない sh -c に渡す、または startPipeline
入力待ちで止まる 子の標準入力が開いたまま getOutputStream().close() で閉じる
終わらないコマンドで Java も止まる 時間を区切っていない waitFor(timeout) と destroyForcibly
終了コードが 0 以外 コマンド側のエラー 標準エラーの内容を一緒に記録する
打ち切ったのに子の子が残る シェル経由で孫プロセスがいる descendants() も止める

よくある質問

Q. Runtime.exec と ProcessBuilder のどちらを使えばよいですか? A. ProcessBuilder をおすすめします。作業ディレクトリ、環境変数、標準エラーの合流、リダイレクトをメソッドで指定でき、引数もリストで渡せるため、空白を含む引数を安全に扱えます。Process の API ドキュメントでも、ProcessBuilder.start() と Runtime.exec がプロセスを作る方法として並べて説明されています。

Q. 標準出力と標準エラーを別々に取得するにはどうすればよいですか? A. redirectErrorStream を指定せず、getInputStream() と getErrorStream() を別々のスレッドで同時に読みます。この記事の Cmd ユーティリティは CompletableFuture でその形にしています。片方ずつ順番に読むと、もう片方がいっぱいになって止まることがあります。

Q. 終了コードはいつ取得できますか? A. プロセスが終了したあとです。waitFor() は終了まで待って終了コードを返します。waitFor(timeout, unit) は true か false を返すので、true のときに exitValue() で取り出します。

Q. タイムアウトさせたコマンドの出力は取れますか? A. 打ち切るまでに出力された分は取れます。Cmd ユーティリティは出力を別スレッドで読み続けているので、Result の stdout と stderr に途中までの内容が入ります。

まとめ

  • コマンドと引数は1語ずつ渡す。 1本の文字列にまとめると Cannot run program(error 2)になる
  • 出力を読み切ってから waitFor する。 読まないままだと、出力が多いコマンドで止まる
  • 標準エラーの扱いを決める。 redirectErrorStream(true) でまとめる、別スレッドで読む、捨てる、のどれかを選ぶ
  • 終了コードは waitFor() の戻り値。 0 が正常、0 以外の意味はコマンドごとに決まっている
  • 時間を区切るなら waitFor(timeout) と destroyForcibly()。 打ち切ったあとも waitFor で終了を確かめる
  • 縦棒・リダイレクト・ワイルドカードはシェルの機能。 sh -c に渡すか、startPipeline でつなぐ
  • Java 9 以降は ProcessHandle で pid・親子関係・終了通知を扱える

参考・出典

  • Java SE 21 API ドキュメント:ProcessBuilder、Process(docs.oracle.com)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java 外部コマンド実行|ProcessBuilderで標準出力と終了コード」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?