Java Byte Streams vs Character Streams and Buffered I/O

Introduction Underneath methods such as Files.readString(), Java reads and writes data through streams - sequences of data flowing from a source or to a destination. (These I/O streams are unrelated to the Streams API of Java 8.) Java has two families of them: byte streams for raw binary data, and character streams for text. This tutorial explains byte streams vs character streams, and buffered I/O, in Java. We will start with the four base classes and how they are organized. We will then copy a binary file with byte streams, and read and write text with readers and writers - and see why the character encoding matters. Next we will measure what buffering does for speed. Lastly, we will connect bytes to characters with bridge classes, and choose the right class for a job.

(Note - This tutorial builds on Reading and Writing Files with Path and FilesClick to read tutorial: Reading and Writing Files in Java with Path and Files and try-with-resourcesClick to read tutorial: Java try-with-resources and AutoCloseable Explained, which every stream example here uses.) Two families of streams
Byte streamsCharacter streams
Unit of data8-bit bytes16-bit chars (text)
Base classesInputStream, OutputStreamReader, Writer
File classesFileInputStream, FileOutputStreamFileReader, FileWriter
Buffered versionsBufferedInputStream, BufferedOutputStreamBufferedReader, BufferedWriter
Use forImages, audio, ZIP files, network protocols - any binary dataText: CSV, logs, JSON, configuration files

Streams are designed to be wrapped inside each other, each layer adding a feature - new BufferedReader(new FileReader(file)) adds buffering and readLine() to a plain file reader. This is the Decorator design pattern. In modern code, Files.newInputStream(), Files.newBufferedReader() and similar methods create the right stream for a Path in one call. Byte streams - reading and writing raw bytes InputStream.read() returns one byte as an int from 0 to 255, or -1 at the end of the stream. Reading into a byte[] buffer with read(buffer), which returns how many bytes it read, is far more efficient. Here we write a small binary file and copy it.
Writing and copying binary data with byte streams
//ByteCopy.java
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;

public class ByteCopy {
  public static void main(String[] args) throws IOException {
    Path source = Path.of("data.bin");
    try (OutputStream out = Files.newOutputStream(source)) {
      for (int i = 0; i < 1000; i++) {
        out.write(i % 256);                       // write one byte
      }
    }

    Path target = Path.of("data-copy.bin");
    try (InputStream in = Files.newInputStream(source);
         OutputStream out = Files.newOutputStream(target)) {
      byte[] buffer = new byte[256];
      int count;
      int reads = 0;
      while ((count = in.read(buffer)) != -1) {   // -1 means end of stream
        out.write(buffer, 0, count);
        reads++;
      }
      System.out.println("Copied using " + reads + " reads of up to 256 bytes");
    }

    byte[] copied = Files.readAllBytes(target);
    System.out.println("Size: " + copied.length + ", first bytes: " + Arrays.toString(Arrays.copyOf(copied, 6)));
    System.out.println("Identical: " + Arrays.equals(Files.readAllBytes(source), copied));
    try (InputStream in = Files.newInputStream(source)) {
      System.out.println("transferTo copied " + in.transferTo(OutputStream.nullOutputStream()) + " bytes");
    }
    Files.delete(source);
    Files.delete(target);
  }
}
 OUTPUT of the above code
Copied using 4 reads of up to 256 bytes
Size: 1000, first bytes: [0, 1, 2, 3, 4, 5]
Identical: true
transferTo copied 1000 bytes
  • write(buffer, 0, count) writes only the bytes actually read - the last read usually fills only part of the buffer (1000 bytes needed four reads: 256, 256, 256 and 232).
  • Since Java 9, InputStream.transferTo() copies a whole stream in one call, and Files.copy() does the same for files.
Character streams - reading and writing text Files store bytes, not characters. A character encoding - such as UTF-8 - defines how characters are turned into bytes and back. Readers and writers do this conversion for you. BufferedReader.readLine() reads a line at a time, and BufferedWriter.newLine() writes the platform's line separator.
Writing and reading text with character streams
//TextStreams.java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public class TextStreams {
  public static void main(String[] args) throws IOException {
    Path file = Path.of("cities.txt");
    try (BufferedWriter writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
      for (String city : new String[] {"London", "Zürich", "Mumbai", "東京"}) {
        writer.write(city);
        writer.newLine();
      }
    }

    try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
      String line;
      int number = 1;
      while ((line = reader.readLine()) != null) {      // null means end of file
        System.out.println(number++ + ": " + line + " (" + line.length() + " chars, "
            + line.getBytes(StandardCharsets.UTF_8).length + " bytes in UTF-8)");
      }
    }
    Files.delete(file);
  }
}
 OUTPUT of the above code
1: London (6 chars, 6 bytes in UTF-8)
2: Zürich (6 chars, 7 bytes in UTF-8)
3: Mumbai (6 chars, 6 bytes in UTF-8)
4: 東京 (2 chars, 6 bytes in UTF-8)
Zürich has 6 characters but 7 bytes, and 東京 2 characters but 6 bytes - in UTF-8, characters outside basic English take more than one byte. This is why text must be handled as characters, not bytes: splitting a byte stream at an arbitrary point could cut a character in half. Why the encoding matters If text is written in one encoding and read in another, characters outside basic ASCII come out wrong - mojibake. Always state the encoding explicitly when it matters, and use UTF-8 unless you have a reason not to. (Since Java 18, UTF-8 is the default for the file APIs everywhere; before that, the default depended on the operating system.)
Reading UTF-8 text with the wrong encoding
//EncodingMismatch.java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public class EncodingMismatch {
  public static void main(String[] args) throws IOException {
    Path file = Path.of("menu.txt");
    Files.writeString(file, "Crème brûlée - 8€", StandardCharsets.UTF_8);
    System.out.println("Read as UTF-8     : " + Files.readString(file, StandardCharsets.UTF_8));
    System.out.println("Read as ISO-8859-1: " + Files.readString(file, StandardCharsets.ISO_8859_1));
    Files.delete(file);
  }
}
 OUTPUT of the above code
Read as UTF-8     : Crème brûlée - 8€
Read as ISO-8859-1: Crème brûlée - 8€
Buffered I/O - why it matters Every read() or write() on an unbuffered file stream can become a call into the operating system, which is slow. A buffered stream keeps an internal buffer - 8 KB by default - and talks to the file in large blocks, while your code reads and writes small pieces from memory. Let us write and read 1,000,000 single bytes, with and without a buffer.
Unbuffered vs buffered streams
//BufferingSpeed.java
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

public class BufferingSpeed {
  static long writeAndRead(String file, boolean buffered) throws IOException {
    long start = System.nanoTime();
    try (OutputStream out = buffered ? new BufferedOutputStream(new FileOutputStream(file)) : new FileOutputStream(file)) {
      for (int i = 0; i < 1_000_000; i++) out.write(i);
    }
    long sum = 0;
    try (InputStream in = buffered ? new BufferedInputStream(new FileInputStream(file)) : new FileInputStream(file)) {
      int b;
      while ((b = in.read()) != -1) sum += b;
    }
    new java.io.File(file).delete();
    return (System.nanoTime() - start) / 1_000_000;
  }

  public static void main(String[] args) throws IOException {
    System.out.println("Unbuffered: " + writeAndRead("plain.bin", false) + " ms");
    System.out.println("Buffered  : " + writeAndRead("buffered.bin", true) + " ms");
  }
}
 OUTPUT of the above code (timings vary from machine to machine)
Unbuffered: 5899 ms
Buffered  : 90 ms
The buffered version is dramatically faster for the same work. Always buffer file I/O that reads or writes in small pieces - or read and write in large blocks yourself, as the byte copy example did. Remember that a buffered writer holds data in memory until its buffer fills, it is flushed with flush(), or it is closed - another reason to close streams with try-with-resources. Bridges between bytes and characters Sometimes you have a byte stream - from a network socket, a ZIP entry or System.in - and need text. InputStreamReader and OutputStreamWriter are the bridges: they wrap a byte stream and decode or encode characters with a given charset. PrintWriter adds the familiar println() and printf() to any writer.
Converting between byte and character streams
//Bridges.java
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;

public class Bridges {
  public static void main(String[] args) throws IOException {
    ByteArrayOutputStream bytes = new ByteArrayOutputStream();          // an in-memory byte stream
    try (PrintWriter out = new PrintWriter(new OutputStreamWriter(bytes, StandardCharsets.UTF_8))) {
      out.printf("%-6s %5.2f%n", "Tea", 2.5);
      out.printf("%-6s %5.2f%n", "Coffee", 3.75);
    }
    System.out.println("Encoded to " + bytes.size() + " bytes");

    try (BufferedReader in = new BufferedReader(new InputStreamReader(
        new ByteArrayInputStream(bytes.toByteArray()), StandardCharsets.UTF_8))) {
      in.lines().forEach(line -> System.out.println("read back: [" + line + "]"));
    }
  }
}
 OUTPUT of the above code
Encoded to 28 bytes
read back: [Tea     2.50]
read back: [Coffee  3.75]
ByteArrayInputStream and ByteArrayOutputStream are byte streams over memory instead of a file - useful in tests, and to show that every stream class works the same way whatever the source is. Choosing the right class
  • Whole small text or binary file: Files.readString(), writeString(), readAllBytes().
  • Large text file, line by line: Files.newBufferedReader() with readLine(), or Files.lines().
  • Writing lots of text: Files.newBufferedWriter(), wrapped in a PrintWriter for formatting.
  • Binary data: Files.newInputStream()/newOutputStream() with a byte[] buffer, or transferTo().
  • Text from a byte source: InputStreamReader with an explicit charset, inside a BufferedReader.
  • Always use try-with-resources, and always specify the charset for text.
Summary In this tutorial we first saw the two families of I/O streams - byte streams based on InputStream and OutputStream, and character streams based on Reader and Writer - and how they wrap each other. We then copied a binary file with a byte buffer, and read and wrote text with BufferedReader and BufferedWriter, seeing that one character can take several bytes. Next we saw what happens when the wrong encoding is used, and measured how much faster buffered streams are. Lastly, we bridged byte and character streams with InputStreamReader and OutputStreamWriter, and summarized which class to choose. In the next tutorial, we will write whole objects to streams with serializationClick to read tutorial: Java Serialization and Deserialization Basics.