The two previous lessons left a question unanswered. You have written new PrintWriter(new FileWriter(path, UTF_8)) and accepted that it works, but why does PrintWriter wrap FileWriter instead of replacing it? Why are there classes ending in Reader and Writer and others ending in InputStream and OutputStream? And how do you copy a book's cover image, which is not text and which no FileReader can read without wrecking it?

This lesson answers all three. java.io is not a collection of loose classes to be memorised: it is a design with a very deliberate structure, based on two ideas —the stream as an abstraction and the decorator as a way of composing— which, once understood, turn fifty classes into a handful of rules.

By the end you will be able to look at any expression like new DataOutputStream(new BufferedOutputStream(new FileOutputStream(f))) and read exactly what each set of brackets does, in what order the bytes flow and what happens if you remove a layer. And BiblioTech will be able to handle its book covers, which are binary files.

try-with-resources in every example, as always since 06-06. Here it takes on a new nuance worth keeping in mind: closing the outermost decorator closes the whole chain, and that is exactly what you want.

Contents

  1. What a stream is: the conceptual model
  2. The two hierarchies and why there are two
  3. The map of the main classes
  4. Node classes and filter classes
  5. The Decorator pattern in java.io
  6. The bridges: InputStreamReader and OutputStreamWriter
  7. FileInputStream and FileOutputStream: byte by byte
  8. Block reading and the contract of read(byte[])
  9. Copying a binary file: a book cover
  10. DataInputStream and DataOutputStream: primitive types
  11. In-memory streams: ByteArrayInputStream and ByteArrayOutputStream
  12. transferTo: the modern way of copying
  13. Block size and performance
  14. Which stream do I choose for what
  15. Common Mistakes and Tips
  16. Exercises

  1. What a stream is: the conceptual model

A stream is an ordered sequence of data traversed from beginning to end and in one direction only.

The metaphor that gives it its name is a water pipe. The water comes in at one end and goes out at the other; you can take what passes by, but you cannot go back to see what passed a while ago, nor jump ahead to what is coming.

The vocabulary to pin down:

Term Meaning
Stream The sequence of data and the class that manages it
Source Where the data comes from: file, network, memory, keyboard
Sink Where it goes: file, network, memory, screen
Direction Input (read) or output (write). Never both
Position How much has been consumed. It advances on its own and does not go back

The three properties that define a stream and must be internalised:

1. It is unidirectional. There is no class that reads and writes. InputStream reads, OutputStream writes. If you need both on the same file, you open two streams —or you use RandomAccessFile, which is not a stream and which this lesson does not cover.

2. It is sequential. There is no random access: to reach byte 1000 you have to consume the previous 999. This limitation is what lets the same abstraction serve a local file, a network connection or data being generated on the fly.

3. It is agnostic about the source. And this is the property that makes it valuable. A method that receives an InputStream works with a file, with a socket from module 9, with an array of bytes in memory or with the output of another process, without changing a line:

/**
 * Counts the bytes of ANY input stream.
 *
 * It neither knows nor cares whether it comes from a file, the network,
 * memory or an array. That is exactly what the abstraction contributes.
 */
public static long countBytes(InputStream input) throws IOException {
    long total = 0;
    int read;
    byte[] block = new byte[8192];
    while ((read = input.read(block)) != -1) {
        total += read;
    }
    return total;
}

That method can be called with a file, with an HTTP response (module 9) or with data generated in memory. It is the polymorphism of module 3 applied to I/O, and it is the reason the hierarchy exists.

An important terminological warning to avoid a very widespread confusion:

These streams have nothing to do with the collections Streams API (stream(), filter, map, collect), which is from Java 8 and is studied in 10-04. They share the name and nothing else. The ones in this lesson are from Java 1.0, live in java.io and carry bytes or characters; the ones in 10-04 live in java.util.stream and carry the elements of a collection. The only point of contact you will see is in 07-06, with Files.lines().

  1. The two hierarchies and why there are two

Java has two complete, parallel families of stream classes:

Byte family Character family
Base classes InputStream / OutputStream Reader / Writer
Unit byte (8 bits, −128 to 127) char (16 bits, Unicode)
Since Java 1.0 Java 1.1
For Binary data: images, PDF, ZIP, executables Text
Encoding Neither knows nor needs it Essential: it translates bytes into characters
Read method read() returns 0..255 or −1 read() returns 0..65535 or −1
Node examples FileInputStream, FileOutputStream FileReader, FileWriter

Why one was not enough

Java 1.0 only had the byte family, and it was soon discovered to be insufficient. The reason is the one you already know from 07-01: a character is not a byte.

In ASCII the correspondence is 1 to 1 and the distinction does not matter. But as soon as Unicode comes in:

  • é in UTF-8 is two bytes.
  • € is three.
  • An emoji, four.

If you only have byte streams and you want to read text, you have to do the decoding yourself: accumulate bytes, know how many form the next character, handle characters split across two reads. That is complex, error-prone and always the same. The Reader/Writer family does it for you.

And there is a second, subtler reason: characters can be split across two blocks. If you read 8192 bytes and the last one is the first of an é, the second byte arrives on the next read. A Reader maintains that internal state and hands you the complete é. Handling it by hand is exactly the kind of bug that only shows up with large files and only sometimes.

The decision rule is simple and admits no excuses:

Is the file text a person could read? Use Reader/Writer. Is it binary data? Use InputStream/OutputStream. Not sure? It is binary. Treat it as bytes and you will not spoil it.

What happens if you get it wrong: if you read a JPEG with a FileReader, the decoder tries to interpret arbitrary bytes as UTF-8 characters. Most do not form valid sequences and are replaced by �. The data is irreversibly corrupted. If you then write it out, the image no longer opens. It is the silent corruption of 07-01 taken to the extreme.

  1. The map of the main classes

The byte hierarchy:

flowchart TD
    IS["InputStream<br/>(abstract)"]
    IS --> FIS["FileInputStream<br/>NODE: file"]
    IS --> BAIS["ByteArrayInputStream<br/>NODE: memory"]
    IS --> OTHERS["Other nodes:<br/>socket, process, resource"]
    IS --> FILT["FilterInputStream<br/>(base decorator)"]
    FILT --> BIS["BufferedInputStream<br/>adds a buffer"]
    FILT --> DIS["DataInputStream<br/>adds primitive types"]
    IS --> OIS["ObjectInputStream<br/>serialised objects (07-05)"]

    style IS fill:#e3f2fd
    style FILT fill:#fff3e0
    style FIS fill:#e8f5e9
    style BAIS fill:#e8f5e9

The character hierarchy:

flowchart TD
    R["Reader<br/>(abstract)"]
    R --> ISR["InputStreamReader<br/>BRIDGE bytes to characters"]
    ISR --> FR["FileReader<br/>NODE: text file"]
    R --> SR["StringReader<br/>NODE: memory"]
    R --> CAR["CharArrayReader<br/>NODE: memory"]
    R --> FIR["FilterReader<br/>(base decorator)"]
    R --> BR["BufferedReader<br/>adds a buffer and readLine"]

    style R fill:#e3f2fd
    style ISR fill:#f3e5f5
    style BR fill:#fff3e0
    style FR fill:#e8f5e9

And the writing one, which is symmetrical:

flowchart TD
    W["Writer<br/>(abstract)"]
    W --> OSW["OutputStreamWriter<br/>BRIDGE characters to bytes"]
    OSW --> FW["FileWriter<br/>NODE: text file"]
    W --> SW["StringWriter<br/>NODE: memory"]
    W --> BW["BufferedWriter<br/>adds a buffer and newLine"]
    W --> PW["PrintWriter<br/>adds println and printf"]

    style W fill:#e3f2fd
    style OSW fill:#f3e5f5
    style BW fill:#fff3e0
    style PW fill:#fff3e0
    style FW fill:#e8f5e9

Notice a revealing detail in the last two diagrams: FileReader extends InputStreamReader and FileWriter extends OutputStreamWriter. They are not independent classes: they are shortcuts. FileReader is a bridge that already has a FileInputStream wired in. This explains at a stroke why it accepts a Charset in the constructor and why in 07-01 the alternative new InputStreamReader(new FileInputStream(path), UTF_8) did exactly the same thing.

