Senior 5 min · March 05, 2026

Java File Handling — Data Loss from Unflushed Buffers

JVM crash truncates log entries due to unflushed BufferedWriter buffers.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Java file I/O uses streams: InputStream/OutputStream for binary, Reader/Writer for text.
  • The File class represents a path, not an open file — use it for existence checks before I/O.
  • Always close streams with try-with-resources; OS file handles are finite (default 1024 per process on Linux).
  • BufferedReader buffers reads to reduce disk hits; Files.lines() provides lazy streaming for large files.
  • FileWriter defaults to overwrite (truncate); pass 'true' for append mode — losing data is a painful rite of passage.
Plain-English First

Think of your Java program as a person sitting at a desk. The computer's hard drive is a massive filing cabinet in the corner. File handling is simply the set of actions your program uses to walk over to that cabinet, pull out a folder, read what's inside, add new notes, or create a brand-new folder. Without file handling, your program's memory disappears the moment it stops running — like writing notes on a whiteboard that gets erased every night.

Every meaningful application eventually needs to persist data. A banking app logs transactions. A web server writes error logs. A game saves your progress. Without the ability to read from and write to files, your Java program lives entirely in RAM — and the moment the JVM shuts down, everything it knew is gone forever. File handling is the bridge between your program's temporary world and the permanent world of the disk.

The problem Java historically had was that its original file I/O API (java.io) was clunky, verbose, and made it annoyingly easy to leak resources. You'd open a file, forget to close it, and slowly starve your OS of file handles. The modern Java ecosystem fixed this with try-with-resources, the NIO.2 API introduced in Java 7, and utility classes like Files and Paths that let you accomplish in one line what used to take twenty.

By the end of this article you'll know exactly which Java file API to reach for in which situation, how to safely read and write text files without leaking resources, how to handle real-world edge cases like missing files and encoding issues, and what interviewers mean when they ask you to 'explain the difference between FileReader and BufferedReader.' Let's build this understanding layer by layer.

The File Class: Your Map Before You Open the Territory

Before you read or write anything, Java needs to know WHERE the file lives. That's what the java.io.File class is for. It doesn't open a file — it just represents a path on the filesystem. Think of it as a GPS coordinate: the coordinate itself doesn't move you anywhere, but you need it before you can navigate.

The File class lets you check whether a path exists, whether it's a file or a directory, how large it is, and whether you have permission to read or write it. These checks prevent ugly runtime crashes. If you try to open a file that doesn't exist without checking first, Java throws a FileNotFoundException right in your face.

This class is also how you create new files and directories programmatically. You'd use this when your app needs to set up a log directory on first run, or verify a config file exists before loading it. It's your reconnaissance tool — use it before committing to any I/O operation.

FileInspector.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package io.thecodeforge.filehandling;

import java.io.File;
import java.time.Instant;

public class FileInspector {

    public static void main(String[] args) {
        // Represent a path — no file is opened yet, this is just a reference
        File configFile = new File("app-config.txt");

        // Check existence BEFORE attempting to read — avoids FileNotFoundException
        if (configFile.exists()) {
            System.out.println("File found: " + configFile.getAbsolutePath());
            System.out.println("Size: " + configFile.length() + " bytes");
            System.out.println("Readable: " + configFile.canRead());
            System.out.println("Writable: " + configFile.canWrite());
            System.out.println("Last modified: " +
                Instant.ofEpochMilli(configFile.lastModified()));
        } else {
            System.out.println("Config file not found. Creating it now...");

            try {
                // createNewFile() returns true if file was created, false if it already existed
                boolean wasCreated = configFile.createNewFile();
                System.out.println("File created: " + wasCreated);
            } catch (java.io.IOException e) {
                // IOException is thrown if the parent directory doesn't exist
                // or if you don't have write permission
                System.err.println("Could not create file: " + e.getMessage());
            }
        }

        // Demonstrate directory creation — mkdirs() creates the full path, not just one level
        File logDirectory = new File("logs/2024/january");
        if (!logDirectory.exists()) {
            boolean dirCreated = logDirectory.mkdirs(); // plural 'mkdirs' handles nested dirs
            System.out.println("Log directory created: " + dirCreated);
        }
    }
}
Output
Config file not found. Creating it now...
File created: true
Log directory created: true
Watch Out: mkdir() vs mkdirs()
mkdir() only creates ONE directory level. If you call it on 'logs/2024/january' and 'logs' doesn't exist yet, it silently returns false and creates nothing. Always use mkdirs() (plural) when your path has multiple levels.
Production Insight
File.exists() has a TOCTOU race condition — file could be deleted between check and open.
Use Files.exists() with LinkOption.NOFOLLOW_LINKS for symlink safety.
Even after a check, always catch IOException because the filesystem is shared.
Key Takeaway
File represents a path, not an open file handle.
Check existence and permissions before opening to avoid ugly crashes.
Don't trust check results — race conditions exist; handle IOException anyway.

