Lesson 55 of 71
Streams, Readers, and Files
This lesson covers blocking I/O in java.io and the java.nio.file API for files. After it, a reader can read and write bytes and text with the right charset, choose a buffer, work with files through Path and Files, and explain why Java serialization is avoided in new code. ByteBuffer and memory-mapped files are taught in ByteBuffer, Direct Buffers, and Memory-Mapped Files and are only linked here.
| API | Purpose |
|---|---|
InputStream, OutputStream |
Sequences of bytes |
Reader, Writer |
Sequences of char, decoded from bytes with a Charset |
BufferedInputStream, BufferedOutputStream, BufferedReader, BufferedWriter |
Fewer calls to the underlying stream |
Path, Files |
File names and file operations |
RandomAccessFile |
Read and write at any offset |
FileChannel |
Channel-based file I/O, transferTo |
ObjectOutputStream, ObjectInputStream |
Java serialization |
A blocking call is one that does not return until data is available, the data is written, or an error occurs. Every API in this lesson blocks. Non-blocking channels are the subject of NIO Channels and Selectors.
Every file example works inside a directory from Files.createTempDirectory and deletes it at the end. The programs print contents and counts, never paths.
Example 1: InputStream and OutputStream
An OutputStream accepts bytes and an InputStream produces them. read() returns one byte as an int from 0 to 255, or -1 at the end of the stream. read(byte[]) returns the number of bytes it put in the array, which can be fewer than the array length.
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
public class ByteStreams {
public static void main(String[] args) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(1);
out.write(new byte[] {2, 3, 4, 5, (byte) 200});
byte[] data = out.toByteArray();
System.out.println("written: " + Arrays.toString(data));
try (InputStream in = new ByteArrayInputStream(data)) {
System.out.println("first read(): " + in.read());
byte[] chunk = new byte[4];
int n;
while ((n = in.read(chunk)) != -1) {
System.out.println("read " + n + " bytes: " + Arrays.toString(Arrays.copyOf(chunk, n)));
}
System.out.println("at end: " + in.read());
}
}
}written: [1, 2, 3, 4, 5, -56]
first read(): 1
read 4 bytes: [2, 3, 4, 5]
read 1 bytes: [-56]
at end: -1The byte 200 is stored as the signed byte value -56. read() returns an int so that all 256 byte values and the end marker -1 fit in one return type. The second read(chunk) returned 1, not 4: code that assumes a full array is a common bug. InputStream.readNBytes(n) and readAllBytes() loop until they have the requested count or reach the end.
Example 2: readers, writers, and charsets
A Reader produces char values and a Writer accepts them. InputStreamReader and OutputStreamWriter connect the two worlds by decoding and encoding with a Charset. The same text has a different byte length in different charsets.
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
public class Charsets {
public static void main(String[] args) throws IOException {
String text = "naïve €5";
System.out.println("chars: " + text.length());
ByteArrayOutputStream utf8 = new ByteArrayOutputStream();
try (Writer w = new OutputStreamWriter(utf8, StandardCharsets.UTF_8)) {
w.write(text);
}
System.out.println("UTF-8 bytes: " + utf8.size());
ByteArrayOutputStream utf16 = new ByteArrayOutputStream();
try (Writer w = new OutputStreamWriter(utf16, StandardCharsets.UTF_16BE)) {
w.write(text);
}
System.out.println("UTF-16BE bytes: " + utf16.size());
try (Reader r = new InputStreamReader(new ByteArrayInputStream(utf8.toByteArray()), StandardCharsets.UTF_8)) {
StringBuilder sb = new StringBuilder();
int c;
while ((c = r.read()) != -1) sb.append((char) c);
System.out.println("decoded with UTF-8: " + sb);
}
String wrong = new String(utf8.toByteArray(), StandardCharsets.ISO_8859_1);
System.out.println("decoded with ISO-8859-1: " + wrong.length() + " chars, equal = " + wrong.equals(text));
}
}chars: 8
UTF-8 bytes: 11
UTF-16BE bytes: 16
decoded with UTF-8: naïve €5
decoded with ISO-8859-1: 11 chars, equal = falseï takes 2 bytes in UTF-8 and € takes 3, so 8 characters become 11 bytes. Decoding with the wrong charset does not fail: it produces different text, one character per byte. Since Java 18 the default charset is UTF-8, but passing the charset explicitly keeps the code correct when it reads data produced elsewhere.
Example 3: buffering
Each call on an unbuffered stream reaches the underlying sink, and for a file or socket that is one system call. A buffered stream collects writes in an array and passes them on in large blocks. This example counts the calls that reach the sink.
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
public class Buffering {
static class CountingSink extends OutputStream {
int calls;
long bytes;
@Override public void write(int b) { calls++; bytes++; }
@Override public void write(byte[] b, int off, int len) { calls++; bytes += len; }
}
public static void main(String[] args) throws IOException {
CountingSink raw = new CountingSink();
for (int i = 0; i < 100_000; i++) raw.write('x');
System.out.println("unbuffered: calls=" + raw.calls + " bytes=" + raw.bytes);
CountingSink sink = new CountingSink();
try (OutputStream out = new BufferedOutputStream(sink, 8192)) {
for (int i = 0; i < 100_000; i++) out.write('x');
}
System.out.println("buffered: calls=" + sink.calls + " bytes=" + sink.bytes);
CountingSink lost = new CountingSink();
OutputStream notClosed = new BufferedOutputStream(lost, 8192);
notClosed.write(new byte[100]);
System.out.println("before flush: bytes=" + lost.bytes);
notClosed.flush();
System.out.println("after flush: bytes=" + lost.bytes);
}
}unbuffered: calls=100000 bytes=100000
buffered: calls=13 bytes=100000
before flush: bytes=0
after flush: bytes=100100,000 single-byte writes become 13 block writes of up to 8192 bytes. The last part of the data stays in the buffer until flush or close, so a buffered stream that is never closed loses its tail. The JDK default buffer size for BufferedInputStream, BufferedReader, and BufferedWriter is 8192 (DEFAULT_BUFFER_SIZE and DEFAULT_CHAR_BUFFER_SIZE in the JDK source). BufferedOutputStream without a size argument starts smaller and grows up to 8192.
Example 4: Path and Files
Path is an immutable file name. Files holds static methods that act on paths: create, read, write, copy, move, delete, list, and read attributes. writeString and readAllLines open and close the file inside the call.
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
public class PathsAndFiles {
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("io-demo");
try {
Path notes = dir.resolve("notes.txt");
System.out.println("file name: " + notes.getFileName());
System.out.println("exists before: " + Files.exists(notes));
Files.writeString(notes, "alpha\nbeta\n", StandardCharsets.UTF_8);
Files.writeString(notes, "gamma\n", StandardOpenOption.APPEND);
System.out.println("size: " + Files.size(notes));
List<String> lines = Files.readAllLines(notes);
System.out.println("lines: " + lines);
Path copy = Files.copy(notes, dir.resolve("copy.txt"));
Files.move(copy, dir.resolve("moved.txt"), StandardCopyOption.ATOMIC_MOVE);
Files.createDirectories(dir.resolve("a/b"));
try (Stream<Path> s = Files.list(dir)) {
System.out.println("entries: " + s.map(p -> p.getFileName().toString()).sorted().toList());
}
} finally {
try (Stream<Path> walk = Files.walk(dir)) {
walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
}
}
System.out.println("deleted: " + !Files.exists(dir));
}
}file name: notes.txt
exists before: false
size: 17
lines: [alpha, beta, gamma]
entries: [a, moved.txt, notes.txt]
deleted: trueresolve joins path parts without touching the disk. ATOMIC_MOVE asks the file system for a rename that other processes see as one step, a common way to publish a finished file. Files.list and Files.walk return streams that hold an open directory handle, so they go in try-with-resources. Deleting a tree means deleting children first, which the reverse-sorted walk does.
Example 5: Files.lines and try-with-resources
Files.lines returns a lazy Stream<String>: it reads lines as the stream pulls them, so a large file is not loaded into memory at once. The stream holds the file open until it is closed. Try-with-resources closes it even when the pipeline throws.
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class LogScan {
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("logs");
Path log = dir.resolve("app.log");
try {
Files.write(log, java.util.List.of(
"INFO start", "WARN slow disk", "INFO request ok",
"ERROR timeout", "INFO request ok", "ERROR refused"));
try (Stream<String> lines = Files.lines(log)) {
Map<String, Long> byLevel = lines
.map(l -> l.substring(0, l.indexOf(' ')))
.collect(Collectors.groupingBy(l -> l, TreeMap::new, Collectors.counting()));
System.out.println("by level: " + byLevel);
}
try (BufferedReader r = Files.newBufferedReader(log)) {
String line;
int n = 0;
while ((line = r.readLine()) != null) {
n++;
if (line.startsWith("ERROR")) System.out.println("line " + n + ": " + line);
}
}
} finally {
Files.deleteIfExists(log);
Files.deleteIfExists(dir);
}
}
}by level: {ERROR=2, INFO=3, WARN=1}
line 4: ERROR timeout
line 6: ERROR refusedThe two styles read the same file. The stream suits filtering and grouping. The BufferedReader loop suits code that tracks state across lines, such as a line number. Both decode with UTF-8 unless a charset is passed. Resources declared in one try-with-resources header close in reverse order, and an exception from close is added as a suppressed exception when the body has already thrown (see Exceptions).
Example 6: RandomAccessFile with fixed-size records
RandomAccessFile reads and writes at any byte offset through seek. With fixed-size records, record i starts at i * RECORD_SIZE, so one record can be read or updated without reading the rest of the file.
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
public class Records {
static final int RECORD = Long.BYTES + Integer.BYTES; // id + quantity = 12 bytes
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("raf");
Path file = dir.resolve("orders.dat");
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "rw")) {
for (int i = 0; i < 5; i++) {
raf.writeLong(1000 + i);
raf.writeInt(10 * (i + 1));
}
System.out.println("length: " + raf.length());
raf.seek(3L * RECORD + Long.BYTES); // quantity of record 3
raf.writeInt(999);
for (int i = 0; i < 5; i++) {
raf.seek((long) i * RECORD);
System.out.println("record " + i + ": id=" + raf.readLong() + " qty=" + raf.readInt());
}
System.out.println("pointer: " + raf.getFilePointer());
} finally {
Files.deleteIfExists(file);
Files.deleteIfExists(dir);
}
}
}length: 60
record 0: id=1000 qty=10
record 1: id=1001 qty=20
record 2: id=1002 qty=30
record 3: id=1003 qty=999
record 4: id=1004 qty=50
pointer: 60writeLong and writeInt use big-endian byte order, the same as DataOutputStream. The mode string "rw" opens for reading and writing and creates the file; "rwd" and "rws" also write each update to the storage device before returning. RandomAccessFile is unbuffered, so each readInt is one system call. For bulk access, a FileChannel with a large ByteBuffer or a mapped file is faster.
Example 7: FileChannel basics and transferTo
FileChannel reads and writes ByteBuffers at a position and is covered in detail, together with mapping, in ByteBuffer, Direct Buffers, and Memory-Mapped Files. This example shows two things that lesson does not: getting a channel from a stream class, and transferTo, which copies between channels and lets the operating system move the bytes without passing them through a Java array.
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class ChannelCopy {
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("chan");
Path src = dir.resolve("src.bin");
Path dst = dir.resolve("dst.bin");
try {
byte[] data = new byte[100_000];
for (int i = 0; i < data.length; i++) data[i] = (byte) i;
Files.write(src, data);
try (RandomAccessFile raf = new RandomAccessFile(src.toFile(), "r");
FileChannel in = raf.getChannel();
FileChannel out = FileChannel.open(dst, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
System.out.println("source size: " + in.size());
long done = 0;
while (done < in.size()) {
done += in.transferTo(done, in.size() - done, out);
}
System.out.println("copied: " + done);
System.out.println("source position unchanged: " + (in.position() == 0));
}
System.out.println("mismatch index: " + Files.mismatch(src, dst));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(dst);
Files.deleteIfExists(dir);
}
}
}source size: 100000
copied: 100000
source position unchanged: true
mismatch index: -1transferTo can copy fewer bytes than asked, so the loop continues until the count is reached. The positional form does not move the source channel's position. On Linux the JDK can hand the copy to a kernel system call so the bytes stay in kernel memory. Files.mismatch (Java 12) returns -1 when the two files are equal. Files.copy does the same job in one call; transferTo matters when the target is a socket channel.
Example 8: serialization and why it is avoided
Java serialization writes an object graph to bytes with ObjectOutputStream and rebuilds it with ObjectInputStream. Reading creates objects of whatever classes the bytes name, without running their constructors (records are the exception: they are rebuilt through the canonical constructor). An ObjectInputFilter limits which classes may be created.
import java.io.*;
import java.util.ArrayList;
import java.util.List;
public class Serial {
record Trade(String symbol, long qty) implements Serializable {
Trade {
if (qty <= 0) throw new IllegalArgumentException("qty must be positive");
}
}
static byte[] write(Object o) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
out.writeObject(o);
}
return bytes.toByteArray();
}
public static void main(String[] args) throws Exception {
byte[] bytes = write(new Trade("ACME", 100));
System.out.println("serialized size: " + bytes.length + " bytes for 4 chars + 8 bytes of data");
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
System.out.println("read back: " + in.readObject());
}
byte[] listBytes = write(new ArrayList<>(List.of(1, 2, 3)));
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(listBytes))) {
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter("Serial$Trade;java.lang.*;!*"));
in.readObject();
} catch (InvalidClassException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}serialized size: 84 bytes for 4 chars + 8 bytes of data
read back: Trade[symbol=ACME, qty=100]
rejected: filter status: REJECTEDThe reasons serialization is avoided in new code:
| Problem | Consequence |
|---|---|
| Reading creates any class on the class path | Crafted input can run code through "gadget" classes; many CVEs come from this |
| Constructors of ordinary classes are skipped | Invariants checked in constructors are not enforced on read |
Format tied to class names and serialVersionUID |
Renaming or changing a class breaks old data |
| Size and speed | Class descriptors make small objects large; the format is Java-only |
The filter allows only Trade and java.lang classes, so the ArrayList in the second stream is rejected before any object is created. Common replacements are explicit formats: JSON, Protocol Buffers, or a hand-written binary layout (see Binary Protocols and Encoding). When old code must read serialized data, a filter with an allow list and !* at the end rejects everything else.
Interview points
read()returns-1at end of stream;read(byte[])may return fewer bytes than requested. Loop, or usereadNBytes.- Bytes become text only through a
Charset. Wrong charset gives wrong text, not an exception. - Buffering reduces system calls; an unflushed buffered writer loses data. Close in try-with-resources.
Files.lines,Files.list, andFiles.walkhold open handles and must be closed.RandomAccessFilegives offset access for fixed-size records; it is unbuffered.FileChannel.transferTocopies without a user-space array and can be partial.- Java serialization is a security risk on untrusted input; use explicit formats or an
ObjectInputFilterallow list.
Summary
InputStream/OutputStreamcarry bytes;Reader/Writercarry characters decoded with a charset.- Default JDK buffer size for buffered readers and streams is 8192.
Pathnames a file;Filesperforms operations on it.Files.linesis lazy and must be closed.RandomAccessFile.seekmoves to an offset; itswriteInt/writeLongare big-endian.FileChanneladds positional reads and writes andtransferTo.- Serialization skips constructors of non-record classes and creates classes named by the input.