The minimum contract of each base class, which is the only thing to memorise:

// InputStream
public abstract int read() throws IOException;            // 0..255, or -1 at the end
public int read(byte[] b) throws IOException;             // how many it read, or -1
public int read(byte[] b, int off, int len);
public void close() throws IOException;
public long transferTo(OutputStream target);              // Java 9

// OutputStream
public abstract void write(int b) throws IOException;     // the least significant byte
public void write(byte[] b);
public void write(byte[] b, int off, int len);
public void flush() throws IOException;
public void close() throws IOException;

// Reader
public int read() throws IOException;                     // 0..65535, or -1
public int read(char[] c);
public void close() throws IOException;

// Writer
public void write(int c);
public void write(String s);
public void write(char[] c);
public void flush();
public void close();

Everything else is an addition. readLine() belongs to BufferedReader, println() to PrintWriter, readInt() to DataInputStream. If you know the four base classes, you can read any Java I/O code.

  1. Node classes and filter classes

This is the distinction that organises everything:

Node class (or connection class) Filter class (or decorator)
Where it gets the data from From a real resource: file, network, memory From another stream
What it adds Access to the resource A capability: buffer, types, format
Can be used alone Yes No: it needs something to wrap
Input examples FileInputStream, ByteArrayInputStream BufferedInputStream, DataInputStream
Output examples FileOutputStream, ByteArrayOutputStream BufferedOutputStream, PrintWriter

They are recognisable at a glance from the constructor:

// NODE: it receives a resource (a path, a File, an array)
new FileInputStream("catalog.txt")
new ByteArrayInputStream(data)

// FILTER: it receives ANOTHER STREAM
new BufferedInputStream(anotherStream)
new DataInputStream(anotherStream)
new PrintWriter(anotherWriter)

And hence the rule that explains all the nested expressions you have seen:

The innermost layer is always a node. All the outer ones are filters.

new DataInputStream(          // filter: adds readInt, readDouble...
    new BufferedInputStream(  // filter: adds a buffer
        new FileInputStream(  // NODE: connects to the file
            "data/loans.bin")))

It is read from the inside out: "I open the file, I put a buffer on it, and on top of that I read primitive types".

  1. The Decorator pattern in java.io

What you have just seen has a name of its own in the design patterns catalogue: it is the Decorator, and java.io is its canonical example —the one that appears in every book.

The idea: instead of creating a class for every combination of capabilities, you create layers that wrap one another. Each decorator implements the same interface it decorates and delegates to the wrapped one, adding its own part.

Imagine the alternative without a decorator. If you wanted to cover every combination of source (file, memory, network) by capability (buffered, typed, both), you would need one class per combination:

Plain Buffered Typed Buffered and typed
File FileInput BufferedFileInput DataFileInput BufferedDataFileInput
Memory MemoryInput BufferedMemoryInput DataMemoryInput BufferedDataMemoryInput
Network SocketInput BufferedSocketInput DataSocketInput BufferedDataSocketInput

Twelve classes for three sources and two capabilities. And each new capability multiplies the number: with a third capability there would be twenty-four. It is the combinatorial explosion the decorator avoids: 3 nodes + 2 filters = 5 classes, with the twelve combinations available by composition.

How the data flows along the chain:

flowchart LR
    APP["Your code"] -->|"readInt()"| D["DataInputStream"]
    D -->|"read(byte[4])"| B["BufferedInputStream"]
    B -->|"read(byte[8192])<br/>only if the buffer is empty"| F["FileInputStream"]
    F -->|"system call"| OS["Operating system"]
    OS --> DISK["Disk"]

    style D fill:#fff3e0
    style B fill:#fff3e0
    style F fill:#e8f5e9

Follow a call to readInt() along that chain:

  1. DataInputStream.readInt() needs 4 bytes and asks the BufferedInputStream for them.
  2. BufferedInputStream looks at its internal buffer. If it has 4 bytes, it returns them without touching the disk.
  3. If it is empty, it asks the FileInputStream for 8192 bytes in one go, stores them and returns the 4 requested.
  4. FileInputStream makes one system call.
  5. DataInputStream combines the 4 bytes into an int and returns it.

Two thousand calls to readInt() produce a single system call. That is the value of the buffer, and it is the whole topic of 07-04.

The three practical consequences of the decorator, which must always be kept in mind:

1. Order matters. These two chains are not equivalent:

// CORRECT: the buffer sits between the file and the types
new DataInputStream(new BufferedInputStream(new FileInputStream(f)))

// USELESS: the buffer is on the outside. DataInputStream reads straight from
// the file, 4 bytes at a time, with one system call for each.
new BufferedInputStream(new DataInputStream(new FileInputStream(f)))

The general rule: the buffer goes as close as possible to the node, so that it is the one absorbing the system calls.

2. Closing the outer one closes everything. Each decorator's close() calls the wrapped one's close(), in cascade. That is why declaring the outer layer in the try-with-resources is enough:

// This is enough: PrintWriter's close() closes BufferedWriter,
// which closes FileWriter, which closes the file.
try (PrintWriter output = new PrintWriter(
        new BufferedWriter(
            new FileWriter("report.txt", StandardCharsets.UTF_8)))) {
    output.println("...");
}

And this has an awkward corollary worth knowing: if an intermediate layer's constructor fails, the layers already built are left unclosed. With the chain above, if new BufferedWriter(...) threw an exception, the already-built FileWriter would be left open with no reference to it. It is a rare but real case; the robust form is to declare each layer separately in the try-with-resources:

try (FileWriter fw = new FileWriter("report.txt", StandardCharsets.UTF_8);
     BufferedWriter bw = new BufferedWriter(fw);
     PrintWriter output = new PrintWriter(bw)) {
    output.println("...");
}
// Each resource declared separately: all are closed, in reverse order (06-06)

More verbose, but without that gap. For ordinary code the nested form is acceptable; for critical infrastructure code, this one.

3. The abstraction is preserved. A method that receives an InputStream accepts any chain of decorators, because they all are InputStreams.

This pattern is formalised, with its general structure and its other uses, in lesson 12-02. Here you have seen it working in the place where it is best appreciated.

  1. The bridges: InputStreamReader and OutputStreamWriter

The two hierarchies are not isolated: there are two classes that connect them, and they are exactly the point where the encoding you have been specifying for two lessons is decided.

Class Converts It is a
InputStreamReader Bytes → characters Reader that wraps an InputStream
OutputStreamWriter Characters → bytes Writer that wraps an OutputStream
flowchart LR
    subgraph BYTES["World of BYTES"]
        FIS["FileInputStream"]
    end
    subgraph BRIDGE["THE BRIDGE"]
        ISR["InputStreamReader<br/>charset UTF-8"]
    end
    subgraph CHARS["World of CHARACTERS"]
        BR["BufferedReader"]
        APP["Your code:<br/>String"]
    end

    FIS -->|"bytes 0xC3 0xA9"| ISR
    ISR -->|"character 'e acute'"| BR
    BR --> APP

    style ISR fill:#f3e5f5

That diagram contains the whole idea: the translation happens at a single point, and that point is where the charset is specified. Everything to the left is meaningless bytes; everything to the right is characters.

import java.io.*;
import java.nio.charset.StandardCharsets;

public class BridgeUsage {

    /** Reading text with the complete, explicit chain. */
    public static void read(String path) throws IOException {
        try (BufferedReader reader = new BufferedReader(       // 3. buffer and readLine
                new InputStreamReader(                          // 2. BRIDGE: the charset goes here
                        new FileInputStream(path),              // 1. NODE: bytes from the file
                        StandardCharsets.UTF_8))) {

            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        }
    }

    /** Symmetrical writing. */
    public static void write(String path, String text) throws IOException {
        try (BufferedWriter writer = new BufferedWriter(
                new OutputStreamWriter(
                        new FileOutputStream(path),
                        StandardCharsets.UTF_8))) {
            writer.write(text);
        }
    }
}

Now you can see the exact equivalence with what you used in 07-01 and 07-02:

// These two lines are practically the same thing:
new FileReader(path, StandardCharsets.UTF_8)
new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8)

