Skip to content

VOL 01 / CH 15 / LESSON 01

15.1 I/O Streams and the Persistence Boundary ​

After learning about exceptions, collections, generics, and resource cleanup, allocate about two hours for this article and run two temporary directory experiments using Java 17. Upon completion, you'll be able to choose the right API for binary versus text data, process large inputs in a streaming fashion, and clearly understand who is responsible for closing resources.

Process Memory Is Not Persistent ​

An object that exists only in process memory disappears when the process exits. If the next run must restore it, the program has to write the data to an external destination in a format it can read again.

I/O means reading from a source or writing to a destination. These may be files, network connections, process pipes, or memory buffers; an in-memory stream does not itself provide persistence. This page uses files as an example because files are easy to verify repeatedly.

Reading Route ​

  1. First determine whether the data is bytes or text;
  2. Understand that a single read might only return a portion of the data;
  3. Use buffering and streaming to manage memory and control call costs;
  4. Use try-with-resources to clearly define resource ownership;
  5. Distinguish flush, close, and durable persistence;
  6. Understand the compatibility and security boundaries of native Java serialization;
  7. Complete a log analyzer that stops when it encounters FATAL.

1. A stream is a directional data channel ​

text
Source → InputStream / Reader → Program
Program → OutputStream / Writer → Destination

Bytes are transmitted by InputStream and OutputStream. Reader and Writer transfer UTF-16 code units. Bridges such as InputStreamReader and OutputStreamWriter decode or encode bytes using a charset. An in-memory character stream such as StringReader needs no byte conversion.

DataInbound/OutboundCommon Types
Images, ZIP files, audio, protocol framesbyte streamInputStream, OutputStream
UTF-8 logs, CSV, configuration textcharacter streamReader, Writer
Known small filesOne-time convenient methodFiles.readString, readAllBytes
Large files or sizes determined externallyStreaming loopBufferedReader, buffered byte array

If you're unsure about a file's type, treat it as raw bytes. Arbitrarily decoding and re-encoding will alter binary content that can't be represented by that encoding.

2. Byte Streams: Copying a binary image ​

java
try (InputStream input = Files.newInputStream(source);
     OutputStream output = Files.newOutputStream(target)) {
    byte[] buffer = new byte[8192];
    int read;
    while ((read = input.read(buffer)) != -1) {
        output.write(buffer, 0, read);
    }
}

Source and target must be distinct files, including through hard links or symbolic links; otherwise opening the output can truncate the input.

read(buffer) returns the actual number of bytes written to the array. The last read often falls short of the buffer length, so writing requires using output.write(buffer, 0, read). Writing the entire array will carry over the residual bytes from the previous read to the file end.

-1 indicates the end of input. A single read is not obligated to fill the array; network streams often return shorter fragments. A buffer of length 0 is a useless edge case and should be avoided.

For ordinary OutputStream, write(buffer, offset, length) either writes the specified range or throws an exception; an exception may occur after a partial write. FileChannel, socket channels, and other NIO channels might process only part of the buffer; when using them, check ByteBuffer.hasRemaining().

Convenient Copy ​

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

The standard library works well for routine file copying, as well as for streaming to or from files. Still, writing your own loops is worth mastering, because checksums, rate limiting, progress tracking, and protocol parsing all require logic inserted between data blocks.

3. Character Streams: UTF-8 text report ​

Text files are still made up of bytes. Character encoding specifies how bytes correspond to characters.

java
try (BufferedReader reader = Files.newBufferedReader(
        Path.of("dispatch.txt"), StandardCharsets.UTF_8)) {
    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
}

readLine() returns a line of content with trailing \n, \r, or \r\n removed. If the original line endings are to be preserved, using only readLine() is insufficient; character or byte-level preservation of the delimiter is required.

When writing text, encode it clearly:

java
try (BufferedWriter writer = Files.newBufferedWriter(
        Path.of("dispatch-copy.txt"), StandardCharsets.UTF_8)) {
    writer.write("a developer lends out BK-15");
    writer.newLine();
}

newLine() uses the current platform line separator. When the protocol requires a fixed \n, the protocol-specified character should be written directly.

Which APIs default to UTF-8 ​

These methods default to UTF-8. readString/writeString arrived in Java 11; the buffered factories without charset parameters arrived in Java 8:

  • Files.readString, Files.writeString;
  • Files.newBufferedReader, Files.newBufferedWriter.