Writing Files Safely — Why try-with-resources Is Non-Negotiable

Writing to a file in Java involves a stack of stream objects. At the bottom, FileWriter handles the raw bytes. On top of it, BufferedWriter batches those writes into chunks before hitting the disk — this is dramatically faster than flushing every single character individually. Think of it like mailing letters: you wouldn't run to the post office for every individual letter, you'd collect them and make one trip.

The critical lesson here isn't the syntax — it's resource management. Every time you open a file stream, the OS allocates a file handle. Most operating systems have a hard limit on open file handles per process (typically 1024 on Linux). If you forget to call close(), those handles pile up. Your app eventually crashes with 'Too many open files' — and that error is infuriating to debug in production.

The solution is try-with-resources (introduced in Java 7). Any object that implements AutoCloseable — which all I/O streams do — gets automatically closed when the try block exits, whether normally or via an exception. There's no excuse to use the old finally-block pattern anymore.

UserActivityLogger.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package io.thecodeforge.filehandling;

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class UserActivityLogger {

    private static final String LOG_FILE_PATH = "user-activity.log";
    private static final DateTimeFormatter TIMESTAMP_FORMAT =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    // Logs a user action to a file — the second argument controls append vs overwrite
    public static void logAction(String username, String action) throws IOException {
        String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT);
        String logEntry = String.format("[%s] USER=%s ACTION=%s%n",
            timestamp, username, action);

        // try-with-resources: the writer is GUARANTEED to close when this block exits
        // FileWriter(path, true) — the 'true' flag means APPEND, not overwrite
        try (BufferedWriter writer = new BufferedWriter(
                new FileWriter(LOG_FILE_PATH, true))) {

            writer.write(logEntry);
            // BufferedWriter batches writes — flush() forces the buffer to disk immediately
            // Not needed here because close() calls flush() automatically
        }
        // No need for finally { writer.close() } — try-with-resources handles it
    }

    public static void main(String[] args) throws IOException {
        logAction("alice", "LOGIN");
        logAction("alice", "VIEW_DASHBOARD");
        logAction("bob",   "LOGIN");
        logAction("alice", "LOGOUT");

        System.out.println("Activity log written to: " + LOG_FILE_PATH);
        System.out.println("Check the file to see the 4 entries.");
    }
}
Output
Activity log written to: user-activity.log
Check the file to see the 4 entries.
Pro Tip: Append vs Overwrite
new FileWriter(path) — no second argument — OVERWRITES the file every time. new FileWriter(path, true) APPENDS. For log files you almost always want append. Forgetting this flag and losing a week of logs is a rite of passage nobody wants to experience.
Production Insight
BufferedWriter without flush on every log entry means data stays in JVM buffer — crash loses it.
Each open file handle consumes OS limit (default 1024 per process on Linux).
Rule: always close in finally or use try-with-resources; never leak handles.
Key Takeaway
try-with-resources guarantees closure even on exceptions.
BufferedWriter batches writes for performance but delays persistence.
Append mode (true) prevents overwriting; overwrite loses data.

Reading Files Efficiently — BufferedReader and the Modern Files API

Reading a file line-by-line is one of the most common operations in any backend system: parsing CSVs, loading configs, processing log files. Java gives you two solid approaches — the classic BufferedReader for fine-grained control, and the modern java.nio.file.Files utility for convenience.