// And so are these two:
new FileWriter(path, StandardCharsets.UTF_8)
new OutputStreamWriter(new FileOutputStream(path), StandardCharsets.UTF_8)

FileReader is literally a subclass of InputStreamReader with the FileInputStream already wired in. The short form is convenient for files; the long one becomes necessary as soon as the source is not a file:

// Reading text from STANDARD INPUT: there is no "FileReader for System.in"
new InputStreamReader(System.in, StandardCharsets.UTF_8)

// Reading text from a network connection (module 9)
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)

// Reading text from a resource inside the .jar
new InputStreamReader(getClass().getResourceAsStream("/config.txt"),
                      StandardCharsets.UTF_8)

None of those three cases can be solved with FileReader, because the source is not a file. The bridge is what makes the charset specifiable for any text source.

And a special case that deserves a warning: System.out is a PrintStream, a byte stream with text methods. It is a historical oddity from Java 1.0, predating the existence of Writer. That is why System.out.println("Café") can show accents wrongly in a Windows console: the conversion is done by PrintStream with the console encoding, which need not be UTF-8. If you need control:

PrintWriter console = new PrintWriter(
        new OutputStreamWriter(System.out, StandardCharsets.UTF_8), true);
console.println("Café Society");         // controlled charset

The true in the second parameter enables automatic flushing on every println, which on a console is what you want.

  1. FileInputStream and FileOutputStream: byte by byte

These are the node classes for binary data. Their API is that of InputStream/OutputStream with no additions.

import java.io.FileInputStream;
import java.io.IOException;

public class SimpleBinaryReading {

    /** Shows the first bytes of a file in hexadecimal. */
    public static void dumpHeader(String path, int howMany) throws IOException {
        try (FileInputStream input = new FileInputStream(path)) {

            for (int i = 0; i < howMany; i++) {
                int b = input.read();            // 0..255, or -1 at the end
                if (b == -1) {
                    System.out.println("\n(end of file)");
                    break;
                }
                System.out.printf("%02X ", b);
                if ((i + 1) % 16 == 0) {
                    System.out.println();
                }
            }
            System.out.println();
        }
    }

    public static void main(String[] args) throws IOException {
        dumpHeader("covers/effective-java.jpg", 32);
    }
}

Output with a JPEG:

FF D8 FF E0 00 10 4A 46 49 46 00 01 01 00 00 48
00 48 00 00 FF DB 00 43 00 08 06 06 07 06 05 08

Those first bytes are the format's signature: FF D8 FF identifies a JPEG, just as 89 50 4E 47 identifies a PNG and 25 50 44 46 identifies a PDF. It is the kind of data that can only be read with byte streams.

The technical detail that confuses everybody:

read() returns an int between 0 and 255, but Java's byte type runs from −128 to 127. The method does the unsigned conversion for you. If you cast to byte, the value 200 becomes −56. To get back to 0..255 you have to mask: b & 0xFF.

byte b = -56;                    // this is how a byte[] stores it
int unsigned = b & 0xFF;         // 200: the real value
System.out.printf("%02X%n", unsigned);   // C8

This detail causes real failures in checksums, comparisons and hex dumps. When you work with bytes, mask with & 0xFF whenever you need the numeric value.

In writing, the asymmetry is symmetrical:

try (FileOutputStream output = new FileOutputStream("test.bin")) {
    output.write(255);     // writes the byte 0xFF
    output.write(65);      // writes the byte 0x41, which is 'A' in ASCII
    output.write(300);     // writes 0x2C: it keeps the LOW 8 bits, without warning
}

write(int) discards the high bits silently. Writing 300 writes 44. It is a source of bugs in code that computes values and writes them without checking the range.

And as in 07-02, FileOutputStream has its append parameter:

new FileOutputStream("data.bin");         // OVERWRITES, truncates to zero
new FileOutputStream("data.bin", true);   // APPENDS at the end

With the same consequences and the same risk. The warning from 07-02 applies identically.

  1. Block reading and the contract of read(byte[])

Reading byte by byte has the same performance problem as reading character by character, for the same reason. The immediate solution is to read in blocks:

public int read(byte[] buffer) throws IOException

Its contract has three points to know precisely, because all three get got wrong:

1. It returns how many bytes it has read, not how many fit. It may return fewer than the array size even though there is data left to read. With a local file this only happens at the end; with a network socket (module 9) it happens constantly, because data arrives in chunks.

2. It returns −1 only when there is absolutely nothing more. A 0 is possible with a zero-length array, but it never means end of file.

3. It does not guarantee filling the array. There is no promise whatsoever that it will. Assuming so is the classic mistake.

The correct loop:

import java.io.FileInputStream;
import java.io.IOException;

public class BlockReading {

    private static final int BLOCK_SIZE = 8192;

    public static long countBytes(String path) throws IOException {
        long total = 0;

        try (FileInputStream input = new FileInputStream(path)) {
            byte[] block = new byte[BLOCK_SIZE];
            int read;

            // 'read' is HOW MANY bytes there really are in the array.
            // Only the first 'read' are new data; the rest is rubbish
            // from the previous iteration.
            while ((read = input.read(block)) != -1) {
                total += read;
                process(block, read);              // ALWAYS with the limit
            }
        }
        return total;
    }

    private static void process(byte[] block, int howMany) {
        for (int i = 0; i < howMany; i++) {        // up to 'howMany', not up to length
            // ...
        }
    }
}

The mistake, in its most common and most destructive form:

// WRONG: it ALWAYS copies the whole array
while ((read = input.read(block)) != -1) {
    output.write(block);                      // <-- should be write(block, 0, read)
}

With a 10,000-byte file and 8192-byte blocks: the first read brings 8192, the second brings 1808. But write(block) writes 8192 in both, so the copied file has 16,384 bytes instead of 10,000, and the last 6,384 are rubbish from the first read. With an image, the result does not open. With a ZIP, it is corrupt.

Always write write(block, 0, read). It is the same trap as the append(block, 0, read) of 07-01, in its binary and more serious version.

And a warning: if you really need to fill the array —because the format demands reading exactly N bytes— you have to insist in a loop. Java has it solved:

// Java 9+: reads EXACTLY n bytes or throws EOFException
byte[] header = input.readNBytes(16);

// Java 9+: reads EVERYTHING that is left. Careful with large files (07-01)
byte[] everything = input.readAllBytes();

  1. Copying a binary file: a book cover

BiblioTech stores the cover image of each book. Copying them requires byte streams, no two ways about it.

package com.nexussoftware.bibliotech.service;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.logging.Logger;

/**
 * Management of the cover images of the BiblioTech catalogue.
 *
 * Covers are JPEG or PNG: BINARY data. Using Reader/Writer on them would
 * destroy them, because the character decoder replaces the bytes that do
 * not form valid sequences.
 */
public class CoverManager {

    private static final Logger LOG = Logger.getLogger(CoverManager.class.getName());

    private static final int BLOCK_SIZE = 8192;
    private static final long MAX_SIZE = 5 * 1024 * 1024;        // 5 MB

    private final File directory;

    public CoverManager(String coverDirectory) {
        this.directory = new File(coverDirectory);
        if (!directory.exists() && !directory.mkdirs()) {
            LOG.warning("Could not create " + directory.getAbsolutePath());
        }
    }

    /**
     * Copies a book cover into the store, naming it after its ISBN.
     *
     * @return bytes copied
     * @throws IOException if the copy fails
     */
    public long importCover(String isbn, File source) throws IOException {
        if (!source.isFile()) {
            throw new IOException("Not a readable file: " + source.getAbsolutePath());
        }
        if (source.length() > MAX_SIZE) {
            throw new IOException("The cover exceeds the maximum of "
                    + (MAX_SIZE / 1024) + " KB: " + source.length() + " bytes");
        }

        File target = new File(directory, isbn + extension(source.getName()));
        long copied = copy(source, target);

        LOG.info(() -> String.format("Cover of %s imported: %d bytes into %s",
                isbn, copied, target.getName()));
        return copied;
    }