In Java 17, FileReader/FileWriter constructors without a charset use the default charset. Java 11+ provides a constructor with Charset, but new code finds the Files factory easier for expressing paths, encodings, and open options.

java
new FileReader("dispatch.txt", StandardCharsets.UTF_8); // Java 11+

The value of UTF-8 isn't just in compatibility with old APIs, it puts file format contracts directly in the code.

What happens when the input contains invalid bytes? ​

new String(bytes, UTF_8) replaces erroneous input, while Files.newBufferedReader(path, UTF_8) strictly decodes and reports errors. This example configures a CharsetDecoder explicitly. Save it as StrictDecodeDemo.java to run it:

java
import java.nio.ByteBuffer;
import java.nio.charset.*;

public class StrictDecodeDemo {
    public static void main(String[] args) throws CharacterCodingException {
        CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT)
                .onUnmappableCharacter(CodingErrorAction.REPORT);
        try {
            decoder.decode(ByteBuffer.wrap(new byte[]{(byte) 0xC3, 0x28}));
            throw new AssertionError("Invalid UTF-8 was accepted");
        } catch (MalformedInputException expected) {
            System.out.println("Invalid UTF-8 rejected");
        }
    }
}

The bytes C3 28 are invalid UTF-8. The program catches MalformedInputException and prints Invalid UTF-8 rejected.

4. Complete Experiment: Binary Copy and Text Reading ​

The program creates temporary directories on its own, without relying on manually prepared map.jpg or dispatch.txt. TemporaryFiles participates in try-with-resources and attempts cleanup, preserving cleanup failures as primary or suppressed exceptions. Forced process termination can still leave temporary files.

java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class StreamDemo {
    public static void main(String[] args) throws IOException {
        Path root = Files.createTempDirectory("stream-demo-");
        Path binary = root.resolve("map.bin");
        Path copy = root.resolve("map-copy.bin");
        Path dispatch = root.resolve("dispatch.txt");

        TemporaryFiles cleanup = new TemporaryFiles(copy, dispatch, binary, root);
        try (cleanup) {
            byte[] payload = {0, 1, 2, 127, (byte) 128, (byte) 255};
            try (OutputStream output = Files.newOutputStream(binary)) {
                output.write(payload);
            }

            try (InputStream input = Files.newInputStream(binary);
                 OutputStream output = Files.newOutputStream(copy)) {
                byte[] buffer = new byte[4];
                int read;
                while ((read = input.read(buffer)) != -1) {
                    output.write(buffer, 0, read);
                }
            }

            List<String> expectedLines = List.of("a developer lends out BK-15", "Inventory: 1");
            try (BufferedWriter writer = Files.newBufferedWriter(
                    dispatch, StandardCharsets.UTF_8)) {
                for (String line : expectedLines) {
                    writer.write(line);
                    writer.newLine();
                }
            }

            List<String> actualLines = new ArrayList<>();
            try (BufferedReader reader = Files.newBufferedReader(
                    dispatch, StandardCharsets.UTF_8)) {
                String line;
                while ((line = reader.readLine()) != null) {
                    actualLines.add(line);
                }
            }

            boolean identical = Files.mismatch(binary, copy) == -1;
            check(actualLines.equals(expectedLines), "Text content inconsistency");
            check(identical, "Binary copy inconsistency");

            System.out.println("Binary bytes: " + Files.size(binary));
            System.out.println("Text lines: " + actualLines);
            System.out.println("Copy consistent: " + identical);
            System.out.println("Stream check passed");
        }
    }

    private record TemporaryFiles(Path... paths) implements AutoCloseable {
        @Override
        public void close() throws IOException {
            IOException failure = null;
            for (Path path : paths) {
                try {
                    Files.deleteIfExists(path);
                } catch (IOException error) {
                    if (failure == null) failure = error;
                    else failure.addSuppressed(error);
                }
            }
            if (failure != null) throw failure;
        }
    }

    private static void check(boolean condition, String message) {
        if (!condition) {
            throw new AssertionError(message);
        }
    }
}

Run:

bash
chapter15_stream_root=$(mktemp -d)
mkdir -p "$chapter15_stream_root/out"
# Save the above code as "$chapter15_stream_root/StreamDemo.java"
javac --release 17 -Xlint:all -Werror \
  -d "$chapter15_stream_root/out" \
  "$chapter15_stream_root/StreamDemo.java"
java -ea -cp "$chapter15_stream_root/out" StreamDemo
rm -r -- "$chapter15_stream_root"

