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
- 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 signedbytevalue 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
- 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, whetherread()’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
BufferedOutputStreamto 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
BufferedOutputStreamis written to, without callingflush, the content of the stream below it is looked at directly. In the second measurement, the same steps are completed withflush.
// 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.