Java Runtime.exec(): Running OS Commands Safely (with Runnable Examples)

The first time I reached for Runtime.getRuntime().exec() in a production Java service, it wasn’t because I wanted “Java to run shell commands.” I had a real constraint: a vendor tool only shipped as a native executable, and my Java app had to drive it, read its output, and decide whether to continue. That’s the moment you discover that process execution is not “just one line of code.” It’s an interface boundary between two worlds: the JVM and the operating system.

If you’ve ever needed to call ffmpeg, run git to read metadata, invoke kubectl, start a helper daemon, or wrap a legacy script from a Java application, exec() is the entry point. In this post I’ll walk you through what actually happens when you call exec(), what the six overloads mean, and how to avoid the classic failure modes: broken quoting, hanging processes, missing output, and security footguns. I’ll show runnable examples you can paste into a single .java file and execute, plus a small “modern” wrapper I recommend in 2026-style codebases.

The mental model: Runtime, Process, and what exec() really does

Runtime.exec() starts a native process. That process is not a thread. It does not run inside your JVM. It is a separate OS-level process with its own:

  • executable and arguments
  • working directory
  • environment variables
  • standard input (stdin), standard output (stdout), and standard error (stderr)
  • exit code

In Java, the handle you get back is a Process. Think of Process as a remote control: you can wait for completion, write to its input stream, read its output streams, and terminate it.

A key detail that surprises people: exec() does not automatically start a shell. If you do this:

Runtime.getRuntime().exec("ls -l");

…you are not asking the shell to parse ls -l. You are asking the OS to find an executable literally named ls -l (or to apply platform-specific tokenization rules that often don’t match what you expect). That’s why the “it works in my terminal” command so often fails when passed as a single string.

If you truly need shell features—pipes (|), redirection (>), globbing (*.log), &&, environment expansion ($HOME)—you must explicitly invoke a shell (for example sh -c on Unix-like systems or cmd /c on Windows). I’ll show that pattern later, along with the security implications.

The six overloads of exec() and when each one earns its keep

Runtime.exec() has multiple overloads because a real process launch needs more than a command. You’ll typically pick one of these shapes:

1) exec(String command)

  • Quick and tempting.
  • Most fragile with quoting and spaces.

2) exec(String[] cmdarray)

  • My default when I must use Runtime.exec().
  • You control tokenization by explicitly separating arguments.

3) exec(String[] cmdarray, String[] envp)

  • Adds environment variables.

4) exec(String[] cmdarray, String[] envp, File dir)

  • Adds environment variables and working directory.

5) exec(String command, String[] envp)

  • Single-string command plus environment.
  • Still fragile with quoting.

6) exec(String command, String[] envp, File dir)

  • Single-string command plus environment plus working directory.

A practical rule I use:

  • If the command has any arguments, I prefer String[].
  • If I need shell syntax, I explicitly call the shell using String[].
  • If I need a working directory or environment variables, I use the overload that expresses that directly (or I switch to ProcessBuilder, which I generally prefer for new code).

A quick note about ProcessBuilder

Even though this post is focused on Runtime.exec(), you should know that Java’s newer, more configurable API is ProcessBuilder. It’s easier to:

  • set the directory
  • set environment variables via a map
  • redirect streams to files
  • avoid subtle envp formatting mistakes

In many teams I work with, the rule is simple: ProcessBuilder for new code, Runtime.exec() only when you’re touching legacy code or you need a tiny, isolated call and you understand the tradeoffs.

Here’s a short “Traditional vs Modern” table that matches what I see in 2026 code reviews:

Task

Traditional pattern

Modern pattern I recommend —

— Launch command with args

exec("tool -a -b")

exec(new String[]{"tool","-a","-b"}) or new ProcessBuilder(...) Set env vars

exec(cmd, envp)

ProcessBuilder.environment().put(...) Handle stdout/stderr

read one stream, forget the other

read both concurrently or redirect error stream Timeouts

none, hope it finishes

waitFor(timeout), then destroy()/destroyForcibly() Shell features

pass pipes in a string

sh -c ... / cmd /c ... with strict input validation

Runnable example 1: run a cross-platform command and capture output

If you copy the following into ExecBasicsDemo.java and run it, it will:

  • pick an OS-appropriate command
  • start the process with exec(String[])
  • read stdout and stderr
  • enforce a timeout
  • print the exit code

