Appearance
Traditional Java File Handling: Streams, Buffers, Readers, and the Decorator Pattern
Java gives you two broad approaches to working with files. The first is the traditional stream based API that has been part of Java since the beginning. The second is NIO, which is the newer, more modern approach. Before you can appreciate what NIO improves upon, you need to understand the traditional approach deeply. And honestly, the traditional API is still very much alive in production systems today. Kafka, for example, writes its logs to disk and makes heavy use of NIO, but that does not mean the traditional approach has been retired. You will still meet it regularly.
Everything Is Bytes at the Bottom
Before touching a single class, you need to internalize one fundamental truth: at the lowest level, everything is bytes. It does not matter whether you are reading a text file, writing integers, storing images, or serializing objects. The operating system only understands bytes. Every interaction with the file system goes through system calls that deal in raw bytes.
This is not a Java limitation. It is how operating systems work. Java's file handling API is built on top of this reality, and once you understand it, the entire class hierarchy clicks into place.
The Two Root Abstract Classes
At the foundation of all traditional Java file I/O sit two abstract classes:
OutputStreamfor writing bytesInputStreamfor reading bytes
These are abstract, meaning you never instantiate them directly. They define the contract. Their file-based subclasses are the ones that talk to the operating system: every read() or write() on them is a system call, and a system call means switching from user space to kernel space, which has overhead.
This detail about system calls matters enormously for performance, and it is the central motivation for everything else we are going to cover.
The Core: FileOutputStream and FileInputStream
The concrete implementations of those two abstract classes that actually interact with real files are:
FileOutputStreamto write bytes to a fileFileInputStreamto read bytes from a file
Here is the most basic way to write bytes to a file:
java
// Writing bytes directly to a file
try (FileOutputStream fos = new FileOutputStream("data.bin")) {
fos.write(72); // writes the byte 72 (which is 'H' in ASCII)
fos.write(101); // 'e'
fos.write(108); // 'l'
fos.write(108); // 'l'
fos.write(111); // 'o'
}And reading those bytes back:
java
// Reading bytes one at a time
try (FileInputStream fis = new FileInputStream("data.bin")) {
int byteValue;
while ((byteValue = fis.read()) != -1) {
// read() returns -1 at end of file
System.out.print((char) byteValue);
}
}The read() method returns an int rather than a byte. The value is between 0 and 255 for actual data, and -1 signals end of file. You cast to char when you want to print as text.
Two more details:
new FileOutputStream("data.bin")empties the file if it exists (or creates it). To add to the end instead, use append mode:new FileOutputStream("data.bin", true).- Always close a stream when done: closing flushes pending data (the OS also keeps its own kernel buffer before the disk) and releases the file. A closed stream cannot be used again.
Notice the try with resources syntax. The parentheses after try declare resources that implement AutoCloseable. Java guarantees those resources get closed when the block exits, whether normally or by exception. (See the exception handling chapter.) Always close streams; try-with-resources does it for you.
You can also use a File object as a helper for validation before you open the stream:
java
File file = new File("data.bin");
if (!file.exists()) {
System.out.println("File not found");
return;
}
try (FileInputStream fis = new FileInputStream(file)) {
// safe to read
}The File class is just a helper that represents a path. It does not open any stream. It gives you helper methods such as:
- path:
getName(),getAbsolutePath() - metadata:
exists(),isDirectory(),length(),lastModified() - file operations:
createNewFile(),delete(),mkdir()
The Performance Problem: System Calls Are Expensive
Here is the problem. If you have a file with one million bytes in it and you call fis.read() in a loop, you make one million system calls. Each system call requires the CPU to switch from your program (user space) into the operating system kernel and back. That switching is not free. It takes time.
Think of it like this: imagine you need to move a thousand books from one room to another. You could carry one book at a time, walking back and forth a thousand times. Or you could stack twenty books on a cart, make fifty trips, and finish in a fraction of the time. The individual trip is the system call. The books per trip are the bytes. Buffering is the cart.
Now imagine you have one million bytes. Without buffering you make one million system calls. With buffering you might make only around 123 system calls (one million divided by 8192 bytes per buffer load). The difference in performance is dramatic. This is why buffering exists.
The Decorator Pattern: Adding Capabilities in Layers
Before we look at buffering classes, you need to understand the design pattern behind the entire traditional I/O class hierarchy: the Decorator Pattern.
The decorator pattern lets you wrap an object with another object that adds new behavior, without changing the original class. In Java I/O, FileOutputStream and FileInputStream are the core objects at the center. Everything else is a wrapper that adds capabilities on top.
Think of it like a coffee shop order. Your base is plain coffee. You can add milk on top. Then add sugar on top of that. Then add foam on top. Each addition wraps the previous one and adds a new capability, but the base coffee is still inside. In Java I/O, FileOutputStream is your plain coffee. BufferedOutputStream wraps it to add buffering. DataOutputStream wraps that to add primitive type awareness. ObjectOutputStream wraps that to add object serialization.
Here is the key: each wrapper takes the thing it wraps as a constructor argument. That is the decoration. And because they all share a common base type (OutputStream or InputStream), you can chain them together however you like.
BufferedOutputStream and BufferedInputStream
Adding buffering is simple. You wrap the core stream:
java
// Writing 1000 lines with buffering
try (
FileOutputStream fos = new FileOutputStream("output.txt");
BufferedOutputStream bos = new BufferedOutputStream(fos)
) {
for (int i = 0; i < 1000; i++) {
String line = "Line " + i + "\n";
bos.write(line.getBytes()); // goes to internal buffer, not disk yet
}
// bos.flush() happens automatically on close
}When you call bos.write(...), the bytes do not go to disk immediately. They go into an internal buffer (8192 bytes by default; you can pass another size, e.g. new BufferedInputStream(in, 16384)). Only when that buffer fills up, or when you flush, or when you close the stream, does the actual write to disk happen via a single system call.
Reading with a buffer works the same way in reverse:
java
// Reading with buffering
try (
FileInputStream fis = new FileInputStream("output.txt");
BufferedInputStream bis = new BufferedInputStream(fis)
) {
int byteValue;
while ((byteValue = bis.read()) != -1) {
// Each bis.read() call hits the internal buffer first
// Only when buffer is empty does it make a system call for 8192 more bytes
System.out.print((char) byteValue);
}
}The first call to bis.read() causes a system call that loads up to 8192 bytes from the file into the buffer. All subsequent calls to bis.read() serve bytes from that buffer with no system call at all. Only when the buffer is exhausted does another system call happen to refill it.
The speed difference is measurable. When writing 50,000 bytes to a file, using FileOutputStream directly (one system call per write) takes significantly longer than using BufferedOutputStream (far fewer system calls). You can prove this yourself by benchmarking with System.nanoTime().
An important point: if you use BufferedOutputStream and forget to call flush() or close(), bytes sitting in the buffer that have not yet reached the disk will be lost. Always close your streams. The try with resources pattern ensures this happens.
DataOutputStream and DataInputStream: Primitive Type Awareness
FileOutputStream knows only bytes. BufferedOutputStream adds buffering to byte writing. But what if you want to write an int? An int in Java is four bytes. If you try to write it with plain FileOutputStream.write(int), you will notice that write only writes the lowest one byte of whatever int you pass. To write a full integer you would have to manually break it into four bytes, write each one, and then when reading you would have to read four bytes and reassemble them. That is tedious and error prone.
DataOutputStream solves this. It adds knowledge of Java's primitive types:
java
// Writing primitive types
try (
DataOutputStream dos = new DataOutputStream(
new BufferedOutputStream(
new FileOutputStream("game.bin")
)
)
) {
dos.writeInt(9500); // player ID, exactly 4 bytes
dos.writeDouble(87.5); // player score, exactly 8 bytes
dos.writeBoolean(true); // active flag, 1 byte
dos.writeLong(1234567L); // timestamp, 8 bytes
dos.writeChar('A'); // level, 2 bytes
}And reading them back in exactly the same order:
java
// Reading primitive types back
try (
DataInputStream dis = new DataInputStream(
new BufferedInputStream(
new FileInputStream("game.bin")
)
)
) {
int playerId = dis.readInt();
double score = dis.readDouble();
boolean isActive = dis.readBoolean();
long timestamp = dis.readLong();
char level = dis.readChar();
System.out.println("Player " + playerId + " score: " + score);
}You must read in the same order you wrote. DataInputStream and DataOutputStream do not store any type metadata in the file. They just know how many bytes each type occupies and handle the conversion for you. Reading in a different order will give you garbage values.
ObjectOutputStream and ObjectInputStream: Full Object Serialization
DataOutputStream handles primitive types field by field. But what if you have an object with ten fields? Writing each field individually is workable, but what if you have a hundred objects, each with ten fields? That becomes impractical.
Java's answer is serialization: converting a complete object graph into a byte sequence and back. To make a class serializable, it must implement the Serializable marker interface:
java
import java.io.Serializable;
public class Player implements Serializable {
private static final long serialVersionUID = 1L;
private int id;
private String name;
private double score;
private transient String sessionToken; // will NOT be serialized
// static fields are also NOT serialized (they belong to the class, not the instance)
// constructors, getters, setters...
}Two important points about what does and does not get serialized:
Fields marked
transientare skipped during serialization. You commonly see this with sensitive data like passwords, tokens, or encryption keys. Even if the object gets written to disk or transmitted over a network, those fields will not be included. When the object is deserialized, transient fields get their default value (nullfor objects, 0 for numbers,falsefor booleans).staticfields are not serialized either. Static fields belong to the class, not to any particular instance, so they have no place in a serialized object.
Writing and reading objects:
java
Player player = new Player(1, "Alice", 9500.0, "token123");
// Writing an object
try (
ObjectOutputStream oos = new ObjectOutputStream(
new BufferedOutputStream(
new FileOutputStream("player.dat")
)
)
) {
oos.writeObject(player); // entire object tree converted to bytes
}
// Reading it back
try (
ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(
new FileInputStream("player.dat")
)
)
) {
Player loaded = (Player) ois.readObject(); // cast is required
System.out.println(loaded.getName());
}Notice the full decorator chain: FileOutputStream at the core, wrapped by BufferedOutputStream for performance, wrapped by ObjectOutputStream for object awareness. Each layer adds exactly one capability, and you can mix and match layers as your needs require.
The Character Problem: Why Byte Streams Are Not Enough for Text
You might be wondering: we already showed reading text with FileInputStream earlier. The bytes got cast to char and it worked. What is the problem?
It works for ASCII characters. Every ASCII character fits in one byte. But many characters do not. The euro sign, Hindi script, Chinese characters, emoji, and countless other characters from around the world require multiple bytes to represent. The UTF-8 encoding, for example, uses one byte for ASCII characters but up to four bytes for characters outside the ASCII range. The dollar sign in UTF-8 is one byte. Many characters outside the English alphabet take two or three bytes. Some emoji take four bytes.
If you read a UTF-8 file byte by byte with FileInputStream and cast each byte to char, you get garbage for any character that requires more than one byte. You are reading one byte and treating it as a complete character when it is only a fragment.
What you need is a reader that knows about encoding: one that can look at a group of bytes, understand that they together form one character, decode them correctly, and return that single character. That is what InputStreamReader and OutputStreamWriter provide.
OutputStreamWriter and InputStreamReader: Encoding Awareness
These two classes bridge the gap between the byte world and the text world:
java
// Writing text with explicit encoding
try (
OutputStreamWriter writer = new OutputStreamWriter(
new FileOutputStream("text.txt"),
"UTF-8" // specify the character encoding
)
) {
writer.write("Hello");
writer.write("€"); // euro sign, requires 3 bytes in UTF-8
writer.write("नमस्ते"); // Hindi text, multiple bytes per character
}java
// Reading text with the same encoding
try (
InputStreamReader reader = new InputStreamReader(
new FileInputStream("text.txt"),
"UTF-8"
)
) {
int ch;
while ((ch = reader.read()) != -1) {
System.out.print((char) ch); // correct characters, not garbage
}
}InputStreamReader knows the encoding. When you call read(), it internally reads however many bytes the encoding requires for the next character, decodes them together, and returns the correctly decoded character. If the encoding is UTF-8 and the next character is a three byte sequence, it reads all three bytes, decodes them, and returns the single char.
An important design point: once you wrap a stream with InputStreamReader or OutputStreamWriter, the result is no longer an InputStream or OutputStream. It is a Reader or Writer. This matters for the next layer. You cannot wrap a Reader with BufferedOutputStream. You must wrap it with BufferedReader. The hierarchy splits at this point into a separate reader/writer branch.
Reader and Writer: The Abstract Base Classes for Text
Just as InputStream and OutputStream are the abstract bases for byte I/O, Reader and Writer are the abstract bases for character I/O. InputStreamReader extends Reader. OutputStreamWriter extends Writer. And the buffered versions for the character world are BufferedReader and BufferedWriter.
BufferedReader and BufferedWriter: Buffered Character I/O
BufferedReader adds two things on top of InputStreamReader:
Buffering of decoded characters. Without
BufferedReader, every call toreader.read()reads the bytes for one character, decodes them, and returns. WithBufferedReader, it reads and decodes many characters at once, stores them in an internal buffer, and serves them one by one from that buffer. You get the decode work done in batches instead of on every single read.The
readLine()method. This is the killer feature. Instead of reading character by character, you can read an entire line of text in one call.
java
// The full chain for reading text files efficiently
try (
BufferedReader br = new BufferedReader(
new InputStreamReader(
new FileInputStream("text.txt"),
"UTF-8"
)
)
) {
String line;
while ((line = br.readLine()) != null) {
// readLine() returns null at end of file (not -1 like streams)
System.out.println(line);
}
}Writing with BufferedWriter:
java
// The full chain for writing text files efficiently
try (
BufferedWriter bw = new BufferedWriter(
new OutputStreamWriter(
new FileOutputStream("output.txt"),
"UTF-8"
)
)
) {
bw.write("Hello, world!");
bw.newLine(); // writes the correct line separator for the current OS
bw.write("Second line here");
bw.newLine();
}To read a character versus reading until end of stream: read() on a Reader returns an int that is -1 at end of stream. But readLine() on a BufferedReader returns null at end of stream. Do not confuse the two. Using "".equals(readLine()) as your end condition will fail because readLine() returns an empty string for blank lines, not null.
The Complete Decorator Pattern Picture
Here is a visual summary of how the layers stack:
OBJECT LAYER (full object serialization)
ObjectOutputStream / ObjectInputStream
|
PRIMITIVE LAYER (int, double, long, boolean, char)
DataOutputStream / DataInputStream
|
BUFFER LAYER (performance via memory buffer)
BufferedOutputStream / BufferedInputStream
|
CORE LAYER (actual OS interaction via system calls)
FileOutputStream / FileInputStream
|
FILE ON DISKFor the character/text branch:
BUFFER + READLINE LAYER
BufferedWriter / BufferedReader
|
ENCODING LAYER (UTF-8, UTF-16, etc.)
OutputStreamWriter / InputStreamReader
|
BUFFER LAYER (optional but recommended)
BufferedOutputStream / BufferedInputStream
|
CORE LAYER
FileOutputStream / FileInputStream
|
FILE ON DISKEvery layer wraps the layer below it. You include exactly the layers you need for your use case. Working with raw bytes and need speed? Add buffering. Working with text? Add encoding awareness. Working with objects? Add serialization. The pattern is consistent throughout.
Check yourself
- Why is calling
fis.read()in a loop over a 1 MB file slow? - What is the default buffer size of
BufferedInputStream/BufferedOutputStream? - What can happen if you never flush or close a
BufferedOutputStream? fos.write(300)writes how many bytes?- You wrote
writeInt, thenwriteDouble. What must the reader do? - Which fields are skipped by
ObjectOutputStream? - Why can reading a UTF-8 file byte by byte and casting to
charprint garbage? - What does
readLine()return at the end of the file?
Answers
- Each
read()on a rawFileInputStreamis a system call, so about a million user/kernel switches. - 8192 bytes.
- Bytes still sitting in the buffer never reach the file.
- One byte: only the lowest 8 bits (300 & 0xFF = 44).
- Call
readIntthenreadDouble, in the same order. No type info is stored. transientfields andstaticfields.- Many characters take 2 to 4 bytes in UTF-8; one byte is only a fragment. Use
InputStreamReaderwith the encoding. null(whileread()returns -1).