Before Java NIO (Non-blocking IO) introduced modern paths and channel-based packages, Java applications relied exclusively on the classic stream-based classes in java.io. The core design philosophy of classic Java IO is the Decorator Pattern, where classes are designed to wrap around one another.

Each layer of the wrapper adds a specific capability—such as buffering, filtering, or formatted writing—to build a specialized data pipeline. For beginners, the sheer number of classes (like FileInputStream, FileReader, BufferedReader) can feel overwhelming. However, they fall into clear categories based on whether they process raw binary bytes or human-readable character text. In this guide, we will focus on the classic character-stream pipeline for reading and writing files efficiently using FileReader, BufferedReader, and PrintWriter.

Real-World Analogy: The Well, The Straw, and The Thermos

To understand why Java IO uses these nested wrappers, imagine trying to drink water out of a deep well:

  • The Straw (FileReader): If you drop a very thin straw down the well, you can only sip the water droplet by droplet. This is slow and requires constant physical effort (making a round trip to the disk for every single character).
  • The Thermos Bucket (BufferedReader): To make this efficient, you connect a thermos bucket to the top of the straw. The bucket pulls up a large quantity of water at once and stores it in memory. Now, when you want to drink, you can swallow a whole glass of water (readLine()) instantly without waiting.
  • The Funnel (PrintWriter): When putting water back into another container, you use a funnel with measurement markings, letting you pour water in exact amounts (like formatted println() lines) rather than dumping it all at once.

Reading Files with BufferedReader

To read a file line-by-line, we instantiate a FileReader pointing to our target file. Because FileReader reads characters individually, we immediately wrap it in a BufferedReader. The BufferedReader allocates an internal memory buffer (by default 8 KB). As we call readLine(), it reads blocks of characters from the disk into the buffer, serving them to our application instantly. Once the end of the file is reached, readLine() returns null.

Here is how to read file contents using the classic BufferedReader approach, ensuring proper resource closure:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
 
public class FileRead {
    public static void main(String[] args) {
        String content = textFromFile();
        System.out.println(content);
    }
 
    private static String textFromFile() {
        FileReader f = null;
        try {
            f = new FileReader("wordcheck.txt");
            BufferedReader b = new BufferedReader(f);
            
            String alltext = "";
            String line = "";
            while ((line = b.readLine()) != null) {
                alltext += line + "\n";
            }
            return alltext;
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (f != null) {
                try {
                    f.close(); // Clean up resource handles!
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return null;
    }
}

Writing Files with PrintWriter

Writing files follows a matching pipeline. We start with a FileWriter, which writes individual characters to disk. To write complete lines or formatted output easily, we wrap it in a PrintWriter. The PrintWriter provides the familiar methods we use with System.out, such as print(), println(), and printf(). Crucially, when writing files, we must always invoke close() or flush() on our writer; otherwise, data stored in memory buffers may never be written to the disk.

Here is how to write lines of text to a file using the classic PrintWriter wrapping pattern:

import java.io.FileWriter;
import java.io.PrintWriter;
import java.io.IOException;
 
public class FileWrite {
    public static void main(String[] args) {
        textToFile("Hello World.", "Classic IO is cool.", "Goodbye!");
    }
 
    private static void textToFile(String... lines) {
        PrintWriter w = null;
        try {
            w = new PrintWriter(new FileWriter("output.txt"));
            for (String s : lines) {
                w.println(s);
            }
            System.out.println("Done writing file!");
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (w != null) {
                w.close(); // Flush and close stream!
            }
        }
    }
}

Conclusion & Modern Alternatives

Although modern Java versions offer convenient utility methods like Files.readString() or Files.writeString() for quick one-liners, the classic wrapped IO streams remain crucial. They allow us to process large multi-gigabyte log files line-by-line without running out of JVM heap space. Mastering these decorators is essential for any backend developer handling high-volume file processing.