    /**
     * Binary copy byte by byte, with a buffer at both ends.
     *
     * The decorator chain:
     *   FileInputStream  -> NODE: reads from the file
     *   BufferedInputStream -> FILTER: groups the reads
     *   (and the same on the writing side)
     */
    public long copy(File source, File target) throws IOException {
        long total = 0;

        try (BufferedInputStream input =
                     new BufferedInputStream(new FileInputStream(source), BLOCK_SIZE);
             BufferedOutputStream output =
                     new BufferedOutputStream(new FileOutputStream(target), BLOCK_SIZE)) {

            byte[] block = new byte[BLOCK_SIZE];
            int read;

            while ((read = input.read(block)) != -1) {
                // KEY: (block, 0, read), NEVER plain (block).
                // With (block) rubbish would be copied at the end and the image would not open.
                output.write(block, 0, read);
                total += read;
            }
            // try-with-resources' close() flushes: without it, the last bytes
            // of the buffer would never reach the file (07-02).
        }
        return total;
    }

    /** Detects the format by reading the file SIGNATURE, not its extension. */
    public String detectFormat(File file) throws IOException {
        try (FileInputStream input = new FileInputStream(file)) {
            byte[] signature = input.readNBytes(8);    // Java 9+

            if (signature.length >= 3
                    && (signature[0] & 0xFF) == 0xFF
                    && (signature[1] & 0xFF) == 0xD8
                    && (signature[2] & 0xFF) == 0xFF) {
                return "JPEG";
            }
            if (signature.length >= 8
                    && (signature[0] & 0xFF) == 0x89
                    && signature[1] == 'P' && signature[2] == 'N' && signature[3] == 'G') {
                return "PNG";
            }
            if (signature.length >= 4 && signature[0] == 'G' && signature[1] == 'I' && signature[2] == 'F') {
                return "GIF";
            }
            return "UNKNOWN";
        }
    }

    private String extension(String name) {
        int dot = name.lastIndexOf('.');
        return (dot > 0) ? name.substring(dot).toLowerCase() : ".bin";
    }
}

Two things this class teaches beyond the copying:

  • The & 0xFF in detectFormat. Without it, the comparison signature[0] == 0xFF would always be false: signature[0] is −1 as a byte, and 0xFF is 255 as an int. This is exactly the bug from section 7, and here you see why it matters.
  • Detecting the format by the signature, not the extension. The extension is a convention anybody can change; the first bytes are the real format. It is an elementary security check when you accept files from third parties.

  1. DataInputStream and DataOutputStream: primitive types

These two filters add methods for reading and writing Java primitive types in a defined, portable binary format.

import java.io.*;

public class BinaryData {

    public static void write(String path) throws IOException {
        try (DataOutputStream output = new DataOutputStream(
                new BufferedOutputStream(new FileOutputStream(path)))) {

            output.writeUTF("LN-0001");        // string: 2 length bytes + modified UTF-8
            output.writeInt(15);               // 4 bytes
            output.writeDouble(0.25);          // 8 bytes
            output.writeBoolean(true);         // 1 byte
            output.writeLong(1_700_000_000L);  // 8 bytes
        }
    }

    public static void read(String path) throws IOException {
        try (DataInputStream input = new DataInputStream(
                new BufferedInputStream(new FileInputStream(path)))) {

            // The reading ORDER must be EXACTLY the writing one.
            // The file carries no description whatsoever of its content.
            String reference = input.readUTF();
            int    days      = input.readInt();
            double rate      = input.readDouble();
            boolean returned = input.readBoolean();
            long   timestamp = input.readLong();

            System.out.printf("%s: %d days, %.2f EUR/day, returned=%b, timestamp=%d%n",
                    reference, days, rate, returned, timestamp);
        }
    }
}

The format is fixed and documented:

Method Bytes Format
writeByte 1 The byte
writeShort 2 Big-endian
writeInt 4 Big-endian
writeLong 8 Big-endian
writeFloat 4 IEEE 754
writeDouble 8 IEEE 754
writeBoolean 1 0 or 1
writeChar 2 UTF-16
writeUTF 2 + n 2-byte length + modified UTF-8

The three properties that make this format useful:

  1. It is portable. Always big-endian, regardless of the machine architecture. A file written on an ARM server reads the same on an x86.
  2. It is compact. An int always takes 4 bytes. In text, 1234567890 would take 10.
  3. It is fast. There is no parsing: the bytes are combined directly into the value.

And its three limitations, which must be accepted before choosing it:

  1. It is not self-describing. The file does not say what it contains. If you read in a different order from the one you wrote, you get rubbish with no error at all: a readDouble over what was an int and a float returns a perfectly valid and completely false number.
  2. It is not readable. It cannot be opened with an editor or compared with diff.
  3. It is fragile under change. Adding a field breaks all the existing files.

A specific warning about writeUTF: it does not write standard UTF-8. It uses what is called modified UTF-8, which encodes the null character differently, and strings are limited to 65,535 bytes. It is a Java-internal format: use it only for files Java is going to read. For interchange with other languages, text.

When to use DataStream:

Situation DataStream?
Internal binary cache of the application Yes, it fits well
A compact, controlled proprietary format Yes
A binary network protocol (module 9) Yes, it is its natural case
Data another language has to read No: text or a standard format
Data somebody is going to inspect No: it is not readable
Data with object structure and references No: that is serialisation (07-05)

BiblioTech will not use it for its catalogue —it wants readable, versionable files, and that is 07-07— but knowing it is essential for module 9 and for understanding that the serialisation of 07-05 is built on these very primitives.

  1. In-memory streams: ByteArrayInputStream and ByteArrayOutputStream

These two nodes never touch the disk: their "file" is an array of bytes in memory.

import java.io.*;
import java.nio.charset.StandardCharsets;

public class InMemoryStreams {

    /** ByteArrayOutputStream: accumulates in memory whatever is written to it. */
    public static byte[] generateInMemory() throws IOException {
        ByteArrayOutputStream memory = new ByteArrayOutputStream();

        try (DataOutputStream output = new DataOutputStream(memory)) {
            output.writeUTF("LN-0001");
            output.writeInt(15);
            output.writeDouble(0.25);
        }
        // toByteArray() returns a COPY of what has been accumulated
        return memory.toByteArray();
    }

    /** ByteArrayInputStream: reads from an array as if it were a file. */
    public static void readFromMemory(byte[] data) throws IOException {
        try (DataInputStream input = new DataInputStream(
                new ByteArrayInputStream(data))) {

            System.out.println(input.readUTF());
            System.out.println(input.readInt());
            System.out.println(input.readDouble());
        }
    }

    public static void main(String[] args) throws IOException {
        byte[] data = generateInMemory();
        System.out.println("Generated " + data.length + " bytes without touching the disk");
        readFromMemory(data);
    }
}

What they are really for:

Use Explanation
Tests Testing a method that receives an InputStream without creating files. Its most valuable use
Complete buffer Accumulating an output and deciding afterwards what to do with it
Conversion Moving between byte[] and a stream when an API demands one and you have the other
Sending over the network Preparing the content in memory and sending it in one go (module 9)
In-memory backup Saving the state before a risky operation

The use in tests deserves an example, because it changes the way you write verifiable code:

/**
 * Production method: it receives an InputStream, not a path.
 * That design decision is what makes it testable without files.
 */
public int countLines(InputStream input) throws IOException {
    int lines = 0;
    try (BufferedReader reader = new BufferedReader(
            new InputStreamReader(input, StandardCharsets.UTF_8))) {
        while (reader.readLine() != null) {
            lines++;
        }
    }
    return lines;
}

// TEST: no file, no disk, no cleanup, no dependency on the environment
public void testCountLines() {
    String content = "BOOK;978-0000000001;Effective Java\n"
                   + "BOOK;978-0000000002;Design Patterns\n";

    InputStream simulated =
            new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));

    int result = countLines(simulated);
    // 2 is expected
}

A design lesson worth engraving: a method that receives an InputStream can be tested with an array in memory; one that receives a String path forces you to create test files, clean them up and depend on the file system. Accept the most general abstraction that serves you. This will be picked up again with JUnit in 11-04.

And a practical note: ByteArrayOutputStream does not need closing —its close() does nothing— and that is why in the example it is outside the try-with-resources. If it were inside, it would be closed before toByteArray() could be called. It is one of the very few legitimate exceptions to the rule of closing everything, and one 06-06 already warned about.

