Java I/O Streams: Reading and Writing Data Efficiently
Java I/O Streams: Reading and Writing Data Efficiently
Java's I/O API was designed around a composable stream model: you stack streams on top of each other to add capabilities. A BufferedReader wraps an InputStreamReader which wraps a FileInputStream. Each layer adds one thing — buffering, character decoding, the raw bytes — without the others needing to know about it.
By 1998 at Motorola we were reading device configurations from files, writing poll results to disk, and streaming data between JVMs. Here is what the API looked like and how to use it without leaking resources or corrupting data.
Byte Streams vs Character Streams
Java separates byte I/O from character I/O.
Byte streams (InputStream/OutputStream) deal in raw bytes — network sockets, binary files, images.
Character streams (Reader/Writer) handle text with a specific character encoding. The bridge classes — InputStreamReader and OutputStreamWriter — convert between them.
Always specify the encoding explicitly. new InputStreamReader(stream) uses the platform default, which differs across JVMs and operating systems.
// Always specify encoding — platform default is not portable
Reader reader = new InputStreamReader(stream, "UTF-8");
Writer writer = new OutputStreamWriter(stream, "UTF-8");
Reading a Text File
BufferedReader reader = null;
try {
reader = new BufferedReader(
new InputStreamReader(
new FileInputStream("/etc/nms/devices.conf"), "UTF-8"));
String line;
while ((line = reader.readLine()) != null) {
if (!line.startsWith("#") && !line.trim().isEmpty()) {
processConfigLine(line);
}
}
} finally {
if (reader != null) try { reader.close(); } catch (IOException ignored) {}
}
BufferedReader adds an in-memory buffer so the JVM does not make a system call for every character. Always wrap file reads in a BufferedReader — the difference in performance on large files is significant.
Writing a Text File
PrintWriter writer = null;
try {
writer = new PrintWriter(
new BufferedWriter(
new OutputStreamWriter(
new FileOutputStream("/var/log/nms/devices.csv", true), // append
"UTF-8")));
writer.printf("%s,%s,%d%n", ip, status, System.currentTimeMillis());
} finally {
if (writer != null) writer.close();
}
PrintWriter.printf is convenient for formatted output. The true flag to FileOutputStream appends rather than truncating.
Reading Binary Data
For binary files — serialised objects, images, protocol buffers — use DataInputStream:
DataInputStream dis = new DataInputStream(
new BufferedInputStream(new FileInputStream(binaryFile)));
try {
int version = dis.readInt();
long deviceId = dis.readLong();
byte[] data = new byte[dis.readInt()];
dis.readFully(data);
} finally {
dis.close();
}
readFully blocks until all bytes are read — important on streams where reads can return fewer bytes than requested (sockets, pipes).
Copying Streams
The most common I/O operation is copying one stream to another:
public static void copy(InputStream in, OutputStream out) throws IOException {
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
}
8 KB is a good buffer size — large enough to reduce system calls, small enough to fit in CPU cache. Do not use in.read() one byte at a time; it is dramatically slower.
Serialisation
Java object serialisation writes any Serializable object to a byte stream:
// Write
ObjectOutputStream oos = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("snapshot.bin")));
oos.writeObject(deviceRegistry);
oos.close();
// Read
ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(new FileInputStream("snapshot.bin")));
DeviceRegistry registry = (DeviceRegistry) ois.readObject();
ois.close();
The catch: both sides must have the same version of the class, identified by serialVersionUID. Changing the class without updating the UID causes InvalidClassException. Declare it explicitly:
public class DeviceRegistry implements Serializable {
private static final long serialVersionUID = 1L;
// ...
}
NIO: A Better Model
Java 1.4 (2002) introduced the java.nio package with non-blocking I/O, channels, and memory-mapped files. For high-throughput servers, NIO's Selector-based model handles thousands of connections on a single thread — essential for servers that would previously have required one thread per connection.
For file I/O, FileChannel and memory-mapped files (MappedByteBuffer) made large file operations significantly faster by avoiding copies between kernel and user space.
The original stream API remains useful for simple sequential I/O. NIO is the right choice when you need scale or non-blocking behaviour.