Java:

import java.io.*;

import java.nio.charset.Charset;

import java.time.Duration;

import java.util.*;

import java.util.concurrent.*;

public class ExecBasicsDemo {

public static void main(String[] args) throws Exception {

List command = buildWhoAmICommand();

ExecResult result = run(command, Duration.ofSeconds(3), Charset.defaultCharset(), null, null);

System.out.println("Command: " + String.join(" ", command));

System.out.println("Exit code: " + result.exitCode);

System.out.println("— stdout —");

System.out.println(result.stdout);

System.out.println("— stderr —");

System.out.println(result.stderr);

}

private static List buildWhoAmICommand() {

if (isWindows()) {

return List.of("cmd", "/c", "whoami");

}

return List.of("whoami");

}

private static boolean isWindows() {

return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win");

}

static ExecResult run(

List command,

Duration timeout,

Charset charset,

Map environment,

File workingDirectory

) throws Exception {

String[] cmdArray = command.toArray(new String[0]);

String[] envp = null;

if (environment != null && !environment.isEmpty()) {

envp = environment.entrySet().stream()

.map(e -> e.getKey() + "=" + e.getValue())

.toArray(String[]::new);

}

Process process = (workingDirectory == null)

? Runtime.getRuntime().exec(cmdArray, envp)

: Runtime.getRuntime().exec(cmdArray, envp, workingDirectory);

ExecutorService pool = Executors.newFixedThreadPool(2);

Future outFuture = pool.submit(() -> readAll(process.getInputStream(), charset));

Future errFuture = pool.submit(() -> readAll(process.getErrorStream(), charset));

boolean finished = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);

if (!finished) {

process.destroy();

boolean exited = process.waitFor(500, TimeUnit.MILLISECONDS);

if (!exited) {

process.destroyForcibly();

process.waitFor(500, TimeUnit.MILLISECONDS);

}

}

int exitCode = finished ? process.exitValue() : -1;

String stdout = safeGet(outFuture, Duration.ofSeconds(1));

String stderr = safeGet(errFuture, Duration.ofSeconds(1));

pool.shutdownNow();

return new ExecResult(exitCode, stdout, stderr, finished);

}

private static String readAll(InputStream in, Charset charset) throws IOException {

try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, charset))) {

StringBuilder sb = new StringBuilder();

String line;

while ((line = reader.readLine()) != null) {

sb.append(line).append(System.lineSeparator());

}

return sb.toString();

}

}

private static String safeGet(Future future, Duration timeout) {

try {

return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);

} catch (Exception e) {

return "";

}

}

static class ExecResult {

final int exitCode;

final String stdout;

final String stderr;

final boolean finished;

ExecResult(int exitCode, String stdout, String stderr, boolean finished) {

this.exitCode = exitCode;

this.stdout = stdout;

this.stderr = stderr;

this.finished = finished;

}

}

}

Why I like this pattern:

  • It avoids the “single string parsing” trap by building an argument list.
  • It reads both stdout and stderr concurrently, which prevents a common hang.
  • It has a timeout and a fallback termination sequence.

This is already far safer than the typical exec(...) one-liner.

Argument parsing: why exec(String[]) saves you from quoting pain

If you remember one thing, make it this: exec(String[]) is the “no surprises” overload.

When you pass a single string, you are forcing Java and the OS to guess how to split it into tokens. That guess varies by platform and can break in ways that are hard to spot during review.

Consider a real command you might run:

  • /usr/local/bin/image-tool --input "Monthly Report.png" --quality 85

If you pass this as one string, you’re now depending on the parsing behavior to keep Monthly Report.png as one argument. If you instead pass:

  • new String[]{"/usr/local/bin/image-tool","--input","Monthly Report.png","--quality","85"}

…your intent is unambiguous.

When you must use a shell

Sometimes the command is inherently shell-based, like:

  • cat app.log grep ERROR

    tail -n 50

There is no direct executable called |. You need a shell:

Unix-like systems:

String[] cmd = {"sh", "-c", "cat app.log

grep ERROR

tail -n 50"};

Process p = Runtime.getRuntime().exec(cmd);

Windows:

String[] cmd = {"cmd", "/c", "type app.log | findstr ERROR"};

Process p = Runtime.getRuntime().exec(cmd);

I only do this when I control the entire command string (hard-coded or assembled from safe, validated pieces). If any untrusted input can touch that shell string, you are very close to command injection.

Runnable example 2: setting environment variables and working directory

Environment variables and the working directory are where the “interaction with the OS” becomes practical.

Here’s a runnable example that:

  • sets an environment variable APP_MODE=diagnostics
  • runs a command that prints it
  • sets a working directory so relative paths behave as expected

Java:

import java.io.*;

import java.nio.charset.StandardCharsets;

import java.util.*;

public class ExecEnvAndDirDemo {

public static void main(String[] args) throws Exception {

Map env = new LinkedHashMap();

env.put("APP_MODE", "diagnostics");

File workingDir = new File(System.getProperty("user.home"));

String[] cmd = buildEchoEnvCommand();

String[] envp = env.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).toArray(String[]::new);

Process p = Runtime.getRuntime().exec(cmd, envp, workingDir);

String stdout = readAll(p.getInputStream());

String stderr = readAll(p.getErrorStream());

int exit = p.waitFor();

System.out.println("Working directory: " + workingDir.getAbsolutePath());

System.out.println("Exit code: " + exit);

System.out.println("stdout: " + stdout.trim());

if (!stderr.isBlank()) {

System.out.println("stderr: " + stderr.trim());

}

}

private static String[] buildEchoEnvCommand() {

String os = System.getProperty("os.name").toLowerCase(Locale.ROOT);

if (os.contains("win")) {

// cmd expands %VAR%

return new String[]{"cmd", "/c", "echo %APP_MODE%"};

}

// sh expands $VAR

return new String[]{"sh", "-c", "echo $APP_MODE"};

}

private static String readAll(InputStream in) throws IOException {

try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {

StringBuilder sb = new StringBuilder();

String line;

while ((line = r.readLine()) != null) {

sb.append(line).append("\n");

}

return sb.toString();

}

}

}

A few things I want you to notice:

  • I used the exec(cmd, envp, dir) overload so the process starts in a known directory.
  • For printing an environment variable, I intentionally invoked a shell because %APPMODE% and $APPMODE are shell expansions.
  • envp entries must be exactly NAME=value. If you accidentally include whitespace or forget the =, you’ll get confusing behavior.

If you’re doing more than a couple of variables, ProcessBuilder is less error-prone because it gives you a Map.

Reading output without hangs: the stdout/stderr deadlock pattern

One of the most painful exec() bugs looks like this:

  • Your Java code calls exec().
  • The command clearly runs when you try it in a terminal.
  • Your Java program hangs forever on waitFor().

What happened? Often, the child process wrote enough data to stderr (or stdout) that the OS pipe buffer filled up. Once the buffer is full, the child blocks on write. Meanwhile your Java code is blocked waiting for the process to exit. You have a standoff.

You avoid this by:

  • reading stdout and stderr concurrently (two threads), or
  • calling ProcessBuilder.redirectErrorStream(true) (if you can switch to ProcessBuilder), or
  • redirecting streams to files (best when output is large)

In the first runnable example, I used a tiny thread pool to read both streams at the same time. That pattern is worth keeping.

Encoding matters

If the child process outputs non-ASCII text (accents, CJK characters, emoji, anything beyond plain English), default platform encodings can bite you. If you control the tool, pick UTF-8. If you don’t, detect or configure it.

In practice:

  • For modern CLIs in 2026, UTF-8 is common.
  • On Windows, legacy code pages still appear in enterprise environments.

If your logs look garbled, encoding is the first thing I check.

Timeouts, cancellation, and exit codes you can trust

A process that runs forever is not an edge case. It’s a Tuesday.

Maybe:

  • the external tool is waiting for input
  • it’s stuck on a network call
  • it’s blocked on a file lock
  • it’s prompting interactively because it detected a TTY in some unexpected way

If you don’t set timeouts, you’re letting a child process hold your Java threads hostage.

What I recommend

  • Always use waitFor(timeout, unit) unless the command is guaranteed to finish quickly.
  • If it times out, call destroy() and then destroyForcibly() after a short grace period.
  • Treat “timed out” as a separate failure category from “exit code non-zero.”

Also, check exit codes. A lot of developers read stdout and assume success. I do the opposite: I start with the exit code and only then interpret output.

Process trees and ProcessHandle

Since Java 9, you can inspect process metadata via ProcessHandle and see descendants. That’s valuable when you start a wrapper script that spawns the real worker.

If you terminate only the parent, the child might keep running. When you see “zombie” helper programs in production, that’s a common cause.

A pattern I use:

  • on timeout, destroy descendants first (or at least log them)
  • then destroy the parent

I won’t paste a huge framework here, but even adding simple logging like “PID, command, duration, timed out” will save you hours.

Security: where exec() goes wrong fast, and how I keep it safe

I treat exec() as a privileged boundary. If you cross it casually, you will eventually ship a vulnerability.

Command injection risk

If any part of the command string comes from outside your trust boundary (HTTP request, file content, database field, message queue), and you concatenate it into a shell command, an attacker may execute arbitrary commands.

Bad pattern:

  • "sh -c " + userProvidedText

Safer patterns:

  • avoid shells; pass arguments as a String[]
  • whitelist commands and allowed arguments
  • validate input strictly (for example, allow only digits for an ID, or only a fixed set of subcommands)
  • run the child process with the least privileges possible

Don’t use exec() when a Java API exists

In my experience, many exec() calls are accidental.

Examples:

  • Need to list files? Use java.nio.file.
  • Need to compress a ZIP? Use java.util.zip.
  • Need HTTP calls? Use java.net.http.HttpClient.
  • Need to run SQL migrations? Use a migration library rather than calling psql.

When you use Java APIs, you get portability, better error handling, and fewer security surprises.

Logging and secrets

If you log the full command line, you may log secrets.

Common offenders:

  • --token=...
  • --password=...
  • connection strings

If you must log commands, redact sensitive flags before writing logs. I also avoid passing secrets on the command line when possible; environment variables or stdin can be safer (though not perfect).

When I still choose Runtime.exec() in 2026

Even with ProcessBuilder and better tooling, Runtime.exec() still shows up in real systems.

I choose it when:

  • I’m maintaining legacy code and want a small, low-risk change.
  • The process call is genuinely simple (single executable + a couple of args).
  • The team already has a proven wrapper around it (timeouts, output capture, redaction).

I avoid it when:

  • the call needs complex environment configuration
  • the call needs file redirections
  • the call needs a stable abstraction layer (for tests, for portability, for observability)

In those cases, I switch to ProcessBuilder, or I wrap execution behind an interface so it can be mocked in unit tests.

A practical wrapper pattern I recommend (and how AI-assisted workflows fit)

Most teams end up rewriting the same glue code: run a command, capture output, enforce timeouts, redact secrets, and return a structured result.

In 2026, I often pair a small wrapper with AI-assisted review:

  • I generate the first draft of the wrapper with an assistant.
  • I manually verify: argument separation, timeouts, stream handling, redaction rules.
  • I add a couple of focused tests that simulate a slow process and a noisy stderr.

That workflow is fast, but it still keeps humans in charge of the boundary where security and reliability live.

If you want one actionable step today: centralize process execution in one helper class, even if it’s tiny. Once you have one place to fix hangs, redaction, and telemetry, your future self will thank you.

Key takeaways and next steps

When you call Runtime.getRuntime().exec(), you’re not “running a command” so much as starting a second program and then managing a relationship with it: inputs, outputs, time, and failure modes. I’ve watched teams lose days to issues that came down to three basics: splitting arguments correctly, draining both output streams, and enforcing timeouts.

If you only change one habit, make it this: prefer exec(String[]) over exec(String) and keep each argument as its own array element. That one shift removes a huge class of quoting and parsing problems. Next, treat stdout and stderr as first-class data. Read them concurrently, and always check the exit code before you declare success.

For production services, build a small wrapper that standardizes: timeout behavior, termination, output capture limits, and log redaction. If you’re already on a modern JDK, consider switching new work to ProcessBuilder for clearer environment and directory management. Finally, be deliberate about security: avoid shells unless you control the full command, validate inputs with whitelists, and don’t leak secrets into logs.

If you tell me what kind of external command you’re trying to run (CLI tool, script, container command, vendor binary), I can tailor a safe wrapper and a couple of tests that match your exact scenario.

Scroll to Top