Its equivalents in the character family are StringWriter and StringReader, useful when what you accumulate is text:

StringWriter memory = new StringWriter();
try (PrintWriter output = new PrintWriter(memory)) {
    output.printf("%-25s %8.2f%n", "Effective Java", 3.75);
}
String result = memory.toString();

  1. transferTo: the modern way of copying

Java 9 added a method that reduces the whole copy loop to one line:

public long transferTo(OutputStream target) throws IOException
import java.io.*;

public class ModernCopy {

    /** Complete copy. Java 9+. */
    public static long copy(File source, File target) throws IOException {
        try (InputStream input = new BufferedInputStream(new FileInputStream(source));
             OutputStream output = new BufferedOutputStream(new FileOutputStream(target))) {

            return input.transferTo(output);        // the whole loop, in one line
        }
    }
}

Comparison with the manual loop:

Manual loop transferTo
Lines of code 6-8 1
Risk of forgetting (block, 0, read) High None
Block size You choose it Internal (8 KB)
Progress during the copy Can be reported Cannot be
Transforming the data on the fly Yes No
Available since Always Java 9

Use transferTo when you only want to copy. Use the loop when you need to report progress, transform or stop midway:

/** Copy reporting progress: here the loop is still necessary. */
public long copyWithProgress(File source, File target) throws IOException {
    long size = source.length();
    long copied = 0;
    int lastPercentage = -1;

    try (InputStream input = new BufferedInputStream(new FileInputStream(source));
         OutputStream output = new BufferedOutputStream(new FileOutputStream(target))) {

        byte[] block = new byte[8192];
        int read;
        while ((read = input.read(block)) != -1) {
            output.write(block, 0, read);
            copied += read;

            int percentage = (int) (copied * 100 / Math.max(1, size));
            if (percentage != lastPercentage && percentage % 10 == 0) {
                System.out.printf("  %d%% (%d of %d bytes)%n", percentage, copied, size);
                lastPercentage = percentage;
            }
        }
    }
    return copied;
}

And in 07-06 you will see the highest-level form of all, which does not even open streams:

Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);

  1. Block size and performance

How much does block size matter? These figures, illustrative and measured over a 100 MB file on an ordinary SSD, give the shape of the curve:

Technique Indicative time Relative
read() byte by byte, unbuffered ~95,000 ms 1x (reference)
read() byte by byte, with BufferedInputStream ~1,400 ms 68x
Blocks of 512 bytes ~420 ms 226x
Blocks of 8 KB ~180 ms 528x
Blocks of 64 KB ~165 ms 576x
Blocks of 1 MB ~170 ms 559x
transferTo ~150 ms 633x
Files.copy (07-06) ~130 ms 731x

The three conclusions:

1. The jump that matters is the first one. From unbuffered byte by byte to anything buffered there is a factor of 70. From 8 KB to 64 KB there is 8%. If you are only going to make one optimisation, that is the one.

2. 8 KB is the sweet spot. It is no accident: it coincides with the typical file system block size, and it is the default value of BufferedInputStream and of transferTo. Going higher gives diminishing returns and consumes memory: a hundred concurrent copies with 1 MB buffers are 100 MB of heap.

3. Too large a buffer can be worse. It stops fitting in the processor cache and increases pressure on the garbage collector. More is not better beyond a certain point.

Practical rule: 8 KB unless you have measured and have a reason. And before touching the block size, make sure there is a buffer: that is where 99% of the possible improvement lies.

  1. Which stream do I choose for what

The complete decision table for the module:

I need to... Class Family
Read text from a file BufferedReader over FileReader Characters
Write text to a file PrintWriter or BufferedWriter over FileWriter Characters
Read text from the console BufferedReader over InputStreamReader(System.in) Characters
Read text from the network BufferedReader over InputStreamReader(socket) Characters
Read a binary file BufferedInputStream over FileInputStream Bytes
Write a binary file BufferedOutputStream over FileOutputStream Bytes
Copy a file transferTo, or Files.copy (07-06) Bytes
Read/write primitives with a fixed format DataInputStream / DataOutputStream Bytes
Read/write complete objects ObjectInputStream / ObjectOutputStream (07-05) Bytes
Work with data in memory ByteArrayInputStream / ByteArrayOutputStream Bytes
Accumulate text in memory StringWriter / StringReader Characters
Convert bytes into characters InputStreamReader with a charset Bridge
Convert characters into bytes OutputStreamWriter with a charset Bridge

And the four composition rules that sum up the lesson:

  1. The node goes inside; the filters, outside.
  2. The buffer goes as close as possible to the node.
  3. The charset is specified at the bridge, which is the only place where translation happens.
  4. Close the outermost layer: closing propagates in cascade.

Common Mistakes and Tips

  • Using Reader/Writer with binary data. It destroys the file irreversibly: the bytes that do not form valid characters are replaced by the replacement character. The image no longer opens.
  • Using InputStream/OutputStream with text and no bridge. It works until an é shows up, and then it gets split in the middle of a block.
  • Putting the buffer in the wrong place. BufferedInputStream(DataInputStream(...)) contributes nothing: the buffer must sit between the node and the format filters.
  • write(block) instead of write(block, 0, read). The most destructive mistake in the lesson: the copied file comes out larger and with rubbish at the end.
  • Assuming read(byte[]) fills the array. It never promises to. With sockets it fails constantly.
  • Comparing bytes without & 0xFF. signature[0] == 0xFF is always false because byte is signed. Mask when you need the numeric value.
  • Believing that write(300) writes 300. It writes 44: it keeps the low 8 bits, without warning.
  • Reading a DataInputStream in a different order from the written one. It gives no error: it returns perfectly valid and completely false values.
  • Using writeUTF for interchange with other languages. It is not standard UTF-8, it is Java's modified UTF-8.
  • Closing ByteArrayOutputStream before toByteArray(). It is not fatal —its close() does nothing— but it gives away that you have not understood what it is for.
  • Forgetting that System.out is a byte PrintStream. That is why accents come out wrong in some consoles. Wrap it in an OutputStreamWriter with a charset if you need control.
  • Building nested chains where an intermediate layer can fail. If the intermediate constructor throws, the already-built layer is left unclosed. For critical code, declare each layer separately in the try-with-resources.
  • Looking for performance in the block size before putting in a buffer. The buffer gives a factor of 70; the block size, 8%.
  • Tip: read chains from the inside out. new DataInputStream(new BufferedInputStream(new FileInputStream(f))) is "I open the file, I put a buffer on it, and I read types". With that reading, no java.io expression is ever intimidating again.
  • Tip: accept InputStream, not String path. It is the design decision that makes your code testable without files and reusable with the network and memory.
  • Tip: check the format by the signature, not by the extension. The first bytes do not lie; the file name does.
  • Tip: if in doubt between text and binary, choose binary. Treating text as bytes keeps it intact. The other way round destroys it.

Exercises

Exercise 1: hexadecimal viewer

Write HexViewer with a method public static void dump(String path, int maxBytes) that shows the content of a file in the classic three-column format:

00000000  FF D8 FF E0 00 10 4A 46  49 46 00 01 01 00 00 48  |......JFIF.....H|
00000010  00 48 00 00 FF DB 00 43  00 08 06 06 07 06 05 08  |.H.....C........|

Requirements:

  1. Offset in hexadecimal with 8 digits.
  2. 16 bytes per line, grouped in eights.
  3. ASCII column: printable bytes (32 to 126) as a character, the rest as ..
  4. Block reading, never byte by byte.
  5. A main that also detects the format by its signature (JPEG, PNG, GIF, PDF, ZIP, Java class).

Exercise 2: performance comparator

Write PerformanceComparison measuring five ways of copying the same file:

  1. FileInputStream.read() byte by byte, unbuffered.
  2. With BufferedInputStream/BufferedOutputStream, byte by byte.
  3. Blocks of 512 bytes.
  4. Blocks of 8192 bytes.
  5. transferTo.