FileReader alone reads one character at a time from disk. That's catastrophically slow for large files. Wrapping it in BufferedReader adds an internal buffer (default 8KB) that reads a large chunk from disk, then serves your line-by-line calls from memory. The disk is hit far less often. For a 100MB log file, this difference is measured in seconds vs minutes.

The NIO.2 `Files` class (note: java.nio.file.Files, not java.io.File) is the modern alternative. Files.readAllLines() reads the entire file into a List<String> in one call — perfect for small config files. Files.lines() returns a lazy Stream<String>, which is ideal for large files because it doesn't load everything into memory at once. Know when to use each: small file with random access → readAllLines(). Large file processed sequentially → Files.lines() with a stream pipeline.

CsvReportParser.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package io.thecodeforge.filehandling;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Stream;

public class CsvReportParser {

    // --- APPROACH 1: BufferedReader — great when you need manual control per line ---
    public static void readWithBufferedReader(String filePath) throws IOException {
        System.out.println("=== BufferedReader Approach ===");

        try (BufferedReader reader = new BufferedReader(
                new FileReader(filePath, StandardCharsets.UTF_8))) { // Always specify charset!

            String currentLine;
            int lineNumber = 0;

            // readLine() returns null at end-of-file — that's your loop termination signal
            while ((currentLine = reader.readLine()) != null) {
                lineNumber++;
                if (lineNumber == 1) {
                    System.out.println("Header row: " + currentLine);
                    continue; // Skip the CSV header
                }
                String[] columns = currentLine.split(",");
                if (columns.length >= 3) {
                    System.out.printf("  Record: name=%-15s score=%s grade=%s%n",
                        columns[0].trim(), columns[1].trim(), columns[2].trim());
                }
            }
            System.out.println("Total data rows read: " + (lineNumber - 1));
        }
    }

    // --- APPROACH 2: Files.lines() — best for large files, uses a lazy stream ---
    public static long countHighScorers(String filePath, int minimumScore)
            throws IOException {
        Path csvPath = Paths.get(filePath);

        // Files.lines() does NOT load the whole file into memory at once
        // The try-with-resources is REQUIRED here — the stream holds an open file handle
        try (Stream<String> lineStream = Files.lines(csvPath, StandardCharsets.UTF_8)) {
            return lineStream
                .skip(1)  // skip the header row
                .map(line -> line.split(","))
                .filter(cols -> cols.length >= 2)
                .mapToInt(cols -> {
                    try { return Integer.parseInt(cols[1].trim()); }
                    catch (NumberFormatException e) { return 0; }
                })
                .filter(score -> score >= minimumScore)
                .count();
        }
    }

    // --- APPROACH 3: Files.readAllLines() — for small files where you want a List ---
    public static void readSmallConfigFile(String filePath) throws IOException {
        System.out.println("\n=== Files.readAllLines() Approach ===");
        Path configPath = Paths.get(filePath);

        // Loads ALL lines into memory — only use this for small files (< a few MB)
        List<String> allLines = Files.readAllLines(configPath, StandardCharsets.UTF_8);
        allLines.stream()
            .filter(line -> !line.startsWith("#")) // ignore comment lines
            .filter(line -> line.contains("="))
            .forEach(line -> System.out.println("  Config entry: " + line));
    }

    public static void main(String[] args) throws IOException {
        // To run this, create a file called 'students.csv' with the content:
        // name,score,grade
        // Alice,92,A
        // Bob,74,C
        // Charlie,88,B
        // Diana,95,A

        readWithBufferedReader("students.csv");

        long highScorers = countHighScorers("students.csv", 85);
        System.out.println("\nStudents scoring 85 or above: " + highScorers);
    }
}
Output
=== BufferedReader Approach ===
Header row: name,score,grade
Record: name=Alice score=92 grade=A
Record: name=Bob score=74 grade=C
Record: name=Charlie score=88 grade=B
Record: name=Diana score=95 grade=A
Total data rows read: 4
Students scoring 85 or above: 3
Watch Out: Files.lines() Must Be Closed
Files.lines() returns a lazy Stream that holds an open file handle underneath. If you don't wrap it in try-with-resources, the file stays locked until GC runs — which is unpredictable. This is one of the most common resource leak patterns in modern Java code.
Production Insight
Files.lines() holds an open file handle until stream closed; GC may not close it timely.
Large file processed with Files.readAllLines() blows heap — OOM in production.
Rule: for large files always use Files.lines() with try-with-resources.
Key Takeaway
BufferedReader buffers reads; Files.lines() is lazy.
Files.readAllLines() is convenient but memory-heavy.
Always close Files.lines() inside try-with-resources.

The NIO.2 Power Tools — Files.write(), Files.copy(), and Atomic Operations

Once you've mastered reading and writing, the next level is manipulating files as units: copying, moving, deleting, and writing content in a single call. The java.nio.file.Files utility class is your Swiss Army knife here. It was designed to replace the verbose, error-prone java.io.File operations with clean, predictable alternatives.

Files.write() is brilliant for writing small files — it handles opening, writing, flushing, and closing all in one call. You can also pass StandardOpenOption flags to control exactly how the write behaves: APPEND, CREATE, TRUNCATE_EXISTING, CREATE_NEW (which fails if the file exists — great for preventing accidental overwrites).

Files.copy() and Files.move() are atomic on most operating systems when copying within the same filesystem. Files.move() with the ATOMIC_MOVE option is especially useful for safe file replacement — the classic pattern is to write to a temp file, then atomically rename it to the final destination. This prevents any reader from ever seeing a half-written file, which is critical in high-reliability systems.

SafeConfigWriter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package io.thecodeforge.filehandling;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;

public class SafeConfigWriter {

    private static final Path CONFIG_PATH = Paths.get("application.properties");

    // SAFE write pattern: write to a temp file, then atomically rename
    // This means readers never see a partially-written config file
    public static void writeConfigSafely(List<String> configLines) throws IOException {
        // Create a temp file in the SAME directory — required for atomic move
        Path tempFile = Files.createTempFile(
            CONFIG_PATH.getParent(), // same directory as the real file
            ".app-config-",          // prefix
            ".tmp"                   // suffix
        );

        try {
            // Write all content to the temp file first
            // StandardOpenOption.WRITE + TRUNCATE_EXISTING is the default for Files.write()
            Files.write(tempFile, configLines, StandardCharsets.UTF_8);

            // ATOMIC_MOVE: on Linux/macOS this is a rename() syscall — instantaneous
            // REPLACE_EXISTING: overwrite the destination if it already exists
            Files.move(tempFile, CONFIG_PATH,
                StandardCopyOption.ATOMIC_MOVE,
                StandardCopyOption.REPLACE_EXISTING);

            System.out.println("Config written atomically to: " + CONFIG_PATH.toAbsolutePath());

        } catch (IOException writeError) {
            // If anything goes wrong, clean up the temp file
            Files.deleteIfExists(tempFile); // deleteIfExists won't throw if file is already gone
            throw writeError; // re-throw so the caller knows the write failed
        }
    }

    // Demonstrates other NIO.2 utilities
    public static void demonstrateCopyAndDelete() throws IOException {
        Path sourceFile = Paths.get("application.properties");
        Path backupFile = Paths.get("application.properties.bak");

        // Copy a file — REPLACE_EXISTING prevents CopyOption collision if backup exists
        if (Files.exists(sourceFile)) {
            Files.copy(sourceFile, backupFile, StandardCopyOption.REPLACE_EXISTING);
            System.out.println("Backup created: " + backupFile);
            System.out.println("Backup size: " + Files.size(backupFile) + " bytes");
        }

        // Files.readString() — Java 11+, the most concise way to read a small file
        if (Files.exists(sourceFile)) {
            String content = Files.readString(sourceFile, StandardCharsets.UTF_8);
            System.out.println("\nConfig content:\n" + content);
        }
    }

    public static void main(String[] args) throws IOException {
        List<String> configEntries = List.of(
            "# Application Configuration",
            "app.name=TheCodeForge",
            "app.version=2.1.0",
            "db.host=localhost",
            "db.port=5432",
            "cache.enabled=true"
        );

        writeConfigSafely(configEntries);
        demonstrateCopyAndDelete();
    }
}
Output
Config written atomically to: /home/user/project/application.properties
Backup created: application.properties.bak
Backup size: 112 bytes
Config content:
# Application Configuration
app.name=TheCodeForge
app.version=2.1.0
db.host=localhost
db.port=5432
cache.enabled=true
Interview Gold: The Atomic Write Pattern
Interviewers love asking 'how would you prevent data corruption when writing a config file?' The answer is exactly this: write to a temp file, then atomically rename it. Never write directly to the production file — a crash mid-write leaves it corrupted. This pattern is used by databases, package managers, and virtually every production system.
Production Insight
Atomic move works only on same filesystem; cross-filesystem moves may copy then delete, not atomic.
Files.write() with default options truncates existing file — can accidentally wipe data.
Temp file cleanup is critical; use try-finally or Files.deleteIfExists().
Key Takeaway
Atomic rename prevents readers from seeing half-written config files.
Always set StandardOpenOption exhaustively for clarity.
Clean up temp files even on failure.

Binary File Handling: Streams for Non-Text Data

Not all files are text. Images, PDFs, audio, ZIP archives — these are binary data. For binary files, you use FileInputStream and FileOutputStream (or their buffered counterparts). The core difference: binary streams read and write raw bytes, not characters. No charset conversion happens.

FileInputStream reads one byte at a time from disk — that's slow. Wrap it in BufferedInputStream for speed. Similarly, BufferedOutputStream batches writes. For very large files, consider using FileChannel with a direct buffer to avoid copying data between JVM heap and native memory.

One trap: never use Reader/Writer classes on binary data. They try to decode bytes as characters and will corrupt your file. Always stick to InputStream/OutputStream when the data isn't explicitly text.

FileCopierBinary.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package io.thecodeforge.filehandling;

import java.io.*;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

public class FileCopierBinary {

    // Copy using BufferedInputStream/OutputStream — good for moderate file sizes
    public static void copyBuffered(Path source, Path target) throws IOException {
        try (InputStream in = new BufferedInputStream(new FileInputStream(source.toFile()));
             OutputStream out = new BufferedOutputStream(new FileOutputStream(target.toFile()))) {

            byte[] buffer = new byte[8192]; // 8 KB typical buffer
            int bytesRead;
            while ((bytesRead = in.read(buffer)) != -1) {
                out.write(buffer, 0, bytesRead);
            }
        }
        // try-with-resources ensures both streams are closed
    }

    // Copy using FileChannel with transferTo() — faster for large files, zero-copy on many OS
    public static void copyChannel(Path source, Path target) throws IOException {
        try (FileChannel srcChannel = FileChannel.open(source, StandardOpenOption.READ);
             FileChannel destChannel = FileChannel.open(target, StandardOpenOption.CREATE_NEW,
                 StandardOpenOption.WRITE)) {

            // transferTo uses OS-level sendfile() under the hood — avoids copying bytes into Java heap
            long position = 0;
            long size = srcChannel.size();
            while (position < size) {
                position += srcChannel.transferTo(position, size - position, destChannel);
            }
        }
    }

    public static void main(String[] args) throws IOException {
        Path source = Path.of("large-file.bin");
        Path target1 = Path.of("large-file-buffered.bin");
        Path target2 = Path.of("large-file-channel.bin");

        long start = System.nanoTime();
        copyBuffered(source, target1);
        long bufferedTime = System.nanoTime() - start;
        System.out.println("Buffered copy: " + bufferedTime / 1_000_000 + " ms");

        start = System.nanoTime();
        copyChannel(source, target2);
        long channelTime = System.nanoTime() - start;
        System.out.println("Channel copy: " + channelTime / 1_000_000 + " ms");
    }
}
Output
Buffered copy: 345 ms
Channel copy: 182 ms
(Actual times vary by file size and hardware, but channel is typically 2-3x faster for large files)
Never Mix Reader/Writer with Binary Files
If you wrap a FileInputStream in an InputStreamReader, it tries to decode bytes as characters using a charset. This will silently corrupt binary data (PDF, PNG, ZIP). Always use raw InputStream/OutputStream for binary content. There's no safe charset for binary data.
Production Insight
Buffered streams use 8 KB buffer by default — fine for most cases, but consider 64 KB for large sequential reads.
FileChannel.transferTo() uses sendfile() internally on Linux — zero-copy from disk to socket, huge win for static file serving.
DirectByteBuffer (not shown) avoids JVM heap overhead but requires explicit memory management; use with caution.
Key Takeaway
Binary files require InputStream/OutputStream, not Reader/Writer.
Buffered streams reduce system calls; channel-based copy is fastest.
Never use charset conversion on binary data; data corruption is silent and irrecoverable.
● Production incidentPOST-MORTEMseverity: high

Half-Written Log Entry After JVM Crash

Symptom
After the application restarted, the log file contained a partial line (truncated JSON). The log parser threw 'Unexpected end of input' every time it reached that line.
Assumption
The team assumed that BufferedWriter writes were persisted to disk immediately after each call to write().
Root cause
BufferedWriter buffers data in memory. When the JVM crashed, the buffer was never flushed. The data that was 'written' but not yet flushed was lost forever. The file was left in an inconsistent state.
Fix
Switch to explicit flush() after each critical log entry (with a performance trade-off), or use an async logging framework (e.g., Log4j2) that acknowledges writes. More fundamentally, use the atomic write pattern for critical files: write to a temp file, flush, close, then rename.
Key lesson
  • Never assume buffered writes are durably persisted until close() or flush() returns.
  • On abnormal JVM termination, unflushed buffers are lost.
  • For critical data, consider forcing flush() after each write, or use FileChannel with force(true).
Production debug guideCommon symptoms, root causes, and immediate actions when Java file operations fail in production.3 entries
Symptom · 01
FileNotFoundException even though the file clearly exists
Fix
Print System.getProperty('user.dir') to see the JVM's working directory. Use absolute paths or verify relative path resolution. Also check file permissions (readable) and symlinks.
Symptom · 02
'Too many open files' IOException in production logs
Fix
Check lsof -p <pid> or /proc/<pid>/fd on Linux. The culprit is usually an unclosed stream. Search for Files.lines() or new FileReader() without try-with-resources. Fix: wrap every resource in try-with-resources.
Symptom · 03
Mojibake characters: 'é' instead of 'é' in output
Fix
The charset mismatch. Always specify StandardCharsets.UTF_8 explicitly on FileReader/FileWriter. Never rely on the platform default. Use Files.readString() / Files.write() which default to UTF-8.
★ Quick Debug Cheat Sheet for Java File I/OFast commands and checks to diagnose the most common file-related production issues.
FileNotFoundException
Immediate action
Print user.dir and list directory contents
Commands
System.out.println(System.getProperty("user.dir"));
Files.list(Paths.get(".")).forEach(System.out::println);
Fix now
Use absolute path or correct relative path based on actual working directory.
Too many open files+
Immediate action
Check OS file descriptor limit and JVM open file count
Commands
ulimit -n (on Linux) or lsof -p $(pgrep java) | wc -l
jcmd <pid> VM.native_memory summary scale=MB
Fix now
Identify unclosed streams via code review; use try-with-resources on all I/O objects.
File truncated or missing data on crash+
Immediate action
Examine the file's last bytes with hexdump
Commands
tail -c 100 file.log | xxd
od -c file.log | tail
Fix now
Add explicit flush() after critical writes, or switch to FileChannel with force(true).
java.io vs java.nio.file
Aspectjava.io (Classic API)java.nio.file (Modern NIO.2)
Introduced inJava 1.0Java 7
Main classesFile, FileReader, FileWriter, BufferedReaderFiles, Paths, Path
Read entire file in one callNot possible — must loopFiles.readAllLines() or Files.readString() (Java 11+)
Atomic move/renamefile.renameTo() — unreliable across platformsFiles.move() with ATOMIC_MOVE — reliable
Stream-based readingManual while-loop with readLine()Files.lines() returns Stream<String>
Resource managementManual try-finally or try-with-resourcestry-with-resources (same, but less boilerplate with utility methods)
Exception specificityGeneric IOException for most operationsMore specific: NoSuchFileException, AccessDeniedException, etc.
Walk a directory treeRecursive manual implementation neededFiles.walk() or Files.walkFileTree()
Best use caseLow-level stream control, legacy codebasesNew code — cleaner, safer, more powerful

Key takeaways

1
java.io.File is just a path reference
it never opens a file. Use it for existence checks and metadata before committing to I/O operations.
2
Always wrap streams in try-with-resources. Every open stream is an OS file handle, and handles are a finite resource. Resource leaks cause 'Too many open files' errors that only appear under production load.
3
Files.lines() is lazy and memory-efficient for large files, but it MUST be closed explicitly
it holds a live file handle under the hood. Files.readAllLines() loads everything into memory — only use it for small files.
4
The atomic write pattern (write to temp → rename) is the professional-grade way to update any critical file. It guarantees readers never see a partially-written state, even if your process crashes mid-write.
5
Binary files need InputStream/OutputStream, not Reader/Writer. Mixing them up causes silent data corruption that is nearly impossible to detect without manual binary inspection.

Common mistakes to avoid

4 patterns
×

Not specifying a charset when creating FileReader/FileWriter

Symptom
Mojibake characters (e.g., 'é' instead of 'é') when files contain non-ASCII characters. Code that works on your machine breaks on a server with a different default locale.
Fix
Always explicitly pass StandardCharsets.UTF_8: new FileReader(path, StandardCharsets.UTF_8) or use Files.readAllLines(path, StandardCharsets.UTF_8). Never rely on the platform's default charset.
×

Forgetting the append flag on FileWriter and wiping existing data

Symptom
Every time your logging method runs, the log file resets to just the latest entry; all history is gone.
Fix
Use new FileWriter(logFilePath, true) — the boolean 'true' is the append flag. If you want to overwrite intentionally, be explicit in a comment so future-you knows it's on purpose.
×

Not wrapping Files.lines() in try-with-resources

Symptom
File handle leak that eventually causes 'Too many open files' IOException in production, which is nearly impossible to reproduce locally.
Fix
Always use try (Stream<String> lines = Files.lines(path)) { ... }. The Stream is AutoCloseable and holds an OS file handle that must be explicitly released.
×

Using Reader/Writer classes for binary files

Symptom
Silent corruption of binary data (images, ZIPs) — bytes are decoded as characters, modified, and re-encoded; file becomes unreadable.
Fix
Use InputStream/OutputStream for any file that is not guaranteed to be text. Never wrap binary streams in InputStreamReader or OutputStreamWriter.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What's the difference between FileReader and BufferedReader? Why would y...
Q02SENIOR
Explain how you'd safely update a configuration file in a running applic...
Q03SENIOR
When would you choose Files.readAllLines() over Files.lines(), and what ...
Q04SENIOR
What happens if you forget to specify a charset when reading a text file...
Q05SENIOR
How does FileChannel.transferTo() achieve better performance for copying...
Q01 of 05JUNIOR

What's the difference between FileReader and BufferedReader? Why would you never use FileReader alone in production code?

ANSWER
FileReader reads one character at a time from disk, which is very slow. BufferedReader wraps FileReader with an internal buffer (default 8KB) that reads a large chunk from disk into memory, then serves characters from that buffer. In production, you almost always use BufferedReader because disk I/O is orders of magnitude slower than memory access. Without buffering, a 100MB file can take minutes instead of seconds.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between FileInputStream and FileReader in Java?
02
How do I read a file line by line in Java without loading it all into memory?
03
Why does my Java program throw FileNotFoundException even though the file clearly exists?
04
What is the safe way to write a file that multiple threads or processes read?
05
Can I use FileWriter to write to a file that is opened by another process?
🔥

That's Java I/O. Mark it forged?

5 min read · try the examples if you haven't

Previous
CountDownLatch and CyclicBarrier
1 / 8 · Java I/O
Next
FileReader and FileWriter in Java