Command Palette

Search for a command to run...

PHASE 12Intermediate ~34 min· topic 1 of 5

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 Stream API for collections (Topic 10.4).
Byte
The smallest unit computers store and send: 8 bits, a number from 0 to 255. Java's byte type 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 Reader or Writer: a stream that moves char values (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).

The two familiesdiagram
Rendering diagram…

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.

Main.javawhole filejava
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.

Copy.javawhole filejava
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.

Main.javawhole filejava
Reader reader = new InputStreamReader(byteStream, StandardCharsets.UTF_8);
Writer writer = new OutputStreamWriter(byteSink, StandardCharsets.UTF_8);
Characters need a charsetdiagram
Rendering diagram…

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.

Save.javawhole filejava
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 file
Decorators stackdiagram
Rendering diagram…

06Closing 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) { ... }.

Main.javawhole filejava
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. 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. 2

    Make the flush visible

    In the bridges example, write a 10,000-character string ("x".repeat(10000)) before checking sink.size(). Predict whether the size before flush() is still 0. (Hint: the writer's internal buffer is 8192 bytes.)

  3. 3

    Read in the wrong order

    In the decorator example, swap readInt() and readDouble(). Predict whether it throws or prints nonsense, then run it and explain the numbers you see.

Code & diagrams

Bytes in, bytes out New tab

The byte -1 in the array comes back as 255: read() always returns 0 to 255, and -1 only at the end.

Sign in to run this example in your browser.

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!
Characters are not bytes New tab

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.

Sign in to run this example in your browser.

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: true
Bridges: InputStreamReader and OutputStreamWriter New tab

OutputStreamWriter keeps encoded bytes in its own buffer. Nothing reaches the byte stream until flush() or close().

Sign in to run this example in your browser.

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:  6
Decorators: DataOutputStream and DataInputStream New tab

Binary formats must be read back in exactly the order and types they were written. Nothing in the bytes says "this is an int".

Sign in to run this example in your browser.

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 left
try-with-resources closes in reverse order Java 7+ New tab
Sign in to run this example in your browser.

Expected output

body finished
closing B
closing A
-- wrapped --
closing outer
closing inner
sink holds: xyz

Break 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.

terminal
$ javac Main.java
── what you'll see ──
Main.java:6: error: unreported exception IOException; must be caught or declared to be thrown
int b = in.read();
^
1 error

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}.

terminal
$ java Main.java
── what you'll see ──
72
105

Break #3

Forget to flush a writer

In the bridges example, delete writer.flush() and writer.close(), and print sink.size() at the end.

terminal
$ java Main.java
── what you'll see ──
bytes in sink before flush: 0
bytes in sink after flush: 0

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 when len is 0. Code that assumes a full buffer is a classic bug with sockets. Use readNBytes(buf, 0, len) (Java 9) or DataInputStream.readFully when you need exactly len bytes.

  • ▸

    DataOutputStream.writeUTF doesn't write standard UTF-8: it writes modified UTF-8 (the same format as class-file constant pools), where the null character is two bytes C0 80 and characters outside the Basic Multilingual Plane become two 3-byte surrogates. It is limited to 65,535 encoded bytes and throws UTFDataFormatException above that. Don't use it for interchange with non-Java systems.

  • ▸

    FileInputStream and FileOutputStream historically had finalize() methods to close leaked handles. They were removed in Java 18 in favour of a Cleaner, 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 with Too many open files.

  • ▸

    PrintStream (and PrintWriter) swallow IOExceptions: methods like println never throw. You must call checkError() to find out a write failed. That's why System.out.println doesn't need a try, and why you shouldn't use PrintStream for data you can't afford to lose.

Remember this

  1. 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 is int read(). It returns the next byte as an int from 0 to 255, or -1 when there is no more data (the end of stream). It returns an int, not a byte, precisely so that -1 can mean "finished" without clashing with the byte value 255. Every byte destination extends OutputStream, whose core method is void write(int b); it writes only the low 8 bits of the int and ignores the rest.

  2. 2

    Text is not bytes. A Java char is a 16-bit UTF-16 code unit, and turning characters into bytes needs a charset (an encoding) such as UTF-8. In UTF-8, a is one byte, é is two and ₹ is three (Topic 6.8 covers Unicode in depth). A character stream does this translation for you: Reader (with int read() returning a char from 0 to 65535, or -1) and Writer. The bridges are InputStreamReader (bytes in, chars out) and OutputStreamWriter (chars in, bytes out). Always pass the charset explicitly, for example StandardCharsets.UTF_8. Before Java 18 the default charset was the operating system's (often windows-1252 on Windows), so the same program read the same file differently on different machines; since Java 18 (JEP 400) the default is UTF-8.

  3. 3

    Most classes in java.io are decorators: they take another stream in their constructor and add one feature. BufferedInputStream adds a buffer, DataInputStream adds readInt() and readDouble(), InputStreamReader adds 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, and java.io is its most famous example.

  4. 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. 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 by DataInputStream.readInt() at the end), and UncheckedIOException (Java 8), which wraps an IOException where a lambda can't throw checked exceptions.

  6. 6

    Modern helpers save loops: InputStream.readAllBytes() and transferTo(OutputStream) arrived in Java 9, readNBytes(int) in Java 11, and InputStream.nullInputStream()/OutputStream.nullOutputStream() in Java 11. For files, prefer the Files class of Topic 12.3, which builds on these same streams.

Explain it without notes

01

What's the difference between a byte stream and a character stream, and when do you use each?

02

Why does InputStream.read() return an int instead of a byte?

03

How do InputStreamReader and OutputStreamWriter fit in, and why should you always pass a charset?

04

Explain the Decorator pattern in java.io with an example.

05

What guarantees does try-with-resources give, and what happens if both the body and close() throw?

Practice

01

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.

02

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.

03

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.io streams are blocking: a thread that calls read() 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 an int and 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.