Requirements:

  1. First generate a 5 MB test file with pseudorandom data.
  2. Verify in each case that the copy has the same size as the original.
  3. Show a table with the time and the relative improvement over the first method.
  4. Add a comment explaining why the big jump is between methods 1 and 2.

Warn in a comment that this is not a rigorous benchmark: the operating system cache and JVM warm-up distort the measurements, and to measure seriously you use JMH (mentioned in 10-07).

Exercise 3: binary loan store

Write LoanStore to save and retrieve BiblioTech's loans in a proprietary binary format using DataOutputStream/DataInputStream:

  1. A header with a magic number (0x424C4942, which is BLIB), the format version (1) and the number of records.
  2. For each loan: reference (writeUTF), material reference, employee identifier, start day, return day (−1 if not returned) and accumulated fine.
  3. When reading, validate the magic number and throw IOException if it does not match, so as not to interpret somebody else's file as your own.
  4. Also validate the version, and reject future versions with a clear message.
  5. Use BufferedInputStream/BufferedOutputStream and writing to a temporary file with a rename (07-02).
  6. A main that saves three loans, retrieves them and shows them, and afterwards tries to read a file with an incorrect magic number to check the rejection.

Solutions

Solution 1

package com.nexussoftware.bibliotech.util;

import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

/**
 * Hexadecimal file viewer, in the classic three-column format.
 *
 * It works with BYTE STREAMS because the content could be anything:
 * using a Reader would corrupt the data before it could be shown.
 */
public class HexViewer {

    private static final int BYTES_PER_LINE = 16;
    private static final int FIRST_PRINTABLE = 32;
    private static final int LAST_PRINTABLE = 126;

    public static void dump(String path, int maxBytes) throws IOException {
        try (InputStream input = new BufferedInputStream(new FileInputStream(path))) {

            byte[] line = new byte[BYTES_PER_LINE];
            int offset = 0;
            int read;

            // readNBytes (Java 9+) insists until it fills the array or reaches
            // the end. With plain read() we could get fewer than 16 bytes in
            // the middle of the file and the dump would be misaligned.
            while (offset < maxBytes
                    && (read = input.readNBytes(line, 0, BYTES_PER_LINE)) > 0) {

                printLine(offset, line, read);
                offset += read;
            }
        }
    }

    private static void printLine(int offset, byte[] data, int howMany) {
        StringBuilder hex   = new StringBuilder();
        StringBuilder ascii = new StringBuilder();

        for (int i = 0; i < BYTES_PER_LINE; i++) {
            if (i == 8) {
                hex.append(' ');                    // separation of the two groups
            }
            if (i < howMany) {
                // & 0xFF: without this, a negative byte would print as FFFFFFxx
                int b = data[i] & 0xFF;
                hex.append(String.format("%02X ", b));
                ascii.append(isPrintable(b) ? (char) b : '.');
            } else {
                hex.append("   ");                  // padding of the last line
                ascii.append(' ');
            }
        }

        System.out.printf("%08X  %s |%s|%n", offset, hex, ascii);
    }

    private static boolean isPrintable(int b) {
        return b >= FIRST_PRINTABLE && b <= LAST_PRINTABLE;
    }

    /** Detects the format by its SIGNATURE. The extension is not reliable. */
    public static String detectFormat(String path) throws IOException {
        try (InputStream input = new BufferedInputStream(new FileInputStream(path))) {
            byte[] f = input.readNBytes(8);

            if (startsWith(f, 0xFF, 0xD8, 0xFF))              { return "JPEG"; }
            if (startsWith(f, 0x89, 0x50, 0x4E, 0x47))        { return "PNG"; }
            if (startsWith(f, 0x47, 0x49, 0x46, 0x38))        { return "GIF"; }
            if (startsWith(f, 0x25, 0x50, 0x44, 0x46))        { return "PDF"; }
            if (startsWith(f, 0x50, 0x4B, 0x03, 0x04))        { return "ZIP (or JAR, DOCX...)"; }
            if (startsWith(f, 0xCA, 0xFE, 0xBA, 0xBE))        { return "JAVA CLASS"; }
            if (startsWith(f, 0xAC, 0xED))                    { return "SERIALISED OBJECT (07-05)"; }
            return "UNKNOWN (possibly text)";
        }
    }

    /** Compares the first bytes with the given signature. */
    private static boolean startsWith(byte[] data, int... signature) {
        if (data.length < signature.length) {
            return false;
        }
        for (int i = 0; i < signature.length; i++) {
            if ((data[i] & 0xFF) != signature[i]) {   // the & 0xFF, essential again
                return false;
            }
        }
        return true;
    }

    public static void main(String[] args) throws IOException {
        String path = (args.length > 0) ? args[0] : "covers/effective-java.jpg";

        System.out.println("File   : " + path);
        System.out.println("Format : " + detectFormat(path));
        System.out.println();
        dump(path, 64);
    }
}

Output with a JPEG:

File   : covers/effective-java.jpg
Format : JPEG

00000000  FF D8 FF E0 00 10 4A 46  49 46 00 01 01 00 00 48  |......JFIF.....H|
00000010  00 48 00 00 FF DB 00 43  00 08 06 06 07 06 05 08  |.H.....C........|
00000020  07 07 07 09 09 08 0A 0C  14 0D 0C 0B 0B 0C 19 12  |................|
00000030  13 0F 14 1D 1A 1F 1E 1D  1A 1C 1C 20 24 2E 27 20  |........... $.' |

The two teaching points: the & 0xFF appears three times in the solution and all three are essential —without it, a negative byte prints as FFFFFFC3 and every signature comparison fails. And readNBytes(array, 0, n) instead of read(array) guarantees that each line has its 16 bytes: with plain read(), a short read in the middle of the file would misalign the whole dump.

Solution 2

package com.nexussoftware.bibliotech.util;

import java.io.*;
import java.util.Random;

/**
 * Comparison of five binary copy techniques.
 *
 * WARNING: this is NOT a rigorous benchmark. The operating system page cache,
 * the JIT compiler and the garbage collector distort the measurements; the
 * first run always comes out worse. It serves to see the ORDERS OF MAGNITUDE,
 * which is what matters here. To measure seriously you use JMH, which is
 * mentioned in 10-07.
 */
public class PerformanceComparison {

    private static final String SOURCE = "performance-test.bin";
    private static final String TARGET = "performance-copy.bin";
    private static final int SIZE_MB   = 5;

    // ---------- The five techniques ----------

    /** 1. Byte by byte WITHOUT a buffer: one system call per byte. */
    static long byteByByteNoBuffer(File s, File t) throws IOException {
        try (InputStream in = new FileInputStream(s);
             OutputStream out = new FileOutputStream(t)) {
            int b;
            long n = 0;
            while ((b = in.read()) != -1) {
                out.write(b);
                n++;
            }
            return n;
        }
    }

    /** 2. Byte by byte WITH a buffer: the buffer absorbs the system calls. */
    static long byteByByteBuffered(File s, File t) throws IOException {
        try (InputStream in = new BufferedInputStream(new FileInputStream(s));
             OutputStream out = new BufferedOutputStream(new FileOutputStream(t))) {
            int b;
            long n = 0;
            while ((b = in.read()) != -1) {
                out.write(b);
                n++;
            }
            return n;
        }
    }

    /** 3 and 4. In blocks of the given size. */
    static long inBlocks(File s, File t, int size) throws IOException {
        try (InputStream in = new FileInputStream(s);
             OutputStream out = new FileOutputStream(t)) {
            byte[] block = new byte[size];
            int read;
            long n = 0;
            while ((read = in.read(block)) != -1) {
                out.write(block, 0, read);        // the limit, ALWAYS
                n += read;
            }
            return n;
        }
    }

    /** 5. transferTo (Java 9+). */
    static long withTransferTo(File s, File t) throws IOException {
        try (InputStream in = new BufferedInputStream(new FileInputStream(s));
             OutputStream out = new BufferedOutputStream(new FileOutputStream(t))) {
            return in.transferTo(out);
        }
    }

    // ---------- Measurement infrastructure ----------

    interface Technique {
        long copy(File source, File target) throws IOException;
    }

