Chapter TWELVE
File I/O


Exam Objectives

Read and write console and file data using I/O Streams.
Serialize and de-serialize Java objects.
Create, traverse, read, and write Path objects and their properties using java.nio.file API.

Chapter Content


Basic Concepts

Let’s start by defining some concepts.

As we know, data is organized into files, directories, and file systems in a computer.

A file is a group of related data stored on a disk or other storage device. Files can contain programs, documents, images or any other types of data.

A directory, also called a folder, is a collection of files and other directories that are stored under the same name. Directories allow you to organize files in a hierarchical structure. For example:

documents/
  work/
    report.pdf  
    presentation.ppt
  personal/
    resume.doc
    family.jpg

The directory at the very top of the structure is known as the root directory. In Unix systems it is represented by a forward slash (/), while on Windows it is identified by a drive letter followed by a colon, like C:.

To locate a specific file or directory, you need to specify its path, the route from the root directory to that particular item in the hierarchy. Path separators differ between operating systems. Unix uses a forward slash (/) while Windows uses a backslash (\).

A path can be absolute, specifying the complete route from the root:

/home/steve/documents/work/report.pdf
C:\Users\Steve\Documents\Work\report.pdf 

Or it can be relative, specifying the route from the current directory, also known as the working directory:

documents/work/report.pdf
..\personal\resume.doc

Two special symbols are commonly used in relative paths:

So for example, if the current directory is /home/steve/documents, then:

In Java, you can work with the file system in two main ways:

  1. Using the java.io.File class (legacy I/O API)
  2. Using the java.nio.file.Path interface (NIO.2 API)

To create a File instance, simply pass a file or directory path to its constructor:

File file = new File("/home/steve/documents/work/report.pdf");
File dir = new File("C:\\Users\\Steve\\Documents");

Note that this does not actually create the file or directory on the disk, it just creates an object that represents that path. You can then call various methods on the File object to get information about the file or directory or to manipulate it.

In the newer NIO.2 (New Input/Output) API, paths are represented by the Path interface rather than the File class. You can get a Path instance in several ways:

// 1. Using the Paths helper class
Path p1 = Paths.get("/home/steve/documents/work/report.pdf");

// 2. From a File object  
File file = new File("C:\\Users\\Steve\\Documents");
Path p2 = file.toPath();

// 3. By joining path strings
Path p3 = Paths.get("documents", "work", "report.pdf"); 
Path p4 = Paths.get("/home", "steve").resolve("documents");

// 4. From the default FileSystem
Path p5 = FileSystems.getDefault().getPath("documents/work/report.pdf");

You can easily convert between File and Path using the toFile() and toPath() methods:

File file = path.toFile();
Path path = file.toPath();  

The Path interface provides similar methods to File but offers more flexibility and additional features for working with paths.

For example, you can extract specific parts of a path:

Path path = Paths.get("/home/steve/documents/work/report.pdf");
        
Path parent = path.getParent(); // /home/steve/documents/work
Path root = path.getRoot(); // /  
Path name = path.getFileName(); // report.pdf

Or construct paths by joining elements:

Path documents = Paths.get("/home/steve/documents");
Path file = documents.resolve("work/report.pdf"); 

The resulting path doesn’t have to exist, it is just an abstract representation that can be used for further processing.

In the next sections, we’ll focus on the Path interface and the NIO.2 API.

Using NIO.2 Paths

Let’s explore in more detail some of the key methods and concepts related to Path.

This interface provides the following methods for retrieving basic path information:

Here’s an example:

Path path = Paths.get("/home/user/documents/file.txt");
System.out.println(path.toString()); // Output: /home/user/documents/file.txt
System.out.println(path.getNameCount()); // Output: 4
System.out.println(path.getName(0)); // Output: home
System.out.println(path.getName(2)); // Output: documents

Additionally, there are methods for accessing the path elements:

Here’s an example:

Path path = Paths.get("/home/user/documents/file.txt");
System.out.println(path.getFileName()); // Output: file.txt
System.out.println(path.getRoot()); // Output: /
System.out.println(path.getParent()); // Output: /home/user/documents

The relativize method constructs a relative path between the current path and a given path. For example:

Path base = Paths.get("/home/user");
Path path = Paths.get("/home/user/documents/file.txt");
Path relativePath = base.relativize(path);
System.out.println(relativePath); // Output: documents/file.txt

The normalize method returns a path that is a normalized version of the original path, eliminating any redundant elements such as . (current directory) and .. (parent directory):

Path path = Paths.get("/home/user/./documents/../file.txt");
Path normalizedPath = path.normalize();
System.out.println(normalizedPath); // Output: /home/user/file.txt

The toRealPath method returns the real path of an existing file in the file system, resolving any symbolic links:

Path path = Paths.get("/path/to/symlink");
Path realPath = path.toRealPath();
System.out.println(realPath); // Output: /actual/path/to/file

The resolve method resolves a path against the current path, allowing you to mix absolute and relative paths:

Path base = Paths.get("/home/user");
Path relativePath = Paths.get("documents/file.txt");
Path resolvedPath = base.resolve(relativePath);
System.out.println(resolvedPath); // Output: /home/user/documents/file.txt

However, if the path to be resolved is already an absolute path, it will be returned as-is:

Path base = Paths.get("/home/user");
Path absolutePath = Paths.get("/other/path/file.txt");
Path resolvedPath = base.resolve(absolutePath);
System.out.println(resolvedPath); // Output: /other/path/file.txt

The Files Class

The java.nio.file.Files class is part of the NIO.2 API. It provides a rich set of static utility methods for working with files and directories in a more concise and efficient manner compared to the legacy File class.

These are some of its key features:

Here are some of the key methods provided by the Files class:

Several methods of the Files class take optional arguments that control how the operation is performed. Here are some common ones:

In the next sections, we’ll review some of the methods and optional arguments of this class in more detail. But first, let’s talk about I/O streams.

I/O Streams

In Java, I/O (Input/Output) streams provide a way to read data from a source or write data to a destination.

Here’s an analogy to explain I/O streams. Imagine you have a water tank and you want to transfer the water to another container. You can connect a pipe between the tank and the container, and the water will flow from the tank to the container through the pipe. Similarly, I/O streams act as the pipe, allowing data to flow from a source (a file, network, or memory) to a destination (another file, network, or memory).

I/O streams can be classified into several categories.

First of all, Java provides two types of I/O streams: byte streams and character streams.

I/O streams can also be classified into input streams and output streams.

Finally, I/O streams can be categorized into low-level streams and high-level streams.

Stream Classes

The java.io library defines four abstract classes that serve as the parents of all I/O stream classes:

These abstract classes provide the fundamental methods for reading from or writing to a stream, such as read(), write(), close(), and more. Concrete stream classes extend these base classes to provide specific functionality.

Java provides a wide range of concrete I/O stream classes in the java.io package. Some commonly used classes include:

These concrete classes extend the appropriate base classes (InputStream, OutputStream, Reader, or Writer) and implement specific functionality for handling different types of data sources and destinations.

FileInputStream

FileInputStream reads bytes from a file. It inherits from InputStream.

It can be created either with a File object or a String path:

FileInputStream(File file)
FileInputStream(String path)

Here’s how you use it:

try (InputStream in = new FileInputStream("/file.txt")) {
    int b;
    // -1 indicates the end of the file
    while((b = in.read()) != -1) {
        // Do something with the byte read
    }
} catch(IOException e) {
    /** ... */
}

There’s also a read() method that reads bytes into an array of bytes:

byte[] data = new byte[1024];
int numberOfBytesRead;
while((numberOfBytesRead = in.read(data)) != -1) {
    // Do something with the array data
}

All the classes we’ll review should be closed. Fortunately, they implement java.lang.AutoCloseable so they can be used in a try-with-resources.

Also, almost all methods of these classes throw IOExceptions or one of its subclasses (such as FileNotFoundException, which is pretty descriptive).

FileOutputStream

FileOutputStream writes bytes to a file. It inherits from OutputStream.

It can be created either with a File object or a String path and an optional boolean that indicates whether you want to overwrite or append to the file if it exists (it’s overwritten by default):

FileOutputStream(File file)
FileOutputStream(File file, boolean append)
FileOutputStream(String path)
FileOutputStream(String path, boolean append)

Here’s how you use it:

try (OutputStream out = new FileOutputStream("/file.txt")) {
    int b;
    // Made up method to get some data
    while((b = getData()) != -1) {
        // Writes b to the file output stream
        out.write(b);
        out.flush();
    }
} catch(IOException e) {
    /** ... */
}

When you write to an OutputStream, the data may get cached internally in memory and written to disk at a later time. If you want to make sure that all data is written to disk without having to close the OutputStream, you can call the flush() method every once in a while.

FileOutputStream also contains overloaded versions of write() that allow you to write data contained in a byte array.

FileReader

FileReader reads characters from a text file. It inherits from Reader.

It can be created either with a File object or a String path:

FileReader(File file)
FileReader(String path)

Here’s how you use it:

try (Reader r = new FileReader("/file.txt")) {
    int c;
    // -1 indicates the end of the file
    while((c = r.read()) != -1) {
        char character = (char)c;
        // Do something with the character
    }
} catch(IOException e) {
    /** ... */
}

There’s also a read() method that reads characters into an array of chars:

char[] data = new char[1024];
int numberOfCharsRead = r.read(data);
while((numberOfCharsRead = r.read(data)) != -1) {
    // Do something with the array data
}

FileReader assumes that you want to decode the characters in the file using the default character encoding of the machine your program is running on.

FileWriter

FileWriter writes characters to a text file. It inherits from Writer.

It can be created either with a File object or a String path and an optional boolean that indicates whether you want to overwrite or append to the file if it exists (it’s overwritten by default):

FileWriter(File file)
FileWriter(File file, boolean append)
FileWriter(String path)
FileWriter(String path, boolean append)

Here’s how you use it:

try (Writer w = new FileWriter("/file.txt")) {
    w.write('-'); // writing a character
    // writing a string
    w.write("Writing to the file...");
} catch(IOException e) {
    /** ... */
}

Just like an OutputStream, the data may get cached internally in memory and written to disk at a later time. If you want to make sure that all data is written to disk without having to close the FileWriter, you can call the flush() method every once in a while.

FileWriter also contains overloaded versions of write() that allow you to write data contained in a char array, or in a String.

FileWriter assumes that you want to encode the characters in the file using the default character encoding of the machine your program is running on.

BufferedReader

BufferedReader reads text from a character stream. Rather than read one character at a time, BufferedReader reads a large block at a time into a buffer. It inherits from Reader.

This is a wrapper class that is created by passing a Reader to its constructor, and optionally, the size of the buffer:

BufferedReader(Reader in)
BufferedReader(Reader in, int size)

BufferedReader has one extra read method (in addition to the ones inherited by Reader), readLine(). Here’s how you use it:

try (BufferedReader br = new BufferedReader(new FileReader("/file.txt"))) {
    String line;
    // null indicates the end of the file
    while((line = br.readLine()) != null) {
        // Do something with the line
    }
} catch(IOException e) {
    /** ... */
}

When the BufferedReader is closed, it will also close the Reader instance it reads from.

BufferedWriter

BufferedWriter writes text to a character stream, buffering characters for efficiency. It inherits from Writer.

This is a wrapper class that is created by passing a Writer to its constructor, and optionally, the size of the buffer:

BufferedWriter(Writer out)
BufferedWriter(Writer out, int size)

BufferedWriter has one extra write method (in addition to the ones inherited by Writer), newLine(). Here’s how you use it:

try (BufferedWriter bw = new BufferedWriter(new FileWriter("/file.txt"))) {
    bw.write("Writing to the file...");
    bw.newLine();
} catch(IOException e) {
    /** ... */
}

Since data is written to a buffer first, you can call the flush() method to make sure that the text written until that moment is indeed written to the disk.

When the BufferedWriter is closed, it will also close the Writer instance it writes to.

ObjectInputStream and ObjectOutputStream

The process of converting an object to a data format that can be stored (in a file, for example) is called serialization and converting that stored data format into an object is called deserialization.

If you want to serialize an object, its class must implement the java.io.Serializable interface, which has no methods to implement, it only tags the objects of that class as serializable.

We’ll cover this process in more detail later, but right now, you have to know that ObjectOutputStream allows you to serialize objects to an OutputStream while ObjectInputStream allows you to deserialize objects from an InputStream. So both are considered wrapper classes.

Here’s the constructor of the ObjectOutputStream class:

ObjectOutputStream(OutputStream out)

This class has methods to write many primitive types, like:

void writeInt(int val)
void writeBoolean(boolean val)

But the most useful is writeObject(Object). Here’s an example:

class Box implements java.io.Serializable {
    /** ... */
}
...
try(ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("obj.dat"))) {
    Box box = new Box();
    oos.writeObject(box);
} catch(IOException e) {
    /** ... */
}

To deserialize the file obj.dat, we use ObjectInputStream class. Here’s its constructor:

ObjectInputStream(InputStream in)

This class has methods to read many data types, among them:

Object readObject() throws IOException, ClassNotFoundException

Notice that it returns an Object type. Thus, we have to cast the object explicitly. This can lead to a ClassCastException thrown at runtime. Note that this method also throws a ClassNotFoundException (a checked exception), in case the class of a serialized object cannot be found.

Here’s an example:

try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("obj.dat"))) {
    Box box = null;
    Object obj = ois.readObject();
    if(obj instanceof Box) {
        box = (Box)obj;
    }
} catch(IOException ioe) {
    /** ... */
} catch(ClassNotFoundException cnfe) {
    /** ... */
}

PrintStream

PrintStream is a subclass of OutputStream that adds functionality for printing various data types in a human-readable format. It is similar to PrintWriter, but it works with OutputStreams only. Here’s a look at its constructors:

PrintStream(OutputStream out)
PrintStream(OutputStream out, boolean autoFlush)
PrintStream(OutputStream out, boolean autoFlush, String encoding) throws UnsupportedEncodingException
PrintStream(File file) throws FileNotFoundException
PrintStream(File file, String encoding) throws FileNotFoundException, UnsupportedEncodingException
PrintStream(String fileName) throws FileNotFoundException
PrintStream(String fileName, String encoding) throws FileNotFoundException, UnsupportedEncodingException

By default, it uses the default charset of the machine you’re running the program, but you can specify a charset if needed.

PrintStream has the write() method like other OutputStream subclasses, but it overrides them to avoid throwing an IOException.

It also adds methods such as print(), println(), format(), and printf() for convenient output. Here’s how you use this class:

// Opens or creates the file without automatic line flushing
// and using the default character encoding
try (PrintStream ps = new PrintStream("file.txt")) {
    ps.write("Hi".getBytes()); // Writing a String as bytes
    ps.write(100); // Writing a character as bytes

    // write the string representation of the argument
    // it has versions for all primitives, char[], String, and Object
    ps.print(true);
    ps.print(10);

    // same as print() but it also writes a line break as defined by
    // System.getProperty("line.separator") after the value
    ps.println(); // Just writes a new line
    ps.println("A new line...");

    // format() and printf() are the same methods
    // They write a formatted string using a format string,
    // its arguments and an optional Locale
    ps.format("%s %d", "Formatted string ", 1);
    ps.printf("%s %d", "Formatted string ", 2);
    ps.format(Locale.GERMAN, "%.2f", 3.1416);
    ps.printf(Locale.GERMAN, "%.3f", 3.1416);
} catch (FileNotFoundException e) {
    // if the file cannot be opened or created
}

You can learn more about format strings for format() and printf() in the documentation of the java.util.Formatter class.

PrintWriter

PrintWriter is a subclass of Writer that writes formatted data to another (wrapped) stream, even an OutputStream. Just look at its constructors:

PrintWriter(File file) throws FileNotFoundException
PrintWriter(File file, String charset) throws FileNotFoundException, UnsupportedEncodingException
PrintWriter(OutputStream out)
PrintWriter(OutputStream out, boolean autoFlush)
PrintWriter(String fileName) throws FileNotFoundException
PrintWriter(String fileName, String charset) throws FileNotFoundException, UnsupportedEncodingException
PrintWriter(Writer out)
PrintWriter(Writer out, boolean autoFlush)

By default, it uses the default charset of the machine you’re running the program, but this class accepts the following charsets (there are other optional charsets):

As any Writer, this class has the write() method we’ve seen in other Writer subclasses, but it overwrites them to avoid throwing an IOException.

It also adds the methods format(), print(), printf(), println().

Here’s how you use this class:

// Opens or creates the file without automatic line flushing
// and converting characters by using the default character encoding
try(PrintWriter pw = new PrintWriter("/file.txt")) {
    pw.write("Hi"); // Writing a String
    pw.write(100); // Writing a character

    // write the string representation of the argument
    // it has versions for all primitives, char[], String, and Object
    pw.print(true);
    pw.print(10);

    // same as print() but it also writes a line break as defined by
    // System.getProperty("line.separator") after the value
    pw.println(); // Just writes a new line
    pw.println("A new line...");

    // format() and printf() are the same methods
    // They write a formatted string using a format string,
    // its arguments and an optional Locale
    pw.format("%s %d", "Formatted string ", 1);
    pw.printf("%s %d", "Formatted string ", 2);
    pw.format(Locale.GERMAN, "%.2f", 3.1416);
    pw.printf(Locale.GERMAN, "%.3f", 3.1416);
} catch(FileNotFoundException e) {
    // if the file cannot be opened or created
}

Just like with PrintWriter, you can learn more about format strings for format() and printf() in the documentation of the java.util.Formatter class.

Standard Streams

Java initializes and provides three stream objects as public static fields of the java.lang.System class:

Remember, PrintStream does exactly the same and has the same features that PrintWriter, it just works with OutputStreams only.

The following example shows how to read a single character (a byte) from the command line:

System.out.print("Enter a character: ");
try {
    int c = System.in.read();
} catch(IOException e) {
    System.err.println("Error: " + e);
}

Or to read strings:

BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String line = br.readLine();
// Or using the java.util.Scanner class
Scanner scanner = new Scanner(System.in);
String line = scanner.nextLine();

These streams (System.in, System.out, System.err) are used for basic input and output in many Java programs.

Copying, Moving, Deleting, and Comparing Files

You have previously learned that the java.nio.file.Files class provides various methods for file operations like copying, moving, deleting, and comparing files. Let’s explore these operations in detail.

The Files.copy() method allows you to copy a file from one location to another. It takes a source path and a target path as parameters.

If the target file already exists, you can specify how to handle the copy operation using the StandardCopyOption enum.

Path source = Paths.get("path/to/source/file.txt");
Path target = Paths.get("path/to/target/file.txt");

// Copy the file, replacing the target file if it exists
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);

The StandardCopyOption.REPLACE_EXISTING option indicates that if the target file already exists, it should be replaced with the source file.

You can also copy files using I/O streams. This is useful when you need more control over the copying process or when working with large files:

try (InputStream inputStream = new FileInputStream("source.txt");
     OutputStream outputStream = new FileOutputStream("target.txt")) {
    
    byte[] buffer = new byte[1024]; // Buffer size can be adjusted for performance
    int bytesRead;
    while ((bytesRead = inputStream.read(buffer)) != -1) {
        outputStream.write(buffer, 0, bytesRead);
    }
} catch (IOException e) {
    e.printStackTrace();
}

In this example, we create an InputStream to read from the source file and an OutputStream to write to the target file. We use a buffer to read and write the data in chunks.

To copy a file into a directory, you can specify the target directory path and the filename.

Path sourceFile = Paths.get("path/to/source/file.txt");
Path targetDirectory = Paths.get("path/to/target/directory");

// Copy the file into the target directory
Files.copy(sourceFile, targetDirectory.resolve(sourceFile.getFileName()));

The targetDirectory.resolve(sourceFile.getFileName()) expression creates the target path by combining the target directory path with the file name of the source file.

The Files.move() method allows you to move or rename a file or directory.

Path source = Paths.get("path/to/source/file.txt");
Path target = Paths.get("path/to/target/file.txt");

// Move the file
Files.move(source, target);

If the target file already exists, an exception will be thrown. You can use the StandardCopyOption.REPLACE_EXISTING option to replace the target file if it exists:

Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);

An atomic move ensures that the move operation is performed as a single indivisible operation. It either completes successfully or fails without any partial changes:

Path source = Paths.get("path/to/source/file.txt");
Path target = Paths.get("path/to/target/file.txt");

// Perform an atomic move
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);

The StandardCopyOption.ATOMIC_MOVE option guarantees that the move operation is atomic, preventing data corruption during the move process.

The Files.delete() method allows you to delete a file or an empty directory:

Path path = Paths.get("path/to/file.txt");

// Delete the file
Files.delete(path);

If the file does not exist, a NoSuchFileException will be thrown.

The Files.deleteIfExists() method deletes the file if it exists and returns a boolean indicating whether the file was deleted:

Path path = Paths.get("path/to/file.txt");

// Delete the file if it exists
boolean deleted = Files.deleteIfExists(path);

This method does not throw an exception if the file does not exist.

The Files.isSameFile() method allows you to determine if two paths locate the same file in the file system:

Path path1 = Paths.get("path/to/file1.txt");
Path path2 = Paths.get("path/to/file2.txt");

// Check if the paths refer to the same file
boolean isSame = Files.isSameFile(path1, path2);

It returns true if the paths refer to the same file, and false otherwise.

The Files.mismatch() method compares the content of two files and returns the position of the first mismatched byte:

Path file1 = Paths.get("path/to/file1.txt");
Path file2 = Paths.get("path/to/file2.txt");

// Compare the content of the files
long mismatchPosition = Files.mismatch(file1, file2);

If the files have identical content, it returns -1. If the files have different sizes, it returns the size of the smaller file.

These are some of the key methods provided by the Files class for copying, moving, deleting, and comparing files in Java. They offer convenient ways to perform common file operations without the need for manual I/O stream handling.

Reading and Writing Files

Java provides several methods in the java.nio.file.Files class for reading from and writing to files. Let’s explore some commonly used methods and techniques.

Reading Files

The Files class offers two convenient methods for reading the contents of a file: readAllLines() and lines().

The Files.readAllLines() method reads all the lines of a file into a List<String>:

Path path = Paths.get("path/to/file.txt");

try {
    List<String> lines = Files.readAllLines(path);
    for (String line : lines) {
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
}

This method is suitable when you need to process all the lines of a file at once. However, note that it reads the entire file into memory, so it may not be efficient for large files.

On the other hand, the Files.lines() method returns a Stream<String> that allows you to process the lines of a file lazily:

Path path = Paths.get("path/to/file.txt");

try (Stream<String> lines = Files.lines(path)) {
    lines.forEach(System.out::println);
} catch (IOException e) {
    e.printStackTrace();
}

This method is more memory-efficient as it reads the lines on-demand and does not load the entire file into memory at once. It is especially useful when you need to process large files or perform operations like filtering or mapping on the lines.

However, for more control over the reading process, you can use the Files.newBufferedReader() method to create a BufferedReader instance:

Path path = Paths.get("path/to/file.txt");

try (BufferedReader reader = Files.newBufferedReader(path)) {
    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
}

The BufferedReader provides methods such as readLine() to read the file line by line, allowing you to process the lines as needed.

Writing Files

The Files class provides methods for writing content to files, such as write() and newBufferedWriter().

The Files.write() method allows you to write content to a file in a single operation:

Path path = Paths.get("path/to/file.txt");
String content = "Hello, World!";

try {
    Files.write(path, content.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
    e.printStackTrace();
}

This method writes the specified byte array to the file. If the file already exists, it will be overwritten by default.

Additionally, you can specify additional options using the StandardOpenOption enum.

Path path = Paths.get("path/to/file.txt");
String content = "Appended content";

try {
    Files.write(path, content.getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND);
} catch (IOException e) {
    e.printStackTrace();
}

The StandardOpenOption.APPEND option specifies that the content should be appended to the end of the file instead of overwriting it.

Other useful options include:

These options give you more control over how the file is opened and written to.

However, you can also use the Files.newBufferedWriter() method to create a BufferedWriter instance:

Path path = Paths.get("path/to/file.txt");

try (BufferedWriter writer = Files.newBufferedWriter(path)) {
    writer.write("Hello, World!");
    writer.newLine();
    writer.write("This is a new line.");
} catch (IOException e) {
    e.printStackTrace();
}

The BufferedWriter provides methods such as write() and newLine() to write content to the file, allowing you to write line by line or in chunks.

Working with File Attributes

The java.nio.file package provides classes and methods for working with file attributes. File attributes are metadata associated with a file or directory, such as size, modification time, permissions, and more. You can retrieve and modify file attributes using the NIO.2 API.

Java defines several attribute and view types that represent different sets of file attributes:

BasicFileAttributes The BasicFileAttributes interface provides basic file attributes that are common across different file systems. It includes attributes like:

DosFileAttributes The DosFileAttributes interface extends BasicFileAttributes and provides additional attributes specific to DOS/Windows file systems. It includes attributes like:

PosixFileAttributes The PosixFileAttributes interface extends BasicFileAttributes and provides additional attributes specific to POSIX-compliant file systems. It includes attributes like:

To retrieve file attributes, you can use the Files.readAttributes() method, specifying the attribute type you want to retrieve:

Path path = Paths.get("path/to/file.txt");

try {
    BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
    System.out.println("Creation Time: " + attrs.creationTime());
    System.out.println("Last Modified Time: " + attrs.lastModifiedTime());
    System.out.println("Size: " + attrs.size());
} catch (IOException e) {
    e.printStackTrace();
}

In this example, we retrieve the BasicFileAttributes of the file and access its creation time, last modified time, and size.

Additionally, you can specify the LinkOption to control how symbolic links are handled:

Path path = Paths.get("path/to/symlink.txt");

try {
    BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
    boolean isSymLink = attrs.isSymbolicLink();
} catch (IOException e) {
    e.printStackTrace();
}

By passing LinkOption.NOFOLLOW_LINKS, the readAttributes() method will not follow symbolic links and will instead return the attributes of the symbolic link itself.

To quickly check if a file is accessible for reading, writing, or executing, you can use the Files.isReadable(), Files.isWritable(), and Files.isExecutable() methods:

Path path = Paths.get("path/to/file.txt");

boolean isReadable = Files.isReadable(path);
boolean isWritable = Files.isWritable(path);
boolean isExecutable = Files.isExecutable(path);

These methods return true if the file is accessible for the respective operation, and false otherwise.

To modify file attributes you can use the Files.setAttribute() method, specifying the attribute name and value:

Path path = Paths.get("path/to/file.txt");

try {
    Files.setAttribute(path, "dos:readonly", true);
    Files.setAttribute(path, "dos:hidden", true);
} catch (IOException e) {
    e.printStackTrace();
}

In this example, we set the readonly and hidden attributes of a file on a DOS/Windows file system.

Traversing a Directory Tree

Traversing a directory tree, also known as walking a directory tree, refers to the process of recursively visiting all the subdirectories and files within a given directory. The NIO.2 API, in particular, the Files class, provides methods to simplify this process and allows you to perform actions on each visited file and directory.

The Files.walk() method is a convenient way to traverse a directory tree. It returns a Stream<Path> that represents the file tree rooted at the given starting directory:

Path startingDir = Paths.get("/path/to/directory");

try (Stream<Path> stream = Files.walk(startingDir)) {
    stream.forEach(path -> {
        // Process each path
        System.out.println(path);
    });
} catch (IOException e) {
    e.printStackTrace();
}

The Files.walk() method visits all the files and directories in the tree, including the starting directory itself. You can perform various operations on each path using the stream API, such as filtering, mapping, or collecting the paths.

You can control the depth of the traversal by passing a maximum depth value to the Files.walk() method:

Path startingDir = Paths.get("/path/to/directory");
int maxDepth = 3;

try (Stream<Path> stream = Files.walk(startingDir, maxDepth)) {
    // ...
} catch (IOException e) {
    e.printStackTrace();
}

In this example, the traversal will go up to a maximum depth of 3 levels below the starting directory. A depth of 0 means only the starting directory itself is visited.

By default, Files.walk() follows symbolic links. If you want to control this behavior, you can pass a FileVisitOption to the method:

Path startingDir = Paths.get("/path/to/directory");

try (Stream<Path> stream = Files.walk(startingDir, FileVisitOption.FOLLOW_LINKS)) {
    // ...
} catch (IOException e) {
    e.printStackTrace();
}

The FileVisitOption.FOLLOW_LINKS option specifies that symbolic links should be followed during the traversal.

However, when traversing a directory tree in Java, it’s important to be aware of circular paths caused by symbolic links. A circular path occurs when a symbolic link points to a directory that is an ancestor of the link, creating an infinite loop.

To avoid circular paths, you can use the FileVisitOption.FOLLOW_LINKS option and implement your own cycle detection logic. Here’s a complete example:

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.Set;

public class DirectoryTraversal {
    public static void main(String[] args) {
        Path startingDir = Paths.get("/path/to/directory");
        Set<Path> visitedPaths = new HashSet<>();

        try {
            Files.walkFileTree(
                               startingDir, 
                               EnumSet.of(FileVisitOption.FOLLOW_LINKS), 
                               Integer.MAX_VALUE, new SimpleFileVisitor<Path>() {
                @Override
                public FileVisitResult visitFile(
                        Path file, BasicFileAttributes attrs) throws IOException {
                    if (visitedPaths.contains(file)) {
                        // Circular path detected, skip processing
                        return FileVisitResult.CONTINUE;
                    }
                    visitedPaths.add(file);
                    // Process the file
                    System.out.println(file);
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult preVisitDirectory(
                        Path dir, BasicFileAttributes attrs) throws IOException {
                    if (visitedPaths.contains(dir)) {
                        // Circular path detected, skip processing
                        return FileVisitResult.SKIP_SUBTREE;
                    }
                    visitedPaths.add(dir);
                    // Process the directory
                    System.out.println(dir);
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult visitFileFailed(
                        Path file, IOException exc) throws IOException {
                    System.err.println(
                        "Error visiting file: " + file + " - " + exc.getMessage()
                    );
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

This program demonstrates how to traverse a directory tree while avoiding circular paths caused by symbolic links. It begins by defining the starting directory specified by the startingDir variable and uses the Files.walkFileTree method to traverse the directory tree. This method is recommended because it visits all files and directories and can follow symbolic links when specified by the FileVisitOption.FOLLOW_LINKS option. To prevent infinite loops caused by circular paths, the program maintains a set of visited paths (visitedPaths). This set is used to keep track of all the directories and files that have already been visited during the traversal.

The core of the program is the implementation of a SimpleFileVisitor, which overrides several methods to define custom behaviors for visiting files and directories. When the program finds a file or directory, it checks the visitedPaths set to see if the path has already been visited. If the path is found in the set, this indicates a circular path, and the program skips further processing for that path. If the path is not in the set, it is added to the visitedPaths set, and the path is processed (in this case, printed to the console). This ensures that each path is processed only once, effectively preventing infinite loops.

Serializing Data

We talked about serialization before. It is the process of converting an object into a byte stream, which can be saved to a file or transmitted over a network. Deserialization is the reverse process, where the byte stream is converted back into an object. Let’s explore the key concepts and techniques related to serialization in Java.

Serialization allows you to persist the state of an object and recreate it later. This is useful for:

The Java Object Serialization API provides a standard mechanism for developers to handle this process.

To make a class serializable, it must implement the java.io.Serializable interface. This is a marker interface (it has no methods) that tells the Java runtime that the class can be serialized:

public class Employee implements Serializable {
    private String name;
    private int age;

    // Constructor, getters, and setters
}

If you try to serialize a class that doesn’t implement that interface, a java.io.NotSerializableException (a subclass of IOException) will be thrown at runtime.

The serialVersionUID is a unique identifier for the serialized class. It’s used during deserialization to verify that the sender and receiver of a serialized object have loaded classes for that object that are compatible with respect to serialization:

public class Employee implements Serializable {
    private static final long serialVersionUID = 1L;
    // ... rest of the class
}

If you don’t explicitly declare a serialVersionUID, the Java runtime will generate one based on various aspects of your class. However, it’s recommended to declare one explicitly to maintain control over class versioning.

If you have fields in your class that you don’t want to be serialized (for example, sensitive data or derived data), you can mark them with the transient keyword:

public class Employee implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    private transient String password; // This won't be serialized
    // ... rest of the class
}

In summary, to ensure a class is serializable:

  1. The class must be marked Serializable.
  2. Every instance member of the class must be serializable, marked transient, or have a null value at the time of serialization.

After that, you can use ObjectOutputStream and ObjectInputStream for the serialization/deserialization process as shown in a previous section. Here’s a complete example demonstrating this process:

import java.io.*;

public class SerializationDemo {
    public static void main(String[] args) {
        Person person = new Person("John Doe", 30);

        // Serialization
        try (ObjectOutputStream out = 
                 new ObjectOutputStream(new FileOutputStream("person.ser"))) {
            out.writeObject(person);
            System.out.println("Person object serialized");
        } catch (IOException e) {
            e.printStackTrace();
        }

        // Deserialization
        try (ObjectInputStream in = 
                new ObjectInputStream(new FileInputStream("person.ser"))) {
            Person deserializedPerson = (Person) in.readObject();
            System.out.println("Person object deserialized");
            System.out.println("Name: " + deserializedPerson.getName());
            System.out.println("Age: " + deserializedPerson.getAge());
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

class Person implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Getters
    public String getName() { return name; }
    public int getAge() { return age; }
                                      
    // ... rest of the class
}

In this example, we serialize a Person object to a file named person.ser and then deserialize it back into a Person object.

It’s important to note that when deserializing an object, the constructor and any initialization block are not executed. However, the default initialization of instance variables still occurs.

Also, unlike classes, records are automatically serializable. They implicitly implement the Serializable interface, so you don’t need to explicitly declare it:

public record PersonRecord(String name, int age) /** implements Serializable */ {
    // No need to declare serialVersionUID, as it's automatically generated
}

Remember, records provide a compact way to declare classes that are primarily used to store data, and their built-in serializability makes them convenient for use in scenarios where object serialization is required.

Reference Tables

Here are some tables to help you review and understand the I/O stream classes and related concepts:

I/O Stream Classes Summary

Stream Type Byte Streams Character Streams
Input Abstract Classes: InputStream
Concrete Classes: FileInputStream, BufferedInputStream, ObjectInputStream
Abstract Classes: Reader
Concrete Classes: FileReader, BufferedReader
Output Abstract Classes: OutputStream
Concrete Classes: FileOutputStream, BufferedOutputStream, ObjectOutputStream, PrintStream
Abstract Classes: Writer
Concrete Classes: FileWriter, BufferedWriter, PrintWriter

File and Path Operations Comparison

Operation File Class NIO.2 (Path and Files)
Create File file.createNewFile() Files.createFile(path)
Delete File file.delete() Files.delete(path)
Check Existence file.exists() Files.exists(path)
Get Absolute Path file.getAbsolutePath() path.toAbsolutePath()
Check if Directory file.isDirectory() Files.isDirectory(path)
Check if File file.isFile() Files.isRegularFile(path)
List Directory Contents file.list(), file.listFiles() Files.list(path)
Create Directory file.mkdir() Files.createDirectory(path)
Create Directories file.mkdirs() Files.createDirectories(path)
Rename File file.renameTo(dest) Files.move(source, target)

Files Class Methods Summary

Method Description
copy() Copies a file to a target file
createDirectories() Creates a directory and any necessary parent directories
delete() Deletes a file or empty directory
exists() Checks file existence
isDirectory() Checks if the path is a directory
isRegularFile() Checks if the path is a regular file
move() Moves or renames a file
size() Returns the size of a file
readAllBytes() Reads all bytes from a file
readAllLines() Reads all lines from a file
walk() Returns a Stream of file tree structure
write() Writes bytes or lines to a file

Common File Attributes

Attribute BasicFileAttributes DosFileAttributes PosixFileAttributes
Creation Time
Last Modified Time
Last Access Time
Size
Is Directory
Is Regular File
Is Symbolic Link
Is Hidden    
Is Read-only    
Owner    
Group    
Permissions    

StandardOpenOption Values

Option Description
APPEND Append to the end of the file if it exists
CREATE Create a new file if it doesn’t exist
CREATE_NEW Create a new file, failing if it already exists
DELETE_ON_CLOSE Delete the file when the stream is closed
DSYNC Synchronize only the file’s content with the underlying storage device
READ Open for read access
SPARSE Hint that a newly created file will be sparse
SYNC Synchronize every update to the file’s content and metadata with the underlying storage device
TRUNCATE_EXISTING Truncate the file to zero bytes if it exists
WRITE Open for write access

StandardCopyOption Values

Option Description
REPLACE_EXISTING Replace the target file if it exists
COPY_ATTRIBUTES Copy file attributes to the target file
ATOMIC_MOVE Move the file as an atomic file system operation

Key Points

Practice Questions

1. What is the result of the following code snippet?

import java.nio.file.Path;
import java.nio.file.Paths;

public class PathExample {
    public static void main(String[] args) {
        Path basePath = Paths.get("/home/user");
        Path relativePath = Paths.get("documents/notes.txt");
        Path resultPath = basePath.resolve(relativePath);
        System.out.println(resultPath);
    }
}

A) /home/user
B) /home/user/documents
C) /documents/notes.txt
D) /home/user/documents/notes.txt

2. What is the result of the following code snippet?

import java.nio.file.Path;
import java.nio.file.Paths;

public class PathExample {
    public static void main(String[] args) {
        Path path = Paths.get("/home/user/../documents/./notes.txt");
        Path normalizedPath = path.normalize();
        System.out.println(normalizedPath);
    }
}

A) /home/user/../documents/./notes.txt
B) /home/user/documents/notes.txt
C) /home/documents/notes.txt
D) /documents/notes.txt

3. Which of the following classes is used for reading character streams in Java?

A) FileOutputStream
B) FileReader
C) BufferedOutputStream
D) ObjectInputStream

4. Which of the following code snippets correctly copies a file using the Files class, ensuring that an existing target file is overwritten?

A)

Path source = Paths.get("source.txt");
Path target = Paths.get("target.txt");
Files.copy(source, target, StandardCopyOption.ATOMIC_MOVE);

B)

Path source = Paths.get("source.txt");
Path target = Paths.get("target.txt");
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);

C)

Path source = Paths.get("source.txt");
Path target = Paths.get("target.txt");
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);

D)

Path source = Paths.get("source.txt");
Path target = Paths.get("target.txt");
Files.copy(source, target, StandardCopyOption.APPEND);

5. Which of the following code snippets correctly reads all lines from a file into a List<String> using the java.nio.file API?

A)

Path path = Paths.get("file.txt");
List<String> lines = Files.readAllBytes(path);

B)

Path path = Paths.get("file.txt");
List<String> lines = Files.readString(path);

C)

Path path = Paths.get("file.txt");
List<String> lines = Files.lines(path);

D)

Path path = Paths.get("file.txt");
List<String> lines = Files.readAllLines(path);

6. Which of the following code snippets correctly writes a List<String> to a file using the Files class in Java?

A)

Path path = Paths.get("output.txt");
List<String> lines = Arrays.asList("line1", "line2", "line3");
Files.write(path, lines);

B)

Path path = Paths.get("output.txt");
List<String> lines = Arrays.asList("line1", "line2", "line3");
Files.writeString(path, lines);

C)

Path path = Paths.get("output.txt");
List<String> lines = Arrays.asList("line1", "line2", "line3");
Files.writeLines(path, lines);

D)

Path path = Paths.get("output.txt");
List<String> lines = Arrays.asList("line1", "line2", "line3");
Files.write(path, lines, StandardOpenOption.READ);

7. Which of the following methods from the BasicFileAttributes class retrieves the creation time of a file?

A)

BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
attrs.lastModifiedTime();

B)

BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
attrs.creationTime();

C)

BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
attrs.lastAccessTime();

D)

BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
attrs.size();

8. Which of the following code snippets correctly traverses a directory tree using the Files.walkFileTree method in Java?

A)

Path start = Paths.get("start_directory");
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
        return FileVisitResult.SKIP_SUBTREE;
    }
});

B)

Path start = Paths.get("start_directory");
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
        throw new IOException("Error visiting file");
    }
});

C)

Path start = Paths.get("start_directory");
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
        System.out.println("Visited file: " + file);
        return FileVisitResult.TERMINATE;
    }
});

D)

Path start = Paths.get("start_directory");
Files.walkFileTree(start, EnumSet.noneOf(FileVisitOption.class), Integer.MAX_VALUE, new SimpleFileVisitor<Path>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
        System.out.println("Visited file: " + file);
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
        return FileVisitResult.CONTINUE;
    }
});

9. Which of the following code snippets correctly serializes an object to a file?

A)

class Animal implements Serializable {
    private static final long serialVersionUID = 1L;
    private String species;
    private int age;

    public Animal(String species, int age) {
        this.species = species;
        this.age = age;
    }
}

Animal animal = new Animal("Lion", 5);
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("animal.ser"))) {
    ois.writeObject(animal);
} catch (IOException e) {
    e.printStackTrace();
}

B)

class Animal implements Serializable {
    private static final long serialVersionUID = 1L;
    private String species;
    private int age;

    public Animal(String species, int age) {
        this.species = species;
        this.age = age;
    }
}

Animal animal = new Animal("Lion", 5);
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("animal.ser"))) {
    oos.writeObject(animal);
} catch (IOException e) {
    e.printStackTrace();
}

C)

class Animal {
    private String species;
    private int age;

    public Animal(String species, int age) {
        this.species = species;
        this.age = age;
    }
}

Animal animal = new Animal("Lion", 5);
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("animal.ser"))) {
    oos.writeObject(animal);
} catch (IOException e) {
    e.printStackTrace();
}

D)

class Animal implements Serializable {
    private static final long serialVersionUID = 1L;
    private String species;
    private int age;

    public Animal(String species, int age) {
        this.species = species;
        this.age = age;
    }
}

Animal animal = new Animal("Lion", 5);
try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("animal.ser"))) {
    bos.write(animal);
} catch (IOException e) {
    e.printStackTrace();
}

Do you like what you read? Would you consider?


Do you have a problem or something to say?

Report an issue with the book

Contact me