Topic 12.1
Byte Streams and Character Streams
In one line
Java I/O is built on two families: byte streams (InputStream/OutputStream) move raw 8-bit bytes, and character streams (Reader/Writer) move text. A Reader or Writer is a byte stream plus a charset that turns bytes into characters and back.
Think of it like this
A water pipe. Water flows through it one drop after another, in order, and you can't jump back to a drop that has already passed. A stream in Java is the same: data arrives one piece after another from a source (a file, a network socket, an array in memory) or leaves one piece after another to a destination. You can add attachments to a pipe, like a filter or a tank that collects water so you don't open the tap for every drop. Java lets you wrap one stream in another in exactly that way.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Stream (I/O)
- A one-way flow of data, read or written one piece after another, from a source or to a destination. Not the same thing as the Java 8
StreamAPI for collections (Topic 10.4). - Byte
- The smallest unit computers store and send: 8 bits, a number from 0 to 255. Java's
bytetype stores it as -128 to 127. - End of stream
- The point where a source has no more data.
read()signals it by returning -1. - Charset (encoding)
- A rulebook that maps characters to bytes and back, such as UTF-8 or ISO-8859-1. The same text has different bytes in different charsets.
- UTF-8
- The most common charset on the web. It uses 1 byte for English letters and 2 to 4 bytes for other characters.
- Character stream
- A
ReaderorWriter: a stream that movescharvalues (text) instead of raw bytes, translating with a charset. - Decorator
- An object that wraps another object of the same kind and adds a feature, while passing the real work through to the one inside.
- File handle
- A ticket the operating system gives a program for an open file. There is a limited number, so they must be given back by closing the stream.
- try-with-resources
- A
try (...)block that closes the objects declared in its parentheses automatically when the block ends, even after an exception.
Step by step
01The two families
java.io has four abstract base classes. Bytes: InputStream and OutputStream. Characters: Reader and Writer. Every concrete class extends one of them, and its name usually tells you which: anything ending in Stream moves bytes, anything ending in Reader or Writer moves characters.
Each family has sources and sinks (where data really comes from or goes to: files, arrays, sockets) and decorators (which wrap another stream and add a feature).
02read() returns an int, and -1 means the end
InputStream.read() returns the next byte as an int from 0 to 255. A byte variable can only hold -128 to 127, so the byte with all eight bits set (0xFF) would be -1 as a byte, and you couldn't tell it apart from "no more data". Returning int gives 257 possible results: 256 byte values plus -1.
The standard loop assigns and tests in one expression: while ((b = in.read()) != -1). The inner parentheses matter: without them, b would get the boolean result of the comparison.
InputStream in = new ByteArrayInputStream(new byte[] {72, 105, -1});
int b;
while ((b = in.read()) != -1) {
System.out.println(b); // 72, 105, then 255 (not -1!)
}03Reading in chunks
Reading one byte per call is slow on a real file, because each call can reach the operating system. int read(byte[] buf) fills as much of an array as it can and returns how many bytes it read, or -1 at the end.
The count can be less than the array length even before the end, for example when a network packet is small. Always use the returned count, never buf.length, when you process the chunk. In Java 9+ in.transferTo(out) writes this loop for you.
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n); // only the n bytes that were really read
}
// Java 9+: the same loop in one call
long copied = in.transferTo(out);04Characters need a charset
When you write text to a byte stream, something must choose the bytes. "é".getBytes(StandardCharsets.UTF_8) gives two bytes, C3 A9; in ISO-8859-1 it is one byte, E9. If a program writes with one charset and reads with another, you get mojibake: garbled text such as é instead of é.
InputStreamReader and OutputStreamWriter are the bridges between the families. They take a byte stream and a charset. The constructor without a charset uses the default charset, which was platform-dependent before Java 18, so always pass one.
Reader reader = new InputStreamReader(byteStream, StandardCharsets.UTF_8);
Writer writer = new OutputStreamWriter(byteSink, StandardCharsets.UTF_8);05Decorators stack
Each wrapper adds one job. DataOutputStream turns writeInt(42) into the 4 bytes 00 00 00 2A (big-endian, most significant byte first); BufferedOutputStream collects bytes in an 8192-byte array and passes them on in big chunks; FileOutputStream actually writes to the file.
You talk only to the outermost object. Each call passes inward. When you close the outermost one, it flushes its buffer and closes the one inside, all the way down.
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(
new FileOutputStream("scores.bin")))) {
out.writeInt(42); // DataOutputStream: int -> 4 bytes
out.writeUTF("Asha"); // 2-byte length + modified UTF-8
} // close: flush buffer, then close the file06Closing with try-with-resources
Anything that implements AutoCloseable (and Closeable, its I/O sub-interface) can go in the parentheses of try (...). When the block ends, normally or by an exception, Java calls close() on each resource in reverse order of declaration.
If the body throws and close() also throws, the body's exception wins and the close exception is attached to it as a suppressed exception (e.getSuppressed()). Before Java 7, a finally { in.close(); } that threw would silently replace the real error.
Since Java 9 you can name an existing effectively final variable instead of declaring one: try (in) { ... }.
try (InputStream in = new FileInputStream("a.txt");
OutputStream out = new FileOutputStream("b.txt")) {
in.transferTo(out);
} // out.close() runs first, then in.close()07What the JVM really does on a read
FileInputStream.read() is a native method: each call crosses from Java into C code and usually into the operating system kernel with a read system call. That crossing costs far more than reading a byte from memory, which is why unbuffered byte-at-a-time file reading is famously slow (Topic 12.2 measures it).
ByteArrayInputStream and ByteArrayOutputStream never touch the OS: they read from and grow a byte[] on the heap. They are perfect for tests and for building a payload in memory, and their close() does nothing. System.out is a PrintStream, a byte-stream decorator that converts text using the console's charset.
Try it yourself
- 1
Predict the bytes
In "Characters are not bytes", change the text to
"A\u00df"(A and the German sharp s). Predict the char count, the UTF-8 byte count and the hex before you run it. - 2
Make the flush visible
In the bridges example, write a 10,000-character string (
"x".repeat(10000)) before checkingsink.size(). Predict whether the size beforeflush()is still 0. (Hint: the writer's internal buffer is 8192 bytes.) - 3
Read in the wrong order
In the decorator example, swap
readInt()andreadDouble(). Predict whether it throws or prints nonsense, then run it and explain the numbers you see.
Code & diagrams
The byte -1 in the array comes back as 255: read() always returns 0 to 255, and -1 only at the end.
Expected output
read() returned 72
read() returned 105
read() returned 255
read() returned 0
at the end, read() returns -1
written: [72, 105, 33]
as text: Hi!The e-acute takes 2 bytes (C3 A9) and the rupee sign 3 (E2 82 B9). Decoding with the wrong charset turns each byte into its own character.
Expected output
chars: 7
UTF-8 bytes: 10
UTF-16 bytes: 14
UTF-8 hex: 63 61 66 C3 A9 20 E2 82 B9 35
decoded as ISO-8859-1: 10 chars (garbled)
decoded as UTF-8 equals the original: trueOutputStreamWriter keeps encoded bytes in its own buffer. Nothing reaches the byte stream until flush() or close().
Expected output
bytes in the source: 10
char 1: U+006E
char 2: U+0061
char 3: U+00EF
char 4: U+0076
char 5: U+0065
char 6: U+0020
char 7: U+20B9
chars decoded: 7
bytes in sink before flush: 0
bytes in sink after flush: 6Binary formats must be read back in exactly the order and types they were written. Nothing in the bytes says "this is an int".
Expected output
bytes written: 17
first four: 00 00 00 2A
int: 42
double: 2.5
utf: Hi
boolean: true
EOFException: asked for 4 more bytes, none leftExpected output
body finished
closing B
closing A
-- wrapped --
closing outer
closing inner
sink holds: xyzBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Ignore the checked IOException
Call in.read() inside a main that doesn't declare throws IOException and has no try/catch.
Break #2
Store read() in a byte
Write the loop as byte b; while ((b = (byte) in.read()) != -1) { ... } and read the bytes {72, 105, -1, 0}.
Break #3
Forget to flush a writer
In the bridges example, delete writer.flush() and writer.close(), and print sink.size() at the end.
Myth vs fact
Myth
A byte stream and a character stream are interchangeable for text.
Fact
Text in bytes is meaningless without a charset. Byte streams copy bytes faithfully; character streams decode them. Copying a file? Use bytes. Reading words? Use a Reader with an explicit charset.
Myth
read() returns a byte.
Fact
It returns an int: 0 to 255 for data and -1 for the end of the stream. Storing it in a byte first breaks on the value 255.
Myth
The garbage collector will close my streams.
Fact
It may never run in time, and finalizers were deprecated in Java 9 and are being removed. Open files and sockets leak until you call close(). Use try-with-resources.
Myth
String.getBytes() with no argument is safe.
Fact
It uses the default charset. Before Java 18 that depended on the operating system, so the same code produced different bytes on Windows and Linux. Always pass StandardCharsets.UTF_8.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
InputStream.read(byte[], int, int)is allowed to return fewer bytes than asked for, even 0 bytes is only legal whenlenis 0. Code that assumes a full buffer is a classic bug with sockets. UsereadNBytes(buf, 0, len)(Java 9) orDataInputStream.readFullywhen you need exactlylenbytes. - ▸
DataOutputStream.writeUTFdoesn't write standard UTF-8: it writes modified UTF-8 (the same format as class-file constant pools), where the null character is two bytesC0 80and characters outside the Basic Multilingual Plane become two 3-byte surrogates. It is limited to 65,535 encoded bytes and throwsUTFDataFormatExceptionabove that. Don't use it for interchange with non-Java systems. - ▸
FileInputStreamandFileOutputStreamhistorically hadfinalize()methods to close leaked handles. They were removed in Java 18 in favour of aCleaner, which is still only a safety net: it runs at an unpredictable time after GC. A process can hit the OS limit (ulimit -n, often 1024) long before then, failing withToo many open files. - ▸
PrintStream(andPrintWriter) swallowIOExceptions: methods likeprintlnnever throw. You must callcheckError()to find out a write failed. That's whySystem.out.printlndoesn't need atry, and why you shouldn't usePrintStreamfor data you can't afford to lose.
Remember this
- 1
A byte stream moves raw bytes, numbers from 0 to 255. Every byte input source extends the abstract class
java.io.InputStream, whose core method isint read(). It returns the next byte as anintfrom 0 to 255, or -1 when there is no more data (the end of stream). It returns anint, not abyte, precisely so that -1 can mean "finished" without clashing with the byte value 255. Every byte destination extendsOutputStream, whose core method isvoid write(int b); it writes only the low 8 bits of theintand ignores the rest. - 2
Text is not bytes. A Java
charis a 16-bit UTF-16 code unit, and turning characters into bytes needs a charset (an encoding) such as UTF-8. In UTF-8,ais one byte,éis two and₹is three (Topic 6.8 covers Unicode in depth). A character stream does this translation for you:Reader(withint read()returning a char from 0 to 65535, or -1) andWriter. The bridges areInputStreamReader(bytes in, chars out) andOutputStreamWriter(chars in, bytes out). Always pass the charset explicitly, for exampleStandardCharsets.UTF_8. Before Java 18 the default charset was the operating system's (oftenwindows-1252on Windows), so the same program read the same file differently on different machines; since Java 18 (JEP 400) the default is UTF-8. - 3
Most classes in
java.ioare decorators: they take another stream in their constructor and add one feature.BufferedInputStreamadds a buffer,DataInputStreamaddsreadInt()andreadDouble(),InputStreamReaderadds decoding. You stack them:new DataInputStream(new BufferedInputStream(new FileInputStream("x.bin"))). Only the innermost one touches the real source. This is the Decorator pattern from the System Design course, andjava.iois its most famous example. - 4
Streams hold operating system resources such as file handles and sockets, and the garbage collector does not release them on time. Always close them, and close them with try-with-resources (Java 7, Topic 7.6):
try (InputStream in = ...) { ... }. The resource is closed automatically when the block ends, even if an exception is thrown, and resources are closed in the reverse order they were opened. Closing the outermost decorator closes everything inside it. - 5
Almost every I/O method declares
throws IOException, a checked exception (Topic 7.3), because disks fill up, files vanish and networks drop. The compiler forces you to catch it or declare it. Useful subclasses:FileNotFoundException,EOFException("I expected more bytes", thrown byDataInputStream.readInt()at the end), andUncheckedIOException(Java 8), which wraps anIOExceptionwhere a lambda can't throw checked exceptions. - 6
Modern helpers save loops:
InputStream.readAllBytes()andtransferTo(OutputStream)arrived in Java 9,readNBytes(int)in Java 11, andInputStream.nullInputStream()/OutputStream.nullOutputStream()in Java 11. For files, prefer theFilesclass of Topic 12.3, which builds on these same streams.
Explain it without notes
What's the difference between a byte stream and a character stream, and when do you use each?
Why does InputStream.read() return an int instead of a byte?
How do InputStreamReader and OutputStreamWriter fit in, and why should you always pass a charset?
Explain the Decorator pattern in java.io with an example.
What guarantees does try-with-resources give, and what happens if both the body and close() throw?
Practice
Write static long countBytes(InputStream in) that reads in 4-byte chunks and returns the total number of bytes, then test it on a ByteArrayInputStream of 10 bytes. Print how many chunks were read too.
Encode the word "r\u00e9sum\u00e9" (resume with two accents) to UTF-8 and ISO-8859-1, and print both byte counts and whether decoding the UTF-8 bytes as ISO-8859-1 gives back the same string.
Use DataOutputStream to write three (String name, int score) pairs into a ByteArrayOutputStream, then read them back with DataInputStream until EOFException and print each pair.
Trade-offs
- ↔
Byte streams are exact and fast for copying, but give you no help with text; character streams handle text correctly but must know the charset, and decoding costs CPU. Choose by what the data is, not by which class is shorter to type.
- ↔
Small decorators combine freely, but a deep stack is harder to debug and every layer adds a method call. In practice one buffer layer is enough; buffering twice wastes memory and copies.
- ↔
java.iostreams are blocking: a thread that callsread()waits until data arrives. That's simple and, with virtual threads (Java 21, Topic 13.10), scales well; NIO channels and selectors avoid blocking but are much harder to program.
Done when you can
Done when you can name the four abstract base classes and say which family a class belongs to from its name.
Done when you can explain why
read()returns anintand write the correct read loop.Done when you always pass an explicit charset and can explain mojibake.
Done when you can build a decorator stack and say what each layer does.
Done when you close every stream with try-with-resources and know the close order.