EMZETT.
Login

I/O Streams

In short: The basic principle of Java’s input/output API: data flows as a continuous stream between a source (file, network, console) and the program.

In more detail: Java distinguishes byte streams (InputStream/OutputStream, for binary data) and character streams (Reader/Writer, for text data with character encoding). Streams can be chained (“wrapping”): a BufferedReader, for example, wraps a FileReader to add buffering, without changing the base class itself.

In Depth

Byte streams (binary data)          Character streams (text)
InputStream  ──wraps──▶  BufferedInputStream    Reader ──wraps──▶ BufferedReader
    │                                              │
FileInputStream                              FileReader / InputStreamReader

The “wrapping” principle (decorator pattern) is the central building block of the entire stream API: a base class handles the actual source (file, network socket, console), and additional wrapper classes add functionality without changing the base class itself:

// Character stream: file -> character encoding -> buffering
try (BufferedReader br = new BufferedReader(
        new InputStreamReader(new FileInputStream("text.txt"), StandardCharsets.UTF_8))) {
    String line = br.readLine();
}

InputStreamReader is itself already a wrapper that translates a raw byte stream (InputStream) into a character stream (Reader) by applying the specified character encoding — this is exactly where it’s decided whether, say, umlauts are read correctly or appear as broken characters. System.in/System.out are themselves already ready-made byte streams for console input/output, which is why Scanner (for user input) also internally relies on this wrapping principle.

Why streams at all instead of “the whole file at once”?

The core of the stream concept: data doesn’t have to be completely in memory before processing begins. A 10 GB log file can be read line by line without ever holding more than one line in memory at a time — with Files.readAllBytes()/Files.readString() (convenient for small files), by contrast, the entire file would have to fit into memory at once. This trade-off between convenience (everything at once, one line of code) and memory efficiency (stream-based, more code) runs throughout the entire I/O API.

NIO.2 as a modern alternative

Since Java 7, the java.nio.file package (colloquially “NIO.2”, to distinguish it from the older java.nio) offers a significantly more compact API for many standard cases with Files and Path, without replacing the classic stream classes:

// Classic with streams
try (BufferedReader br = new BufferedReader(new FileReader("small.txt"))) {
    String content = br.lines().collect(Collectors.joining("\n"));
}
 
// Equivalent with NIO.2 (only for files that fit entirely into memory)
String content = Files.readString(Path.of("small.txt"));

Internally, NIO.2 also relies on the same stream classes again for large files (Files.newBufferedReader(), for example, returns a completely normal BufferedReader) — the two APIs complement each other rather than replacing one another.

System.in, System.out, System.err as predefined streams

Three standard streams are available in every Java program with no need to open them yourself, managed by the operating system/JVM:

System.out.println("Normal output"); // standard output (stdout)
System.err.println("Error output");   // standard error output (stderr) - separate channel
Scanner scanner = new Scanner(System.in); // standard input (stdin)

The separation between System.out and System.err allows both to be redirected independently of each other when running (e.g. java Program > log.txt 2> error.txt) — normal output lands in one file, error messages in another, without the Java code itself noticing anything about it.

See also: FileInputStream, FileOutputStream, BufferedReader, Files