    static void generateTestFile(File f, int megabytes) throws IOException {
        if (f.exists() && f.length() == megabytes * 1024L * 1024L) {
            return;                                // already generated
        }
        Random random = new Random(42);             // fixed seed: reproducible
        byte[] block = new byte[1024 * 1024];

        try (OutputStream out = new BufferedOutputStream(new FileOutputStream(f))) {
            for (int i = 0; i < megabytes; i++) {
                random.nextBytes(block);
                out.write(block);
            }
        }
    }

    public static void main(String[] args) throws IOException {
        File source = new File(SOURCE);
        File target = new File(TARGET);

        System.out.println("Generating a test file of " + SIZE_MB + " MB...");
        generateTestFile(source, SIZE_MB);
        System.out.println("Size: " + source.length() + " bytes");
        System.out.println();

        String[] names = {
                "1. Byte by byte, NO buffer",
                "2. Byte by byte, buffered",
                "3. Blocks of 512 B",
                "4. Blocks of 8 KB",
                "5. transferTo"
        };
        Technique[] techniques = {
                PerformanceComparison::byteByByteNoBuffer,      // method references (04-06)
                PerformanceComparison::byteByByteBuffered,
                (s, t) -> inBlocks(s, t, 512),
                (s, t) -> inBlocks(s, t, 8192),
                PerformanceComparison::withTransferTo
        };

        long reference = 0;

        System.out.printf("%-28s %10s %10s %12s%n", "TECHNIQUE", "TIME", "MB/s", "SPEEDUP");
        System.out.println("-".repeat(64));

        for (int i = 0; i < techniques.length; i++) {
            long start = System.nanoTime();
            long copied = techniques[i].copy(source, target);
            long ms = (System.nanoTime() - start) / 1_000_000;

            // VERIFICATION: a fast but incorrect copy is worth nothing
            if (copied != source.length() || target.length() != source.length()) {
                System.out.printf("%-28s  INCORRECT COPY: %d bytes%n",
                        names[i], target.length());
                continue;
            }

            if (i == 0) {
                reference = ms;
            }
            double mbs = (source.length() / 1024.0 / 1024.0) / (ms / 1000.0);

            System.out.printf("%-28s %8d ms %10.1f %11.1fx%n",
                    names[i], ms, mbs, (double) reference / Math.max(1, ms));
        }

        System.out.println();
        System.out.println("The big jump is between 1 and 2, and the cause is a single one:");
        System.out.println("without a buffer there is ONE SYSTEM CALL PER BYTE (5 million);");
        System.out.println("with a buffer, one every 8192 bytes (about 640). The rest of");
        System.out.println("the improvements are marginal in comparison.");

        target.delete();
    }
}

Typical output:

Generating a test file of 5 MB...
Size: 5242880 bytes

TECHNIQUE                          TIME       MB/s      SPEEDUP
----------------------------------------------------------------
1. Byte by byte, NO buffer       4680 ms        1.1         1.0x
2. Byte by byte, buffered          78 ms       64.2        60.0x
3. Blocks of 512 B                 26 ms      192.5       180.0x
4. Blocks of 8 KB                   9 ms      555.6       520.0x
5. transferTo                       7 ms      714.3       668.7x

The big jump is between 1 and 2, and the cause is a single one:
without a buffer there is ONE SYSTEM CALL PER BYTE (5 million);
with a buffer, one every 8192 bytes (about 640). The rest of
the improvements are marginal in comparison.

What to take away: the improvement from 1 to 2 is 60x and consists of writing two more words (Buffered...). The improvement from 4 to 5 is 28%. When somebody asks where to start optimising I/O, the answer is always the same: put in a buffer. Notice too the size verification: a fast technique producing an incorrect copy —the classic unbounded write(block)— is detected there and not counted.

Solution 3

package com.nexussoftware.bibliotech.service;

import java.io.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.logging.Logger;

/**
 * Binary loan store for BiblioTech.
 *
 * Proprietary format (BLIB v1):
 *   [4 bytes] magic number 0x424C4942 = "BLIB"
 *   [4 bytes] format version
 *   [4 bytes] number of records
 *   per record:
 *     UTF  loan reference
 *     UTF  material reference
 *     UTF  employee identifier
 *     INT  start day
 *     INT  return day (-1 if not returned)
 *     DOUBLE accumulated fine
 *
 * This format is COMPACT and FAST, but it is NOT readable or versionable in
 * source control. For the catalogue, BiblioTech will use CSV (07-07); this
 * stands as an internal binary cache.
 */
public class LoanStore {

    private static final Logger LOG = Logger.getLogger(LoanStore.class.getName());

    /** "BLIB" in ASCII. It identifies the file as ours. */
    private static final int MAGIC_NUMBER = 0x424C4942;
    private static final int CURRENT_VERSION = 1;

    /** Flat record of a loan, exactly as it is stored. */
    public record LoanRecord(String reference, String materialReference,
                             String employeeId, int startDay,
                             int returnDay, double fine) {

        public LoanRecord {
            Objects.requireNonNull(reference, "The reference cannot be null");
            Objects.requireNonNull(materialReference, "The material cannot be null");
            Objects.requireNonNull(employeeId, "The employee cannot be null");
            if (fine < 0) {
                throw new IllegalArgumentException("The fine cannot be negative: " + fine);
            }
        }

        public boolean isReturned() { return returnDay >= 0; }
    }

    private final File file;

    public LoanStore(String path) {
        this.file = new File(Objects.requireNonNull(path, "The path cannot be null"));
    }

    // ---------------------------- WRITING ----------------------------

    /**
     * Saves all the loans. ATOMIC write (07-02): it writes to a
     * temporary file and only renames at the end.
     */
    public void save(List<LoanRecord> loans) throws IOException {
        Objects.requireNonNull(loans, "The list cannot be null");

        File temp = new File(file.getAbsolutePath() + ".tmp");
        boolean completed = false;

        try {
            try (DataOutputStream output = new DataOutputStream(
                    new BufferedOutputStream(new FileOutputStream(temp)))) {

                // Header
                output.writeInt(MAGIC_NUMBER);
                output.writeInt(CURRENT_VERSION);
                output.writeInt(loans.size());

                // Records, in the SAME order in which they will be read
                for (LoanRecord l : loans) {
                    output.writeUTF(l.reference());
                    output.writeUTF(l.materialReference());
                    output.writeUTF(l.employeeId());
                    output.writeInt(l.startDay());
                    output.writeInt(l.returnDay());
                    output.writeDouble(l.fine());
                }
            }   // close() -> flush guaranteed

            if (file.exists() && !file.delete()) {
                throw new IOException("Could not replace " + file.getName());
            }
            if (!temp.renameTo(file)) {
                throw new IOException("Could not rename " + temp.getName());
            }
            completed = true;

            LOG.info(() -> String.format("Saved %d loans in %s (%d bytes)",
                    loans.size(), file.getName(), file.length()));

        } finally {
            if (!completed && temp.exists() && !temp.delete()) {
                LOG.warning(() -> "Temporary file left undeleted: " + temp.getAbsolutePath());
            }
        }
    }

    // ---------------------------- READING ----------------------------

