Skip to content

Java I/O API

Step 17 of 20

Java I/O API

Introduction

This lesson covers the ninth objective of the Oracle Certified Professional Java SE 21 Developer exam (1Z0-830): Using Java I/O API. Oracle lists three skills: reading and writing console and file data with I/O streams, serializing and deserializing Java objects, and constructing, traversing, creating, reading and writing Path objects and their properties with the java.nio.file API. Oracle does not publish a weighting per objective. In practice, expect code snippets that call Path methods and ask for the printed value, snippets that copy or move files and ask which exception is thrown, and serialization snippets that ask which fields survive a round trip.

To keep the lesson concrete, we work on a small invoicing tool for a bookshop: it reads a CSV of orders, writes a report, stores invoice objects on disk, and archives old files. Every example below belongs to that tool.

Learning objectives

After this lesson you will be able to:

  • Use byte streams (InputStream, OutputStream) and character streams (Reader, Writer) with buffering, and choose between them.
  • Read from and write to the console with System.in, System.out and java.io.Console.
  • Read and write text and binary files with Files, BufferedReader, BufferedWriter, PrintWriter.
  • Create, resolve, relativize, normalize and compare Path objects.
  • Copy, move, delete, list and walk files and directories with Files, with the right StandardCopyOption and OpenOption values.
  • Read file attributes.
  • Serialize and deserialize objects, and explain the effect of Serializable, transient, static and serialVersionUID.

1. Streams: bytes and characters

java.io has two families.

Family Base classes Unit Typical subclasses
Byte streams InputStream, OutputStream int (0 to 255) / byte FileInputStream, FileOutputStream, ByteArrayInputStream, BufferedInputStream, DataInputStream, ObjectInputStream
Character streams Reader, Writer char FileReader, FileWriter, BufferedReader, BufferedWriter, InputStreamReader, PrintWriter, StringReader

InputStream.read() returns an int in the range 0 to 255, or -1 at end of stream. read(byte[]) returns the number of bytes read, or -1. Bridges between the families are InputStreamReader and OutputStreamWriter, which take a charset.

Wrappers add features (decorator pattern):

try (BufferedReader in = new BufferedReader(new FileReader("orders.csv"));
     PrintWriter out = new PrintWriter(new BufferedWriter(new FileWriter("report.txt")))) {
    String line;
    while ((line = in.readLine()) != null) {      // null at end of file
        out.println(line.toUpperCase());
    }
}   // closes both, in reverse order

Rules:

  • Closing a wrapper closes the wrapped stream. With try-with-resources, resources close in reverse order of declaration.
  • BufferedWriter content may not reach the file until flush() or close(). If you forget to close, data can be lost.
  • FileWriter truncates the file by default; new FileWriter(name, true) appends. FileOutputStream behaves the same way with its second argument.
  • readLine() removes the line terminator and returns null at the end.
  • PrintWriter and PrintStream never throw IOException; they set an internal error flag checked with checkError().
  • FileReader and FileWriter use the default charset (UTF-8 by default since Java 18, JEP 400). Always prefer an explicit charset: new FileReader(file, StandardCharsets.UTF_8).
  • DataOutputStream writes primitives in binary (writeInt, writeUTF), and DataInputStream must read them back in the same order.
  • mark(int)/reset() work only when markSupported() is true (for example BufferedReader, ByteArrayInputStream).

2. Console I/O

System.in is an InputStream, System.out and System.err are PrintStreams. A Scanner or BufferedReader makes keyboard input comfortable:

Scanner sc = new Scanner(System.in);
int qty = sc.nextInt();
sc.nextLine();                 // consume the rest of the line
String note = sc.nextLine();

A classic bug: nextInt() does not consume the line terminator, so the next nextLine() returns an empty string.

java.io.Console is obtained with System.console(), which returns null when there is no interactive terminal (IDE, redirected input, many services). Its main methods are readLine(), readPassword() (returns a char[] and does not echo), printf and writer(). Always test for null. Note that Java 22 changed the behavior of System.console() for non-interactive cases, but on Java 21 the null rule applies.

3. Files with java.nio.file

The NIO.2 API (Java 7) is the modern way. Its core types are the interface Path, the utility class Files and the helper Paths (or Path.of, preferred since Java 11).

Creating and inspecting paths

Path p = Path.of("/shop/data/../invoices/2026/inv-001.txt");
p.getFileName();      // inv-001.txt
p.getParent();        // /shop/data/../invoices/2026
p.getRoot();          // /
p.getNameCount();     // 6 (shop, data, .., invoices, 2026, inv-001.txt)
p.getName(0);         // shop
p.subpath(1, 3);      // data/..
p.isAbsolute();       // true
p.normalize();        // /shop/invoices/2026/inv-001.txt

Path methods are purely syntactic: they do not check that the file exists. Files.exists(path) and Files.notExists(path) do. Note that both can be false (when access is denied or status is unknown).

resolve, relativize, normalize, toAbsolutePath

Method Behavior Example result
a.resolve(b) Appends b to a; if b is absolute, returns b /shop.resolve(inv/1.txt) = /shop/inv/1.txt
a.resolveSibling(b) Resolves b against a.getParent() /shop/a.txt.resolveSibling(b.txt) = /shop/b.txt
a.relativize(b) Path from a to b; both must be of the same kind (both absolute or both relative) else IllegalArgumentException /shop.relativize(/shop/inv/1.txt) = inv/1.txt
p.normalize() Removes . and resolves .. lexically a/b/../c becomes a/c
p.toAbsolutePath() Prepends the working directory if relative
p.toRealPath() Resolves symbolic links, checks existence, throws IOException if absent

relativize with ..: Path.of("/a/b").relativize(Path.of("/a/c/d")) is ../c/d. Path.of("a").resolve("") returns a. normalize does not touch the file system, so on a symbolic link it can differ from the real parent.

Reading and writing whole files

Path orders = Path.of("data", "orders.csv");

List<String> lines = Files.readAllLines(orders);                 // UTF-8
String text = Files.readString(orders);                          // Java 11
byte[] bytes = Files.readAllBytes(orders);
try (Stream<String> s = Files.lines(orders)) {                   // lazy, must be closed
    long big = s.filter(l -> l.endsWith(",PAID")).count();
}

Files.writeString(Path.of("report.txt"), "Total: 42\n");
Files.write(Path.of("report.txt"), List.of("a", "b"), StandardOpenOption.APPEND);
try (BufferedWriter w = Files.newBufferedWriter(Path.of("out.txt"))) {
    w.write("hello");
    w.newLine();
}

Files.write and writeString default to CREATE, TRUNCATE_EXISTING and WRITE. With APPEND, the file is extended. With CREATE_NEW, an existing file causes FileAlreadyExistsException. Opening a missing file for reading throws NoSuchFileException. All of these are subclasses of IOException.

Create, copy, move, delete

Files.createDirectory(Path.of("archive"));                       // parent must exist
Files.createDirectories(Path.of("archive/2026/10"));             // creates all, no error if present
Files.createFile(Path.of("archive/readme.txt"));                 // FileAlreadyExistsException if present

Files.copy(src, dst);                                            // fails if dst exists
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
Files.move(src, dst, StandardCopyOption.ATOMIC_MOVE);
Files.delete(path);                                              // NoSuchFileException if absent, DirectoryNotEmptyException if non-empty directory
Files.deleteIfExists(path);                                      // returns boolean

Important details:

  • Files.copy of a directory copies only the directory itself, not its content (an empty directory is created). Copy is shallow.
  • Files.copy does not copy file attributes unless COPY_ATTRIBUTES is passed.
  • Files.move of a non-empty directory within the same file store works as a rename. ATOMIC_MOVE cannot be combined with other options meaningfully, and the implementation may throw AtomicMoveNotSupportedException.
  • There is also Files.copy(InputStream, Path) and Files.copy(Path, OutputStream).

Listing and walking

Method Depth Returns Notes
Files.list(dir) One level Stream<Path> Must be closed
Files.walk(dir) Recursive (all) Stream<Path> Includes dir itself; walk(dir, maxDepth) limits depth
Files.find(dir, depth, BiPredicate) Recursive Stream<Path> Predicate receives path and attributes
Files.newDirectoryStream(dir, glob) One level DirectoryStream<Path> Glob filter, closeable
Files.walkFileTree(dir, visitor) Recursive Visitor callbacks, used for recursive delete
try (Stream<Path> s = Files.walk(Path.of("archive"), 2)) {
    s.filter(Files::isRegularFile)
     .filter(p -> p.toString().endsWith(".txt"))
     .forEach(System.out::println);
}

Walking depth: walk(dir, 1) is equivalent to list(dir) plus dir itself. A recursive delete of a tree needs walkFileTree or a reverse-sorted walk, because Files.delete refuses non-empty directories.

Attributes

long size = Files.size(path);
boolean dir = Files.isDirectory(path);
FileTime modified = Files.getLastModifiedTime(path);
BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
attrs.creationTime(); attrs.isSymbolicLink();
Files.isSameFile(a, b);        // compares the actual files, can throw IOException
Files.isReadable(path); Files.isWritable(path); Files.isHidden(path);

Path equality: Path.of("a").equals(Path.of("a/")) is true (trailing separator is removed), but Path.of("a/../b") is not equal to Path.of("b"), since equality is syntactic.

4. Legacy java.io.File

File still appears. new File("x") does nothing on disk. exists(), createNewFile(), mkdir(), mkdirs(), delete() return booleans instead of throwing, list() returns String[], and toPath() bridges to NIO. Path.toFile() goes the other way. New code should prefer Path.

5. Serialization

Serialization converts an object graph into a byte stream with ObjectOutputStream.writeObject; ObjectInputStream.readObject reconstructs it. The class must implement the marker interface java.io.Serializable, otherwise NotSerializableException is thrown at write time.

class Invoice implements Serializable {
    private static final long serialVersionUID = 1L;
    private String number;
    private transient String internalNote;    // not serialized
    private static int counter = 99;          // not serialized (belongs to the class)
    private Customer customer;                // Customer must be Serializable too
    Invoice(String number, String note) { this.number = number; this.internalNote = note; }
}

try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(Path.of("inv.ser")))) {
    out.writeObject(new Invoice("INV-1", "secret"));
}
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(Path.of("inv.ser")))) {
    Invoice copy = (Invoice) in.readObject();   // ClassNotFoundException, IOException
    // copy.internalNote == null
}

Rules to memorize:

  • Constructors: when deserializing, the constructor of the serializable class (and of serializable ancestors) is not called. The no-argument constructor of the first non-serializable superclass is called, so that class must have an accessible no-argument constructor, otherwise InvalidClassException occurs at read time.
  • Default values: transient fields come back as their default (null, 0, false), not the initializer value from the declaration (initializers do not run either, since the constructor is skipped).
  • Static fields are not part of the object state.
  • Object graph: every referenced object must be serializable or the field must be transient; otherwise NotSerializableException.
  • serialVersionUID: a long constant used to check compatibility. If absent, the JVM computes one from the class structure, so a small change makes old files unreadable with InvalidClassException. Declare it explicitly.
  • Inheritance: if the superclass is serializable, so are all subclasses. Fields of a non-serializable superclass are not saved.
  • Shared references are preserved within one stream: if two fields point at the same object, they point to one copy after reading.
  • Records can be serializable by implementing Serializable; their state is rebuilt through the canonical constructor, and serialVersionUID is not compared for them.
  • Custom hooks private void writeObject(ObjectOutputStream) and private void readObject(ObjectInputStream) allow custom handling; Externalizable requires a public no-argument constructor.
  • Never deserialize untrusted data; use ObjectInputFilter if you must.

Worked example: round trip with transient and static

Statement. Given the class below, what does the program print?

class Book implements Serializable {
    String title;
    transient int copies = 5;
    static String shelf = "A";
    Book(String t, int c) { title = t; copies = c; }
}
// main
Book b = new Book("Java 21", 12);
Book.shelf = "B";
// serialize b to bytes, then in the same JVM:
Book.shelf = "C";
Book r = deserialize(bytes);
System.out.println(r.title + " " + r.copies + " " + Book.shelf);

Step 1. title is a normal instance field: it is written and restored as Java 21.

Step 2. copies is transient, so it is not written. On reading, the constructor is not called and the declaration initializer = 5 does not run, so the field gets the default 0.

Step 3. shelf is static, so it is not in the stream. It keeps the current JVM value, C.

Solution. The output is Java 21 0 C.

Why the other choices are wrong. Java 21 12 C forgets transient. Java 21 5 C assumes the initializer runs, which only happens when a constructor is called. Java 21 0 B assumes static state is saved at serialization time.

Second worked example: path arithmetic

Statement. What do these lines print?

Path base = Path.of("/shop/reports");
Path file = Path.of("/shop/archive/../reports/2026/q3.csv");
System.out.println(base.relativize(file));
System.out.println(base.relativize(file.normalize()));
System.out.println(file.normalize().getNameCount());

Step 1. Without normalization, relativize compares names textually. base has names shop, reports; file has shop, archive, .., reports, 2026, q3.csv. They share only shop, so the result climbs one level from reports (..) and descends the remaining names of file: ../archive/../reports/2026/q3.csv.

Step 2. After normalize, file becomes /shop/reports/2026/q3.csv, and the relative path is 2026/q3.csv.

Step 3. The normalized path has four names: shop, reports, 2026, q3.csv.

Solution. The three lines print ../archive/../reports/2026/q3.csv, 2026/q3.csv and 4.

Why other answers are wrong. Assuming relativize normalizes automatically gives 2026/q3.csv for the first line, which is wrong because Path operations are syntactic. Counting the root as a name would give 5.

Common pitfalls and exam tips

  • Exam tip: distinguish syntactic Path methods (no I/O, no exceptions about existence) from Files methods (touch the disk and throw IOException subclasses).
  • Files.lines, list, walk and find return streams that hold open handles: use try-with-resources.
  • resolve with an absolute argument returns the argument. relativize needs two paths of the same kind.
  • Files.copy throws FileAlreadyExistsException unless REPLACE_EXISTING; Files.delete throws NoSuchFileException, deleteIfExists does not.
  • Copying a directory copies no content; deleting a non-empty directory fails with DirectoryNotEmptyException.
  • FileWriter truncates unless the append flag is true.
  • Forgetting close() or flush() on a buffered writer loses data.
  • transient and static fields are not serialized; constructors of serializable classes are skipped.
  • A serializable class with a non-serializable field type fails at run time, not at compile time.
  • System.console() can be null.

Mini case study: archiving old invoices

Scenario. The bookshop keeps invoices as .txt files under /shop/invoices/YYYY/MM/. Once a month, invoices older than the current year must be moved to /shop/archive/YYYY/ keeping the sub-folder layout, and a manifest file listing the moved files and their sizes must be written. Invoice objects are also cached on disk as .ser files.

Questions. (1) Which API walks the tree? (2) How do you compute each target path? (3) What happens on a second run, and how do you make the move safe? (4) What is wrong with caching a Customer object that holds a Connection field?

Solution.

  1. Use Files.walk(Path.of("/shop/invoices")) inside try-with-resources, filtered with Files::isRegularFile and the extension, because the stream must be closed.
  2. For each source, Path rel = root.relativize(source), then Path target = archiveRoot.resolve(rel). Create parents with Files.createDirectories(target.getParent()) before the move.
  3. A plain Files.move fails with FileAlreadyExistsException if the target is already there. Use StandardCopyOption.REPLACE_EXISTING if overwriting is acceptable, or skip the file. Because a moved file no longer exists in the source tree, a second run finds nothing new. The manifest can be written with Files.write(manifest, lines), using APPEND when several runs add to the same file.
  4. A Connection is not Serializable, so writing the object throws NotSerializableException. Mark the field transient and reopen the connection lazily after reading, or better, do not store infrastructure objects in cached state.

Key terms

  • Byte stream / character stream: classes that read or write raw bytes / text characters.
  • Decorator (wrapper): a stream that adds buffering or conversion around another stream.
  • Path: an immutable, file-system-aware representation of a location, which may not exist.
  • Files: the utility class with static methods that perform file operations.
  • Normalize: remove redundant . and .. elements lexically.
  • Serialization: converting an object graph to bytes with ObjectOutputStream.
  • transient: modifier that excludes an instance field from default serialization.
  • serialVersionUID: the version identifier checked when deserializing.

Summary checklist

  • I can choose between byte and character streams and wrap them with buffers.
  • I know read() returns -1 at the end and readLine() returns null.
  • I can read and write files with Files and know the default open options.
  • I can predict results of resolve, relativize, normalize, getNameCount, subpath.
  • I know the exceptions of copy, move, delete and createFile.
  • I know list, walk and find return streams that must be closed.
  • I can predict what is restored after deserialization (transient, static, constructors).
  • I know the role of serialVersionUID and NotSerializableException.

Official sources