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;
publicclassFileInspector {
publicstaticvoidmain(String[] args) {
// Represent a path — no file is opened yet, this is just a referenceFile configFile = newFile("app-config.txt");
// Check existence BEFORE attempting to read — avoids FileNotFoundExceptionif (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 existedboolean 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 permissionSystem.err.println("Could not create file: " + e.getMessage());
}
}
// Demonstrate directory creation — mkdirs() creates the full path, not just one levelFile logDirectory = newFile("logs/2024/january");
if (!logDirectory.exists()) {
boolean dirCreated = logDirectory.mkdirs(); // plural 'mkdirs' handles nested dirsSystem.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.
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;
publicclassUserActivityLogger {
privatestaticfinalString LOG_FILE_PATH = "user-activity.log";
privatestaticfinalDateTimeFormatter TIMESTAMP_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// Logs a user action to a file — the second argument controls append vs overwritepublicstaticvoidlogAction(String username, String action) throwsIOException {
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 overwritetry (BufferedWriter writer = newBufferedWriter(
newFileWriter(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
}
publicstaticvoidmain(String[] args) throwsIOException {
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.
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;
publicclassCsvReportParser {
// --- APPROACH 1: BufferedReader — great when you need manual control per line ---publicstaticvoidreadWithBufferedReader(String filePath) throwsIOException {
System.out.println("=== BufferedReader Approach ===");
try (BufferedReader reader = newBufferedReader(
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 signalwhile ((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 ---publicstaticlongcountHighScorers(String filePath, int minimumScore)
throwsIOException {
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 handletry (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 { returnInteger.parseInt(cols[1].trim()); }
catch (NumberFormatException e) { return0; }
})
.filter(score -> score >= minimumScore)
.count();
}
}
// --- APPROACH 3: Files.readAllLines() — for small files where you want a List ---publicstaticvoidreadSmallConfigFile(String filePath) throwsIOException {
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));
}
publicstaticvoidmain(String[] args) throwsIOException {
// 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,AreadWithBufferedReader("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;
publicclassSafeConfigWriter {
privatestaticfinalPath 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 filepublicstaticvoidwriteConfigSafely(List<String> configLines) throwsIOException {
// Create a temp file in the SAME directory — required for atomic movePath 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 existsFiles.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 fileFiles.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 utilitiespublicstaticvoiddemonstrateCopyAndDelete() throwsIOException {
Path sourceFile = Paths.get("application.properties");
Path backupFile = Paths.get("application.properties.bak");
// Copy a file — REPLACE_EXISTING prevents CopyOption collision if backup existsif (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 fileif (Files.exists(sourceFile)) {
String content = Files.readString(sourceFile, StandardCharsets.UTF_8);
System.out.println("\nConfig content:\n" + content);
}
}
publicstaticvoidmain(String[] args) throwsIOException {
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;
publicclassFileCopierBinary {
// Copy using BufferedInputStream/OutputStream — good for moderate file sizespublicstaticvoidcopyBuffered(Path source, Path target) throwsIOException {
try (InputStream in = newBufferedInputStream(newFileInputStream(source.toFile()));
OutputStream out = newBufferedOutputStream(newFileOutputStream(target.toFile()))) {
byte[] buffer = new byte[8192]; // 8 KB typical bufferint 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 OSpublicstaticvoidcopyChannel(Path source, Path target) throwsIOException {
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 heaplong position = 0;
long size = srcChannel.size();
while (position < size) {
position += srcChannel.transferTo(position, size - position, destChannel);
}
}
}
publicstaticvoidmain(String[] args) throwsIOException {
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.
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.
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
Aspect
java.io (Classic API)
java.nio.file (Modern NIO.2)
Introduced in
Java 1.0
Java 7
Main classes
File, FileReader, FileWriter, BufferedReader
Files, Paths, Path
Read entire file in one call
Not possible — must loop
Files.readAllLines() or Files.readString() (Java 11+)
Atomic move/rename
file.renameTo() — unreliable across platforms
Files.move() with ATOMIC_MOVE — reliable
Stream-based reading
Manual while-loop with readLine()
Files.lines() returns Stream<String>
Resource management
Manual try-finally or try-with-resources
try-with-resources (same, but less boilerplate with utility methods)
Exception specificity
Generic IOException for most operations
More specific: NoSuchFileException, AccessDeniedException, etc.
Walk a directory tree
Recursive manual implementation needed
Files.walk() or Files.walkFileTree()
Best use case
Low-level stream control, legacy codebases
New 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
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.
Q02 of 05SENIOR
Explain how you'd safely update a configuration file in a running application without risking data corruption if the process crashes mid-write.
ANSWER
The safe pattern is: write to a temporary file in the same directory, then atomically rename it to the target path. In Java NIO.2, use Files.move() with StandardCopyOption.ATOMIC_MOVE. This ensures that readers either see the old file or the new file — never a half-written state. Also, always clean up the temp file if the write fails. This is the same pattern used by package managers and databases.
Q03 of 05SENIOR
When would you choose Files.readAllLines() over Files.lines(), and what are the memory implications of choosing the wrong one for a 2GB log file?
ANSWER
Files.readAllLines() loads the entire file into a List<String> in memory — use it for small config files (a few MB max). Files.lines() returns a lazy Stream<String> that reads from disk on demand. If you call readAllLines() on a 2GB log file, you'll get an OutOfMemoryError because the entire file is held in the heap. With Files.lines(), you can process a file of any size using minimal memory — but you must wrap it in try-with-resources to close the underlying file handle.
Q04 of 05SENIOR
What happens if you forget to specify a charset when reading a text file? Why is this dangerous in production?
ANSWER
The JVM uses the platform's default charset, which varies across operating systems and locales. On a US machine, it might be UTF-8; on a Japanese server, it might be Shift_JIS. The same Java code that reads a file correctly on your laptop will produce mojibake garbage on a server with a different default charset. Always specify StandardCharsets.UTF_8 explicitly — it's the only way to guarantee consistent behavior across environments.
Q05 of 05SENIOR
How does FileChannel.transferTo() achieve better performance for copying large files compared to traditional stream copying?
ANSWER
transferTo() uses an OS-level system call (sendfile on Linux, or equivalent) that copies data from one file descriptor to another entirely in kernel space. This avoids copying the data through JVM heap memory. Traditional stream copying reads bytes into a Java byte array, then writes them out — that's two copies per chunk plus JVM overhead. For large files, this zero-copy approach can be 2-3x faster and reduces CPU load.
01
What's the difference between FileReader and BufferedReader? Why would you never use FileReader alone in production code?
JUNIOR
02
Explain how you'd safely update a configuration file in a running application without risking data corruption if the process crashes mid-write.
SENIOR
03
When would you choose Files.readAllLines() over Files.lines(), and what are the memory implications of choosing the wrong one for a 2GB log file?
SENIOR
04
What happens if you forget to specify a charset when reading a text file? Why is this dangerous in production?
SENIOR
05
How does FileChannel.transferTo() achieve better performance for copying large files compared to traditional stream copying?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What is the difference between FileInputStream and FileReader in Java?
FileInputStream reads raw bytes — use it for binary files like images, PDFs, or audio. FileReader reads characters and applies a charset encoding — use it for text files. Mixing them up means your text files may silently corrupt non-ASCII characters, so always pick the right one based on whether your data is binary or text.
Was this helpful?
02
How do I read a file line by line in Java without loading it all into memory?
Use Files.lines(Paths.get(filePath), StandardCharsets.UTF_8) inside a try-with-resources block. It returns a lazy Stream<String> that reads from disk as you consume the stream. This approach can process a 10GB file with only kilobytes of memory usage, because it never loads more than one buffered chunk at a time.
Was this helpful?
03
Why does my Java program throw FileNotFoundException even though the file clearly exists?
The most common cause is a relative path issue — your code says new File('config.txt') but the JVM's working directory isn't where you think it is. Print System.getProperty('user.dir') to see where Java is actually looking. Other causes include typos in the filename, wrong file extension casing on Linux (which is case-sensitive), or insufficient read permissions on the file.
Was this helpful?
04
What is the safe way to write a file that multiple threads or processes read?
Use the atomic write pattern: write to a uniquely named temporary file, flush and close it, then atomically rename it to the target file name. Use Files.move() with StandardCopyOption.ATOMIC_MOVE. This ensures that readers either see the old content or the new content, never a partial write. Additionally, consider file locking with FileChannel.lock() if you need exclusive access.
Was this helpful?
05
Can I use FileWriter to write to a file that is opened by another process?
On most operating systems, FileWriter can open a file even if another process has it open, but behavior depends on file locking and sharing modes. On Windows, you may get a FileNotFoundException or IOException if another process has an exclusive lock. On Linux, multiple writers are possible but data interleaving is likely. Use FileChannel with explicit locking for predictable cross-process behavior.