    /**
     * Loads the saved loans.
     *
     * @throws IOException if the file is not ours, the version is not
     *                     supported or it is truncated
     */
    public List<LoanRecord> load() throws IOException {
        if (!file.exists()) {
            LOG.warning(() -> "There is no store at " + file.getAbsolutePath()
                    + "; returning an empty list");
            return new ArrayList<>();               // graceful degradation (07-01)
        }

        try (DataInputStream input = new DataInputStream(
                new BufferedInputStream(new FileInputStream(file)))) {

            // 1. VALIDATE THE MAGIC NUMBER.
            //    Without this, somebody else's file would be interpreted as
            //    ours and would produce absurd data WITH NO ERROR AT ALL. It is
            //    the difference between failing and corrupting.
            int magic = input.readInt();
            if (magic != MAGIC_NUMBER) {
                throw new IOException(String.format(
                        "%s is not a BiblioTech store "
                                + "(magic number 0x%08X, expected 0x%08X)",
                        file.getName(), magic, MAGIC_NUMBER));
            }

            // 2. VALIDATE THE VERSION
            int version = input.readInt();
            if (version > CURRENT_VERSION) {
                throw new IOException(String.format(
                        "The store uses version %d of the format and this version "
                                + "of BiblioTech only understands up to %d. "
                                + "Update the application.",
                        version, CURRENT_VERSION));
            }

            // 3. Number of records, with a minimum sanity check
            int howMany = input.readInt();
            if (howMany < 0) {
                throw new IOException("Invalid number of records: " + howMany);
            }

            List<LoanRecord> loans = new ArrayList<>(howMany);

            for (int i = 0; i < howMany; i++) {
                try {
                    loans.add(new LoanRecord(
                            input.readUTF(),        // the ORDER must match
                            input.readUTF(),        // save() exactly
                            input.readUTF(),
                            input.readInt(),
                            input.readInt(),
                            input.readDouble()));

                } catch (EOFException e) {
                    // TRUNCATED file: a previous write was cut short.
                    // The cause is chained (06-03) and it says how many there were.
                    throw new IOException(String.format(
                            "Truncated store: %d records were expected and only "
                                    + "%d are complete", howMany, i), e);
                }
            }

            LOG.info(() -> String.format("Loaded %d loans from %s (format v%d)",
                    loans.size(), file.getName(), version));
            return loans;
        }
    }

    // ---------------------------- DEMONSTRATION ----------------------------

    public static void main(String[] args) throws IOException {
        LoanStore store = new LoanStore("data/loans.blib");

        List<LoanRecord> originals = List.of(
                new LoanRecord("LN-0001", "978-0000000001", "EMP-001", 10, 22,  1.75),
                new LoanRecord("LN-0002", "978-0000000002", "EMP-002", 12, -1,  0.00),
                new LoanRecord("LN-0003", "978-0000000003", "EMP-003",  5, 45, 20.00));

        store.save(originals);

        System.out.println("=== RETRIEVED ===");
        for (LoanRecord l : store.load()) {
            System.out.printf("  %s  material=%s  employee=%s  days %d..%s  fine %.2f%n",
                    l.reference(), l.materialReference(), l.employeeId(),
                    l.startDay(),
                    l.isReturned() ? String.valueOf(l.returnDay()) : "pending",
                    l.fine());
        }

        // --- Checking the rejection of somebody else's file ---
        File impostor = new File("data/impostor.blib");
        try (DataOutputStream d = new DataOutputStream(new FileOutputStream(impostor))) {
            d.writeInt(0xDEADBEEF);      // incorrect magic number
            d.writeInt(1);
            d.writeInt(0);
        }

        System.out.println();
        System.out.println("=== SOMEBODY ELSE'S FILE ===");
        try {
            new LoanStore(impostor.getPath()).load();
            System.out.println("  ERROR: it should have been rejected");
        } catch (IOException e) {
            System.out.println("  Correctly rejected:");
            System.out.println("  " + e.getMessage());
        }
        impostor.delete();
    }
}

Output:

=== RETRIEVED ===
  LN-0001  material=978-0000000001  employee=EMP-001  days 10..22  fine 1.75
  LN-0002  material=978-0000000002  employee=EMP-002  days 12..pending  fine 0.00
  LN-0003  material=978-0000000003  employee=EMP-003  days 5..45  fine 20.00

=== SOMEBODY ELSE'S FILE ===
  Correctly rejected:
  impostor.blib is not a BiblioTech store (magic number 0xDEADBEEF, expected 0x424C4942)

The four ideas to take away:

  1. The magic number is essential in any proprietary binary format. Without it, readUTF() over somebody else's file reads two arbitrary bytes as a length, tries to read that many and returns rubbish —or throws an incomprehensible EOFException. With it, the message says exactly what is happening. It is the same philosophy as 06-04: an exception must carry what whoever reads it needs in order to decide.
  2. The format version is declared from day one. When in a year's time you want to add a field, the old file will still be readable because you will know which version it has. Adding it later is impossible: the files already written do not carry it.
  3. The EOFException is translated. Its default message says nothing. Translated with the context —"3 records were expected and only 2 are complete"— it points straight at the cause: an earlier write that was cut short, exactly what the atomic writing of 07-02 exists to avoid.
  4. The reading and writing order must be identical. There is no way of checking it automatically in this format, and that is its weak point compared with the text of 07-07. A record with the fields in the same order helps make the mistake jump out when you read the code.

Conclusion

You no longer use java.io from memory: you understand its design.

You have the model of the stream: an ordered sequence, unidirectional, sequential and agnostic about the source, with its vocabulary of source, sink and direction. That third property is what gives the whole hierarchy its value: a method that accepts an InputStream works with a file, with a socket from module 9 or with an array in memory, without changing a line. It is module 3's polymorphism applied to I/O. And you know that these streams have nothing to do with the collections Streams API of 10-04, beyond the name.

You know why there are two hierarchies and not one: InputStream/OutputStream for bytes, Reader/Writer for characters, because a character is not a byte —é is two, € is three— and because characters can be split across two read blocks. You have the decision rule with no excuses: if it is readable text, characters; if not, bytes; and if in doubt, bytes, because treating text as bytes preserves it and the other way round destroys it.

You tell node classes —which connect to a real resource and are recognisable because their constructor takes a path, a file or an array— from filter classes —which wrap another stream and are recognisable because their constructor takes a stream. And from that comes the rule that decodes any nested expression: the node goes inside, the filters outside, and it is read from the inside out.

You have seen the Decorator pattern in its canonical example, with the argument that justifies it: without it you would need twelve classes for three sources and two capabilities, and with it five are enough. You know its three practical consequences: order matters —the buffer goes as close as possible to the node—, closing the outer one closes the whole chain in cascade, and the abstraction is preserved because every decorator is the type it decorates. You also know that an intermediate layer failing to construct leaves the previous one open, and how to avoid that by declaring each layer separately. The pattern is formalised in 12-02; here you have seen it where it is best appreciated.

You know the bridges InputStreamReader and OutputStreamWriter, and you know they are the exact point where the encoding you have been specifying for three lessons is decided: on one side meaningless bytes, on the other characters. You now understand why FileReader extends InputStreamReader —it is a bridge with the file already wired in— and why the bridge is essential for reading text from System.in, from a socket or from a resource in the .jar, where no FileReader is possible.

You know how to read and write binary data with FileInputStream/FileOutputStream, byte by byte and in blocks, with the three points of the read(byte[]) contract —it returns how many it read, it does not fill the array, and only -1 means the end— and with the rule that avoids the most destructive mistake of the lesson: write(block, 0, read), never write(block). You know the signed-byte trap and the & 0xFF that resolves it, and you know that write(300) writes 44 without warning. With that, CoverManager copies BiblioTech's images without corrupting them and detects their format by the signature, not by the extension.

You know DataInputStream/DataOutputStream for primitive types in a fixed, portable and compact format, with its three virtues and its three limitations —it is not self-describing, it is not readable, it is fragile under change— and with the warning that writeUTF is not standard UTF-8. You know ByteArrayInputStream/ByteArrayOutputStream and their best use, which is testing I/O code without touching the disk, with the design lesson that goes with it: accept InputStream, not String path. And you know Java 9's transferTo, with the criterion for choosing between it and the manual loop: the loop only if you need progress, transformation or a stop.

And you have the figures that order your performance priorities: the buffer gives a factor of sixty; the block size, eight per cent. Eight kilobytes unless you have measured. Before touching anything, put in the buffer.

That buffer —the one appearing in every chain, the one producing the performance jump, the one you have been hearing about for three lessons— is the protagonist of the next one. In 07-04, BufferedReader and BufferedWriter, you will see exactly what it does internally and why it reduces the work so much, with the diagram of the reads with and without it; the complete contract of readLine() —which returns null at the end and does not include the line break— and the canonical loop that uses it, plus why ready() is not valid as an end condition; newLine() and flushing when writing; exactly what each layer of the PrintWriter + BufferedWriter + FileWriter trio contributes; how to process a one-gigabyte file with constant memory; and the definitive comparison between BufferedReader and the Scanner you have been using since module 1. And BiblioTech will unveil CatalogImporter, capable of reading thousands of lines, validating them one by one and returning a report of what was loaded and what was discarded.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved