---
title: 'Stream-Based Input/Output'
source: 'https://academia.sh/en/courses/java-standard-library/stream-based-io'
course: 'The Standard Library and Streams'
language: en
updated: '2026-08-17T18:09:43+00:00'
license: 'CC BY-SA 4.0'
---

# Stream-Based Input/Output

It is measured that the byte stream promises nothing for write(int) beyond the low eight bits, that the character stream leans on an encoding, and that buffering changes only the call count reaching the stream below it, not the promise. The bounding measurement shows an unflushed buffer leaves data incomplete.

The previous course's last lesson carried absence into a type: when `Optional` was empty, a
read still fell at runtime — the check did not disappear, only its location changed. This
topic asks the same question of the **outside world**. When reading a byte array or a piece of
text, what promise does the library give you, and can you tell, from the call's spelling,
who is carrying that promise — the interface, the class you chose, or you?

This lesson compares two separate families: the byte stream and the character stream. Both
are `java.io` input/output streams; Java's `Stream` data-stream API is a separate API and will
always be referred to with a qualifier throughout this topic. The question is always asked the
same way: if a behavior shows up in two separate implementations, it is the interface's
promise; if it shows up in only one, it belongs to the chosen class; if it never shows up at
all, yet the code still works, it is a rule the caller is following.

Five sections proceed in order. The first two measure what is guaranteed at the byte level —
in writing and in reading. The third shows that buffering never breaks these two promises, it
only changes the call count. The fourth measures the decision the character stream's one
addition over the byte stream — the encoding — places on the caller. The fifth measures a rule
no class gives at all, one only the caller must follow: flushing a buffer.

## The One Promise the Byte Stream Gives

- **IO1** — `OutputStream.write(int)` takes an integer but writes only its low eight bits; the
  remaining twenty-four are discarded. This measurement checks whether two separate
  implementations write the same value and produce the same low eight bits.
- **IO2** — The written byte, when read back, is converted to an unsigned integer with
  `& 0xFF`; the signed `byte` value is never compared directly.

```java
// OnePromise.java — the one promise the byte stream's write(int) gives: the low eight bits
import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;

public class OnePromise {
    public static void main(String[] args) throws Exception {
        int value = 321;
        int expected = value & 0xFF;
        System.out.printf("%-24s%-10s%s%n", "implementation", "written", "same as expected");

        ByteArrayOutputStream array = new ByteArrayOutputStream();
        array.write(value);
        int arrayResult = array.toByteArray()[0] & 0xFF;
        System.out.printf("%-24s%-10d%s%n", "ByteArrayOutputStream", arrayResult, arrayResult == expected);

        Path file = Path.of("one-promise.bin");
        try (OutputStream fileStream = new FileOutputStream(file.toFile())) {
            fileStream.write(value);
        }
        int fileResult = Files.readAllBytes(file)[0] & 0xFF;
        System.out.printf("%-24s%-10d%s%n", "FileOutputStream", fileResult, fileResult == expected);
        Files.delete(file);
    }
}
```

```
implementation          written   same as expected
ByteArrayOutputStream   65        true
FileOutputStream        65        true
```

The written value is 321; in binary, its low eight bits are `01000001`, that is, 65. An
implementation holding data in memory and one writing to a file, with no shared line of code
between them, produce the same low eight bits. This is a promise the `OutputStream` abstract
class documents — the choice of implementation changes nothing here, because the behavior is
already defined in the superclass itself.

The promise the byte stream gives is exactly this much: to carry an eight-bit unit as is. What
the value means — a character, a number, part of an image — is something the stream never
knows at all. A byte stream gives no promise of **interpretation**; it only carries units.
`OutputStream` is not a Java `interface`, it is an abstract class, but that does not change
the question this measure asks: if a behavior is defined in the supertype, that type is
speaking like an interface — every subclass speaking through it has to carry the promise
exactly as is.

## The Same Promise at the Reading End

- **IO3** — `InputStream.read()` is a promise too: the byte read comes back as an unsigned
  integer, between 0 and 255; once the input/output stream ends, the value returned is not a
  byte, it is a marker outside the stream: **-1**.
- **IO4** — The measured array carries three bytes, and the third is a value that would come
  out negative if read as a signed `byte` (255). Here, whether `read()`'s conversion to
  unsigned carries this value correctly too is seen.

```java
// Reading.java — the one promise for read(): a value 0-255, then -1 at the end
import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class Reading {
    static List<Integer> readAll(InputStream in) throws Exception {
        List<Integer> values = new ArrayList<>();
        int b;
        while ((b = in.read()) != -1) values.add(b);
        values.add(in.read());
        return values;
    }

    public static void main(String[] args) throws Exception {
        byte[] data = { 10, (byte) 200, (byte) 255 };

        List<Integer> arrayResult = readAll(new ByteArrayInputStream(data));

        Path file = Path.of("reading.bin");
        Files.write(file, data);
        List<Integer> fileResult;
        try (InputStream fileStream = new FileInputStream(file.toFile())) {
            fileResult = readAll(fileStream);
        }
        Files.delete(file);

        System.out.printf("%-24s%s%n", "implementation", "values read (last two: last byte, end of stream)");
        System.out.printf("%-24s%s%n", "ByteArrayInputStream", arrayResult);
        System.out.printf("%-24s%s%n", "FileInputStream", fileResult);
        System.out.println("are the two lists the same: " + arrayResult.equals(fileResult));
    }
}
```

```
implementation          values read (last two: last byte, end of stream)
ByteArrayInputStream    [10, 200, 255, -1]
FileInputStream         [10, 200, 255, -1]
are the two lists the same: true
```

Of the three bytes, the third one, if read as a signed `byte`, would come out **-1**;
`read()` converts it to an unsigned value and returns **255**. Both the in-memory array and
the file give the same three values, followed by the same marker. This `-1` is not a byte that
was read — no real byte can carry this value, because a byte converted to unsigned is always
between 0 and 255. `-1` is a marker sitting outside the data space, telling that the stream has
**ended**.

Preserving the low eight bits on write, and converting to unsigned plus an end-of-stream marker
on read, together set up the byte stream's entire contract. Both are independent of
implementation, because both are defined in `InputStream` and `OutputStream` themselves; a
subclass cannot change this behavior, it can only choose *how* it provides it — in memory, or
on disk.

## Buffering Changes the Call Count, Not the Promise

- **IO5** — A wrapper is written that counts every write call reaching the stream below it;
  this wrapper only keeps a counter, it does not change the data.
- **IO6** — The same five pieces are written first directly to this wrapper, then through a
  `BufferedOutputStream` to the same wrapper. Two separate things are compared: the number of
  calls reaching the stream below, and the content that stream collects.

```java
// CountingStream.java — a wrapper counting write calls reaching the stream below it (helper class, no main)
import java.io.IOException;
import java.io.OutputStream;

class CountingStream extends OutputStream {
    private final OutputStream under;
    int callCount = 0;

    CountingStream(OutputStream under) { this.under = under; }

    @Override public void write(int b) throws IOException {
        callCount++;
        under.write(b);
    }

    @Override public void write(byte[] data, int off, int len) throws IOException {
        callCount++;
        under.write(data, off, len);
    }

    @Override public void flush() throws IOException { under.flush(); }
    @Override public void close() throws IOException { under.close(); }
}
```

```java
// Buffering.java — buffering changes the call count reaching the stream below it, not the promise
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.util.List;

public class Buffering {
    static final List<byte[]> PIECES = List.of(
            "data".getBytes(), "-".getBytes(), "entry".getBytes(),
            "-".getBytes(), "01".getBytes());

    public static void main(String[] args) throws Exception {
        ByteArrayOutputStream directUnder = new ByteArrayOutputStream();
        CountingStream directCounter = new CountingStream(directUnder);
        for (byte[] piece : PIECES) directCounter.write(piece, 0, piece.length);
        directCounter.close();

        ByteArrayOutputStream bufferedUnder = new ByteArrayOutputStream();
        CountingStream bufferedCounter = new CountingStream(bufferedUnder);
        try (BufferedOutputStream buffered = new BufferedOutputStream(bufferedCounter)) {
            for (byte[] piece : PIECES) buffered.write(piece, 0, piece.length);
        }

        System.out.printf("%-14s%-18s%s%n", "write form", "calls to under", "content delivered");
        System.out.printf("%-14s%-18d%s%n", "direct", directCounter.callCount,
                directUnder.toString());
        System.out.printf("%-14s%-18d%s%n", "buffered", bufferedCounter.callCount,
                bufferedUnder.toString());
        System.out.println("content same: " + directUnder.toString().equals(bufferedUnder.toString()));
    }
}
```

```
write form    calls to under    content delivered
direct        5                 data-entry-01
buffered      1                 data-entry-01
content same: true
```

In the direct write, the five pieces reach the counting stream as five separate calls: every
`write` lands on the stream below immediately. In the buffered write, the same five pieces
reach the counting stream as **one** call, because `BufferedOutputStream` collects the pieces
in its own memory and calls the stream below only when it empties. Both writes deliver
byte-for-byte identical content.

This measurement separates what buffering changes from what it does not. `OutputStream`'s
promise reads "the bytes you write will eventually reach the stream below"; it does not read
"every `write` call will touch the stream below instantly." Buffering never violates this
second sentence, because that sentence was never part of the promise to begin with. What
changes is only a number the caller cannot see: how many calls reach the stream below.

This is why buffering is a choice, not a guarantee. If the stream below is genuinely expensive
— a file, a network connection — five separate calls can turn into five separate system calls;
once a buffer steps in, the same job comes down to one call. Since the choice belongs to the
caller, `BufferedOutputStream` never flushes on its own: only the caller decides when to fill
the buffer and when to flush it early. This lesson's last section's bounding measurement shows
exactly the case where this decision gets skipped.

## The Character Stream Leaning on an Encoding

- **IO7** — A piece of text is converted to bytes with a single encoding and written. The same
  byte array is read back with two separate encodings: the one it was written with, and
  another.
- **IO8** — Three things are compared: byte count (stays fixed, because the source is the
  same), character count, and whether the text read equals the text written. Not the text
  itself, only length and equality are printed.

```java
// Encoding.java — the character stream's promise depends on an encoding, the byte stream's does not
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public class Encoding {
    public static void main(String[] args) throws Exception {
        String text = "ağırlık";
        Path file = Path.of("encoding.bin");

        byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
        Files.write(file, bytes);

        String sameEncoding = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
        String otherEncoding = new String(Files.readAllBytes(file), StandardCharsets.ISO_8859_1);

        System.out.printf("%-29s%-12s%-16s%s%n", "read encoding", "byte count", "char count", "same as written");
        System.out.printf("%-29s%-12d%-16d%s%n", "UTF_8 (as written)", bytes.length,
                sameEncoding.length(), sameEncoding.equals(text));
        System.out.printf("%-29s%-12d%-16d%s%n", "ISO_8859_1 (other encoding)", bytes.length,
                otherEncoding.length(), otherEncoding.equals(text));

        Files.delete(file);
    }
}
```

```
read encoding                byte count  char count      same as written
UTF_8 (as written)           10          7               true
ISO_8859_1 (other encoding)  10          10              false
```

A seven-letter piece of text produces ten bytes in UTF-8, because two of its letters are shown
with two bytes each in this encoding. The same ten bytes, read back with UTF-8, turn into seven
characters and match what was written exactly. The same ten bytes, read with ISO-8859-1, have
each byte counted as its own character, giving ten characters — not matching the text that was
written.

This measurement really does break a rule of the caller's: that the write encoding and the
read encoding be the same. The break's consequence is neither an exception nor a warning; it is
a **silent** result. The program runs without error, produces a string, and that string is
wrong. It was established in the previous section that the byte stream gives no promise of
interpretation at all; the character stream fills exactly that gap — but the encoding filling
it has to be given by **you**. When no encoding is given, the compiler falls back to a default,
and the decision is left to the environment — this is exactly why this lesson gives the
encoding explicitly on every read and write: the table above is exactly why, when the two sides
use different encodings, the error never stops anywhere, only the number changes.

Where this burden is carried is itself a measurable choice. `OutputStreamWriter` and
`InputStreamReader` — the two classes converting a byte stream into a character stream — ask
for a `Charset` parameter in their constructors; `Files.write` and `Files.readAllBytes`, in
turn, are completely unaware of encoding, because both carry only bytes and leave the encoding
decision to the caller, as above, at `getBytes` and the `String` constructor. Whichever layer
it is given at, the decision is not part of the promise; every read and every write has to
carry its own encoding explicitly.

## The Unflushed Buffer

- **IO9** — Right after a `BufferedOutputStream` is written to, without calling `flush`, the
  content of the stream below it is looked at directly. In the second measurement, the same
  steps are completed with `flush`.

```java
// Flushing.java — an unflushed buffer does not carry the data; flushing is the caller's decision
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;

public class Flushing {
    public static void main(String[] args) throws Exception {
        ByteArrayOutputStream underNoFlush = new ByteArrayOutputStream();
        BufferedOutputStream bufferedNoFlush = new BufferedOutputStream(underNoFlush);
        bufferedNoFlush.write("will not be delivered".getBytes());

        ByteArrayOutputStream underFlushed = new ByteArrayOutputStream();
        BufferedOutputStream bufferedFlushed = new BufferedOutputStream(underFlushed);
        bufferedFlushed.write("will be delivered".getBytes());
        bufferedFlushed.flush();

        System.out.printf("%-26s%-13s%s%n", "measurement", "under length", "data in under stream");
        System.out.printf("%-26s%-13d%s%n", "before flush called",
                underNoFlush.size(), underNoFlush.size() > 0);
        System.out.printf("%-26s%-13d%s%n", "after flush called",
                underFlushed.size(), underFlushed.size() > 0);
    }
}
```

```
measurement               under length data in under stream
before flush called       0            false
after flush called        17           true
```

Without `flush` called, the stream below has zero length: the twenty-two-byte text is still
waiting in `BufferedOutputStream`'s own memory, and the stream below has never been touched.
Once `flush` is called, the same mechanism delivers its seventeen bytes in full. The difference
is not in the code's correctness, it is in **whether a call was made** — and making that call
is entirely the caller's decision.

Try-with-resources (Object-Oriented Java, `java-generics/04`) automates this call: if a
`BufferedOutputStream` is opened inside `try (...)`, `close` runs when the block exits, and
`close` calls `flush` internally. But for this automation to kick in, the buffered stream has
to be **declared as a resource**; in the first measurement above, `bufferedNoFlush` never
entered any `try (...)` block, so closing never ran and the data was lost. Try-with-resources
prevents the loss, but writing the line that prevents it is still left to the caller.

Some classes take this decision onto themselves: one of `PrintWriter`'s constructors asks, with
a `boolean` parameter, whether it should auto-flush at the end of every line.
`BufferedOutputStream` has no such option — it always stays manual. Even this difference
confirms the same rule: auto-flushing is a behavior a class *chooses* to have, not an interface
promise **every** input/output stream class follows. Which class you choose also decides
whether you are leaving the flushing decision to yourself, or handing it to the class.

## Summary

- The only promise the byte stream gives for `write(int)` is to carry the low eight bits; this
  promise comes out the same across different implementations, because it is defined in the
  superclass itself.
- Buffering changes the call count reaching the stream below, not the content delivered: five
  calls come down to one, but the result stays byte-for-byte identical.
- The character stream leans on an encoding; the byte stream gives no promise of
  interpretation. When no encoding is given, the decision falls to the environment.
- When the write and read encodings come apart, the error never stops anywhere: a
  seven-character piece of text silently turns into ten characters.
- Bounding measurement: an unflushed buffer leaves data incomplete. Try-with-resources
  prevents this loss, but declaring the buffered stream as a resource is the caller's decision.

## Next Step

Everything measured in this lesson concerned content passing through a stream: which byte was
written, which character was read. But there is one more question before a stream is even
opened: does the thing at the stream's end really exist, and what does the path leading to it
say? The next lesson looks at the file system API — it measures that a path is itself a parsed
value, which operations on that value answer without ever touching the file system, and which
ones really reach down to disk.