Output:

text
Binary bytes: 6
Text lines: [a developer lends out BK-15, Inventory: 1]
Copy consistent: true
Stream check passed

Files.mismatch(a, b) returns the position of the first differing byte; if the contents are identical, it returns -1. It has been available since Java 12, and this chapter is based on Java 17.

5. Buffer the call granularity, without fixed multiples ​

java
try (BufferedInputStream input = new BufferedInputStream(
        Files.newInputStream(path))) {
    // Read
}

A buffer loads larger amounts of data into memory and then satisfies multiple small reads or writes. This typically reduces the number of calls to the underlying I/O stack, but it can't guarantee "a dozen times" or "reducing 10,000 system calls." The outcome depends on these factors:

  • Storage media and filesystem caching;
  • Sequential access or random access;
  • Work performed per item;
  • Is buffering already underway inside the library?
  • Buffer size and concurrent load.

If you've already batch-read with read(byte[]), adding another layer of BufferedInputStream might not yield measurable benefits. First, write correct streaming code, then measure it using JMH or production-like end-to-end tests. Don't draw conclusions from a single System.currentTimeMillis() run; warm-up, caching, and background load will contaminate your data.

6. Small files can be read entirely, but large files must have a size limit ​

java
String config = Files.readString(path, StandardCharsets.UTF_8);
byte[] icon = Files.readAllBytes(path);

These methods are simple but load the entire content into memory. Use them only when file size is constrained by the program or a trusted configuration.

For log files ranging from 10 KB to 2 GB, process them line by line:

java
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    String line;
    while ((line = reader.readLine()) != null) {
        consume(line);
    }
}

Files.lines(path, charset) also returns a lazy Stream<String>:

java
try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
    long errors = lines.filter(line -> line.startsWith("ERROR ")).count();
}

A terminal operation does not automatically close the underlying file resource. Place Files.lines, Files.list, Files.walk, and Files.find inside a try-with-resources.

readLine() still loads one whole line into memory. Without a line-length bound, a huge file containing no newline can exhaust memory. Enforce hard limits while reading character or byte chunks; checking line.length() afterward is too late.

"Streaming" only controls the read mode. If you collect all rows into List, memory still grows with the file. When performing aggregate counting, only the counter is retained; when outputting error rows, rows are read and written as they go.

7. try-with-resources is an ownership declaration ​

java
try (InputStream input = Files.newInputStream(source);
     OutputStream output = Files.newOutputStream(target)) {
    // Using resources
}

Resources are closed in reverse order of declaration: close output first, then close input. Java 9+ allows referencing variables that are already declared and final or effectively final (not reassigned):

java
InputStream input = Files.newInputStream(source);
try (input) {
    // Use input
}

A component creates resources and typically also closes them. If a method receives a stream provided by the caller, the contract must specify whether it takes ownership of the closure. A common convention in the standard library is "who opens, who closes"; exceptions include decorated streams: closing the outermost BufferedWriter also closes the writer it wraps.

Primary Exception and Suppressed Exceptions ​

If the try block and close() both throw exceptions, Java retains the exception from the block and attaches the closure failure to getSuppressed():

java
catch (IOException e) {
    for (Throwable suppressed : e.getSuppressed()) {
        System.err.println("Failed to close resources:" + suppressed.getMessage());
    }
    throw e;
}

Hand-writing finally easily causes cleanup exceptions to mask actual failures and leads to empty catch (IOException e) {}. try-with-resources handles this priority correctly.

8. flush(), close(), and Durable Storage ​

BufferedWriter.flush() passes the Java buffer content to the lower target. close() first flushes then closes. Neither guarantees that data will persist after power loss. The operating system, file system, disk controller, and hardware cache may still retain the data.

For a stronger persistence request:

java
byte[] content = "report complete\n".getBytes(StandardCharsets.UTF_8);
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING,
        StandardOpenOption.WRITE)) {
    java.nio.ByteBuffer buffer = java.nio.ByteBuffer.wrap(content);
    while (buffer.hasRemaining()) {
        channel.write(buffer);
    }
    channel.force(true);
}

force(true) requests writing the content and associated metadata to a storage device. The example writes through the same FileChannel before forcing it. The API guarantees these updates for local devices, but provides no equivalent guarantee for non-local devices. Reopening a channel is not a portable guarantee that arbitrary earlier writes will be synchronized. Systems with high requirements (such as databases and transaction logs) must also design write ordering, checksums, directory synchronization, and crash recovery, and cannot treat a single force as a complete transaction.

