Skip to content
academia.sh

Lesson 11 / 15

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.

Contents

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

  • IO1OutputStream.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.
// 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

  • IO3InputStream.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.
// 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.
// 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(); }
}
// 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.
// 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.
// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close