Ordinary text export often needs only reliable resource closure. Choose the durability level based on the cost of data loss.

9. Open the options to decide whether to overwrite or append ​

java
Files.newOutputStream(path); // CREATE + TRUNCATE_EXISTING + WRITE

Files.newOutputStream(path,
        StandardOpenOption.CREATE,
        StandardOpenOption.APPEND);

Default writing creates a file or overwrites existing content. Appending works well for some logs, but atomic boundaries during concurrent append operations by multiple processes or threads depend on the API, provider, and write size. For strict boundary enforcement, use a single writer, file locking protocols, or a dedicated logging system.

Common options:

OptionBehavior
CREATECreate if it doesn't exist; continue if it already exists
CREATE_NEWAtomic requires that it did not previously exist; otherwise, throw FileAlreadyExistsException
TRUNCATE_EXISTINGTruncates an existing file to 0 bytes when opening for writing
APPENDEach time written to the end of the file, combination limits vary by provider
SYNC / DSYNCRequests stronger synchronization semantics; cost and support depend on the file system

10. Fail I/O with operation context ​

IOException is a family:

ExceptionCommon Meaning
NoSuchFileExceptionPath does not exist or intermediate directories are missing
FileAlreadyExistsExceptionCREATE_NEW or create operation encounters an existing target
AccessDeniedExceptionPermissions, ACLs, read-only mounting, or security policies denied
FileSystemExceptionOther path-level failures reported by the file system
MalformedInputExceptionBytes cannot be strictly decoded with the specified charset

Preserve the cause when translating an exception at an application boundary:

java
try {
    return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException e) {
    throw new IllegalStateException("Failed to read book directory:" + path.getFileName(), e);
}

Don't return the absolute path of a user's directory directly to external clients. Internal logs can record the controlled path, operation, error type, and associated request ID.

11. Saving Objects with Native Java Serialization ​

java
final class SessionSnapshot implements Serializable {
    private static final long serialVersionUID = 1L;

    private String name;
    private String status;
    private int count;
    private transient String sessionToken;
}

Serializable is a marker interface. ObjectOutputStream traverses the reachable object graph; default serialization traverses non-static, non-transient instance fields, and their non-null object references must also be serializable. Custom serialization methods can change the default behavior. serialVersionUID participates in compatibility checks but cannot automatically complete field migrations.

transient merely indicates that the field does not enter the default serialized data. It is not encryption, access control, or memory erasure. The token may still leak if it appears in other fields, logs, or backups.

Safety Boundary ​

Do not perform native Java deserialization of network uploads, message queues, or user files. Constructing object graphs may trigger dangerous behavior in the classpath, allowing attackers to exploit gadget chains to execute unintended code.

ObjectInputFilter can restrict allowed classes, array length, graph depth, and reference count, making it useful as defense in depth for legacy systems. It does not transform unknown inputs into trusted inputs. For long-term data and cross-service protocols, use explicit, versioned formats, validate field ranges, and construct only permitted domain objects.

A closed binary format can be used with DataOutputStream/DataInputStream, but you must define:

  • Magic number and version;
  • Field order, integer width, and byte order;
  • String byte encoding and maximum length;
  • Failure modes of unknown versions and truncated input.

writeUTF uses modified UTF-8, which is not equivalent to an ordinary UTF-8 text field. A cross-language protocol should write the UTF-8 byte length and content explicitly or use an established format.

12. Standard Input, Output, and Error ​

java
System.in   // InputStream
System.out  // PrintStream
System.err  // PrintStream

Command-line programs should write normal machine-readable output to out and diagnostics to err. The shell can redirect both streams separately.

Don't arbitrarily close System.in/out/err in library code, as other components of the process may still rely on them. If you wrap BufferedReader around System.in, it's typically best to manage the lifecycle uniformly at the application's outermost layer.

Some writing methods on PrintWriter and PrintStream record an error state instead of throwing IOException. When reliable detection is needed, check checkError(), or use the one that throws exceptions, BufferedWriter.

13. Final Challenge: A Streaming Log Analyzer ​

An application’s server.log might be 10 KB or 2 GB. The program must:

  • Read line by line in UTF-8;
  • Count INFO, WARN, ERROR, and DEBUG;
  • Write unrecognized lines to error.log;
  • On encountering FATAL, stop processing and exclude subsequent lines from counts;
  • Write the small summary into report.txt.

The complete program creates a set of fixed inputs and verifies the summary, error lines, and stop positions. A command-line version can accept paths as arguments, but input, report, and error log must be distinct files. Before opening outputs, the example checks normalized paths and existing file identities. It assumes program-controlled directories and does not prevent later link replacement by another process.

java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;

public class LogAnalyzerDemo {
    private enum Level {
        INFO, WARN, ERROR, DEBUG
    }

    private record Result(Map<Level, Long> counts, boolean fatal, long invalid) { }

    public static void main(String[] args) throws IOException {
        Path root = Files.createTempDirectory("log-demo-");
        Path input = root.resolve("server.log");
        Path report = root.resolve("report.txt");
        Path errors = root.resolve("error.log");

        TemporaryFiles cleanup = new TemporaryFiles(errors, report, input, root);
        try (cleanup) {
            Files.write(input, List.of(
                    "INFO server started",
                    "DEBUG catalog loaded",
                    "BROKEN",
                    "WARN inventory low",
                    "ERROR receipt failed",
                    "INFO retry accepted",
                    "FATAL database unavailable",
                    "INFO ignored"), StandardCharsets.UTF_8);

            Result result = analyze(input, report, errors);
            List<String> reportLines = Files.readAllLines(report, StandardCharsets.UTF_8);
            List<String> errorLines = Files.readAllLines(errors, StandardCharsets.UTF_8);

            check(result.counts().equals(Map.of(
                    Level.INFO, 2L,
                    Level.WARN, 1L,
                    Level.ERROR, 1L,
                    Level.DEBUG, 1L)), "Count errors");
            check(result.fatal(), "Did not stop at FATAL");
            check(result.invalid() == 1, "Invalid rows count error");
            check(reportLines.equals(List.of(
                    "INFO=2", "WARN=1", "ERROR=1", "DEBUG=1", "FATAL=true")), "Report error");
            check(errorLines.equals(List.of("line 3: BROKEN")), "Inconsistent error logs");

            System.out.println("Stats: " + result.counts());
            System.out.println("Encountered FATAL: " + result.fatal());
            System.out.println("Invalid line: " + result.invalid());
            System.out.println("Report: " + report.getFileName());
            System.out.println("Log check passed");
        }
    }

    private static Result analyze(Path input, Path report, Path errors) throws IOException {
        requireDistinct(input, report, errors);
        EnumMap<Level, Long> counts = new EnumMap<>(Level.class);
        for (Level level : Level.values()) {
            counts.put(level, 0L);
        }

        boolean fatal = false;
        long invalid = 0;
        long lineNumber = 0;
        try (BufferedReader reader = Files.newBufferedReader(input, StandardCharsets.UTF_8);
             BufferedWriter errorWriter = Files.newBufferedWriter(errors, StandardCharsets.UTF_8)) {
            String line;
            while ((line = reader.readLine()) != null) {
                lineNumber++;
                if (line.startsWith("FATAL ") || line.equals("FATAL")) {
                    fatal = true;
                    break;
                }

                int separator = line.indexOf(' ');
                String token = separator < 0 ? line : line.substring(0, separator);
                try {
                    Level level = Level.valueOf(token);
                    counts.merge(level, 1L, Math::addExact);
                } catch (IllegalArgumentException e) {
                    invalid++;
                    errorWriter.write("line " + lineNumber + ": " + line);
                    errorWriter.newLine();
                }
            }
        }

        try (BufferedWriter writer = Files.newBufferedWriter(report, StandardCharsets.UTF_8)) {
            for (Level level : Level.values()) {
                writer.write(level + "=" + counts.get(level));
                writer.newLine();
            }
            writer.write("FATAL=" + fatal);
            writer.newLine();
        }

        Map<Level, Long> snapshot = Collections.unmodifiableMap(new EnumMap<>(counts));
        return new Result(snapshot, fatal, invalid);
    }

    private record TemporaryFiles(Path... paths) implements AutoCloseable {
        @Override
        public void close() throws IOException {
            IOException failure = null;
            for (Path path : paths) {
                try {
                    Files.deleteIfExists(path);
                } catch (IOException error) {
                    if (failure == null) failure = error;
                    else failure.addSuppressed(error);
                }
            }
            if (failure != null) throw failure;
        }
    }

    private static void requireDistinct(Path... paths) throws IOException {
        for (int i = 0; i < paths.length; i++) {
            for (int j = i + 1; j < paths.length; j++) {
                boolean sameName = paths[i].toAbsolutePath().normalize()
                        .equals(paths[j].toAbsolutePath().normalize());
                boolean sameFile = Files.exists(paths[i]) && Files.exists(paths[j])
                        && Files.isSameFile(paths[i], paths[j]);
                if (sameName || sameFile) {
                    throw new IllegalArgumentException("Input and outputs must be distinct files");
                }
            }
        }
    }

    private static void check(boolean condition, String message) {
        if (!condition) {
            throw new AssertionError(message);
        }
    }
}

Output:

text
Stats: {INFO=2, WARN=1, ERROR=1, DEBUG=1}
Encountered FATAL: true
Invalid line: 1
Report: report.txt
Log check passed

Level names are case-sensitive and space-delimited; INFO alone is valid, while a tab does not act as a delimiter. The program reads only small reports back for testing and processes input line by line with long counters. BufferedReader may read ahead, so FATAL stops counting, not necessarily the underlying byte reads. The eighth line INFO ignored occurs after FATAL, so it is not counted.

Production version requirements still need to be determined: whether input files might grow or rotate during analysis, the maximum line length, error log capacity, whether report replacements require atomicity, and how old reports are preserved in the event of interruptions or disk full conditions. The next page covers these file system-level considerations.

14. Common Fault Diagnosis ​

PhenomenonFirst check
Chinese characters display as garbled textDoes writing and reading use the same explicit charset
Extra old bytes at file endWhether the entire buffer was written, rather than the valid range returned by read
Large files cause OutOfMemoryErrorWhether to use readAllBytes, readString, or collect all lines
After the program runs for a while, the file cannot be openedThe file stream or Files.lines may not have been closed
Write succeeded but data lost after power failureflush/close does not equal durability; reevaluate durability requirements
Interleaved logsDo multiple writers lack clear record boundaries and coordination mechanisms
Cannot see the primary exceptionA cleanup failure in finally may mask the primary failure; inspect the exception chain
Serialization triggers unexpected class behaviorDon't accept untrusted native object streams; use an allowlist and explicit formats

15. Practice ​

  1. Change the buffer size of StreamDemo to 1, 4, and 64 bytes. The results must be identical; do not use a single timing measurement to compare performance.
  2. Add a maximum line length limit to the log analyzer. When exceeded, record an error and continue; clearly specify whether the length is measured in characters or bytes.
  3. Rewrite the version that only counts ERROR using Files.lines, ensure the try-with-resources remains present, and do not invoke toList().
  4. Design a binary record format with a magic number, version, UTF-8 name length, name bytes, status, and count. Validate the magic number, length limit, and version before reading fields.
  5. Artificially cause both the try block and close() to fail, print the main exception and getSuppressed(), and observe the shutdown order.

Run the same data with three different boundary conditions ​

First, change the binary copy buffer sequentially to 1, 4, and 64 bytes, ensuring each copy matches the original file byte by byte. Then use strict CharsetDecoder to read invalid UTF-8 sequences C3 28 and verify that it throws MalformedInputException instead of silently replacing characters.

Feed the log analyzer input vastly larger than the memory-resident fixture set, yet the processing retains only four counters, the current line, and the error output stream. If an inline call to toList() occurs, memory will still grow with the file. Lines after FATAL are not to be counted, and the error file must have capacity or a rotation policy.

Finally, let both the try block and the two close() fail simultaneously, verifying that the main exception remains the primary exception, resources are closed in reverse declaration order, and if closure fails, it is recorded in suppressed exceptions, only then are resource ownership, access modes, and failure chains truly connected.

Check the official contract for Java 17 ​

  • InputStream and Reader: partial reads, EOF, and character boundaries.
  • Files: Text encoding, bulk reading, streaming line reading, and file copying.
  • JLS 14.20.3: try-with-resources: Rules for resource closure order and suppressed exceptions.
  • ObjectInputFilter: The filtering boundary for the old object stream; it cannot automatically trust unknown inputs.

Next Step: Deciding Where to Store the Data ​

The stream API handles the transfer: the binary image is copied byte by byte, and the text report is decoded using UTF-8. The next article enters the example directory, where the file system API determines how paths are composed, how directories are traversed, and how reports are replaced.

Continue learning about files, directories, and paths

Built with VitePress | Software Systems Atlas