Index

Practical Java IO and NIO Examples

Java IO and NIO

10.1 File Copying and Moving

File manipulation—copying and moving files—is a common requirement in many Java applications, whether for backup, organization, or processing workflows. Java provides multiple APIs to accomplish these tasks: the traditional I/O streams (FileInputStream/FileOutputStream) and the modern NIO (java.nio.file.Files) utilities introduced since Java 7.

This tutorial covers both approaches with practical examples, explaining their differences, advantages, and use cases.

Traditional IO Approach: Copying Files Using Streams

Before Java 7, the common way to copy a file was to open input and output streams and manually transfer bytes. This approach gives you low-level control but requires careful resource management and more code.

Copying a File Using FileInputStream and FileOutputStream

import java.io.*;

public class FileCopyTraditional {
    public static void copyFile(File source, File destination) throws IOException {
        try (FileInputStream fis = new FileInputStream(source);
             FileOutputStream fos = new FileOutputStream(destination)) {

            byte[] buffer = new byte[8192]; // 8KB buffer
            int bytesRead;

            while ((bytesRead = fis.read(buffer)) != -1) {
                fos.write(buffer, 0, bytesRead);
            }
        }
    }

    public static void main(String[] args) {
        File src = new File("source.txt");
        File dest = new File("destination.txt");

        try {
            copyFile(src, dest);
            System.out.println("File copied successfully using traditional IO.");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Explanation

Moving Files Using Traditional IO

Moving a file by streams requires copying the file contents and then deleting the original file:

import java.io.*;

public class FileMoveTraditional {
    public static void moveFile(File source, File destination) throws IOException {
        copyFile(source, destination);  // Copy the file
        if (!source.delete()) {         // Delete the original file
            throw new IOException("Failed to delete original file: " + source.getAbsolutePath());
        }
    }

    // Reuse copyFile from previous example
    public static void copyFile(File source, File destination) throws IOException {
        try (FileInputStream fis = new FileInputStream(source);
             FileOutputStream fos = new FileOutputStream(destination)) {

            byte[] buffer = new byte[8192];
            int bytesRead;

            while ((bytesRead = fis.read(buffer)) != -1) {
                fos.write(buffer, 0, bytesRead);
            }
        }
    }

    public static void main(String[] args) {
        File src = new File("source.txt");
        File dest = new File("moved.txt");

        try {
            moveFile(src, dest);
            System.out.println("File moved successfully using traditional IO.");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Explanation

Modern NIO Approach: Using java.nio.file.Files

Java 7 introduced the NIO.2 package (java.nio.file), which simplifies file operations with the Files utility class. It provides methods such as Files.copy() and Files.move(), which are easier to use, safer, and more powerful.

Copying Files Using Files.copy()

import java.io.*;
import java.nio.file.*;

public class FileCopyNIO {
    public static void main(String[] args) {
        Path source = Paths.get("source.txt");
        Path destination = Paths.get("destination.txt");

        try {
            Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING);
            System.out.println("File copied successfully using NIO.");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Explanation

Moving Files Using Files.move()

import java.io.*;
import java.nio.file.*;

public class FileMoveNIO {
    public static void main(String[] args) {
        Path source = Paths.get("source.txt");
        Path destination = Paths.get("moved.txt");

        try {
            Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING);
            System.out.println("File moved successfully using NIO.");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Explanation

When to Use Each Approach?

Feature Traditional IO (FileInputStream / FileOutputStream) NIO (Files.copy(), Files.move())
Simplicity Verbose and manual Simple and concise
Performance Buffer size configurable but requires manual code Optimized, atomic operations where supported
Resource management Must manage streams explicitly Automatic and safer
Atomic moves (rename) Not supported Supported if file system allows
Metadata copying Not handled Supported (COPY_ATTRIBUTES option)
Portability and modern API Legacy, less portable Modern, recommended since Java 7
Error handling More complex to handle all corner cases Robust and consistent

Summary and Best Practices

Conclusion

Copying and moving files in Java can be done either with low-level stream-based APIs or the modern NIO utilities. While traditional IO is still valid, NIO’s Files API is the recommended approach for most use cases, offering simplicity, reliability, and enhanced functionality.

By mastering both, you’ll be equipped to handle file operations in any Java environment, ensuring your applications manipulate files efficiently and correctly.

Index

10.2 Implementing a File Watcher

Monitoring file system changes—such as file creation, modification, or deletion—is a common need in many applications, including logging systems, IDEs, synchronization tools, and auto-reloaders. Java’s NIO.2 API, introduced in Java 7, provides a powerful and efficient way to watch file system events through the WatchService API.

This guide walks you through implementing a file watcher step-by-step, explaining key concepts, and providing a complete, well-commented Java example.

Understanding the WatchService API

The WatchService API allows you to monitor one or more directories for changes such as:

The API works by:

  1. Registering a directory (Path) with a WatchService.
  2. Listening asynchronously for events on the registered directory.
  3. Retrieving and processing the events as they occur.

Step 1: Obtain a WatchService

You create a WatchService from the file system's default provider:

WatchService watchService = FileSystems.getDefault().newWatchService();

This service will be used to register directories and poll for events.

Step 2: Register a Directory with the WatchService

You register a Path representing a directory with the WatchService, specifying which event kinds to watch:

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

dir.register(watchService, 
             StandardWatchEventKinds.ENTRY_CREATE,
             StandardWatchEventKinds.ENTRY_DELETE,
             StandardWatchEventKinds.ENTRY_MODIFY);

You can register multiple directories if needed.

Step 3: Poll and Process Events

Events are retrieved as WatchKey instances from the WatchService. You can use blocking or non-blocking polling:

Each WatchKey contains one or more WatchEvents, each describing an event kind and the affected file relative to the registered directory.

Complete Java Example: Directory File Watcher

import java.io.IOException;
import java.nio.file.*;
import static java.nio.file.StandardWatchEventKinds.*;
import java.util.List;

public class DirectoryWatcher {

    public static void main(String[] args) {
        // Directory to monitor
        Path dir = Paths.get("watched-dir");

        // Create the watcher service
        try (WatchService watchService = FileSystems.getDefault().newWatchService()) {

            // Register the directory for CREATE, DELETE, and MODIFY events
            dir.register(watchService, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);

            System.out.println("Watching directory: " + dir.toAbsolutePath());

            // Infinite loop to wait and process events
            while (true) {
                WatchKey key;
                try {
                    // Wait for a key to be signaled (blocking call)
                    key = watchService.take();
                } catch (InterruptedException e) {
                    System.out.println("Interrupted. Exiting.");
                    return;
                }

                // Retrieve all pending events for the key
                List<WatchEvent<?>> events = key.pollEvents();

                for (WatchEvent<?> event : events) {
                    // Get event kind
                    WatchEvent.Kind<?> kind = event.kind();

                    // The context for directory entry event is the relative path to the file
                    WatchEvent<Path> ev = (WatchEvent<Path>) event;
                    Path filename = ev.context();

                    System.out.printf("Event kind: %s. File affected: %s%n", kind.name(), filename);

                    // You can add custom logic here, e.g. react to specific files
                }

                // Reset the key — this step is critical to receive further watch events
                boolean valid = key.reset();
                if (!valid) {
                    System.out.println("WatchKey no longer valid, directory might be inaccessible.");
                    break;
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Explanation of the Code

Important Notes and Best Practices

Extending the File Watcher

You can extend the watcher to:

Summary

By following this guide and using the example code, you can implement your own directory monitoring solution in Java that responds in near real-time to file system changes.

Index

10.3 Building a Simple HTTP Server with NIO

Traditional Java networking with ServerSocket and Socket classes uses blocking IO, where a thread waits for each client. While simple, this doesn’t scale well with many concurrent clients because each connection consumes a thread.

Java NIO (New IO) introduced non-blocking IO and the selector pattern to handle many connections efficiently with a small number of threads. This tutorial walks you through creating a minimal HTTP server using NIO’s ServerSocketChannel, SocketChannel, and Selector.

What Youll Learn

Non-blocking IO and Selector Overview

Non-blocking IO

In non-blocking mode, read/write calls on channels do not block if the data is not immediately available. Instead, they return immediately with how much data was read or written. This means a single thread can:

Selector

A Selector allows a single thread to monitor multiple channels for IO events:

Step 1: Setting up the ServerSocketChannel

ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false);  // Non-blocking mode

Step 2: Creating the Selector and Registering the Server Channel

Selector selector = Selector.open();
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

Step 3: Event Loop with Selector

The server runs an event loop, calling selector.select() to wait for events, then handling each ready channel accordingly.

Step 4: Accepting Connections and Registering Client Channels

When the server socket channel is ready to accept, you call accept(), configure the new socket channel as non-blocking, and register it with the selector for read events.

Step 5: Reading Data from Client Channels

When a socket channel is ready for reading, you read bytes into a buffer. For simplicity, we assume the request fits into one read. (In production, you'd accumulate and parse incrementally.)

Step 6: Parsing HTTP Requests and Writing Responses

We parse the request line (e.g., GET / HTTP/1.1), ignore headers for simplicity, and respond with a simple HTTP 200 OK message with a plain text body.

Full Working Java Code

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.Set;

public class SimpleNIOServer {

    private static final int PORT = 8080;
    private static final String RESPONSE_BODY = "Hello from NIO HTTP Server!";
    private static final String RESPONSE_TEMPLATE = 
        "HTTP/1.1 200 OK\r\n" +
        "Content-Length: %d\r\n" +
        "Content-Type: text/plain\r\n" +
        "Connection: close\r\n" +
        "\r\n%s";

    public static void main(String[] args) throws IOException {
        // Open server socket channel
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.bind(new InetSocketAddress(PORT));
        serverChannel.configureBlocking(false);

        // Open selector
        Selector selector = Selector.open();

        // Register server channel for accept events
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);

        System.out.println("Server listening on port " + PORT);

        while (true) {
            // Wait for events
            selector.select();

            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> iter = selectedKeys.iterator();

            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                iter.remove();

                if (key.isAcceptable()) {
                    handleAccept(key, selector);
                }

                if (key.isReadable()) {
                    handleRead(key);
                }

                if (key.isWritable()) {
                    handleWrite(key);
                }
            }
        }
    }

    private static void handleAccept(SelectionKey key, Selector selector) throws IOException {
        ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
        SocketChannel clientChannel = serverChannel.accept();  // Accept connection
        clientChannel.configureBlocking(false);

        System.out.println("Accepted connection from " + clientChannel.getRemoteAddress());

        // Register client channel for reading
        clientChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
    }

    private static void handleRead(SelectionKey key) throws IOException {
        SocketChannel clientChannel = (SocketChannel) key.channel();
        ByteBuffer buffer = (ByteBuffer) key.attachment();

        int bytesRead = clientChannel.read(buffer);
        if (bytesRead == -1) {
            // Client closed connection
            System.out.println("Client closed connection: " + clientChannel.getRemoteAddress());
            clientChannel.close();
            key.cancel();
            return;
        }

        // Check if we have received a full HTTP request (simplistic check: look for double CRLF)
        String request = new String(buffer.array(), 0, buffer.position(), StandardCharsets.US_ASCII);
        if (request.contains("\r\n\r\n")) {
            System.out.println("Received request:\n" + request.split("\r\n")[0]);  // Print request line

            // Prepare response
            String response = String.format(RESPONSE_TEMPLATE, RESPONSE_BODY.length(), RESPONSE_BODY);

            // Attach response bytes to the key for writing
            key.attach(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII)));

            // Change interest to write
            key.interestOps(SelectionKey.OP_WRITE);
        }
    }

    private static void handleWrite(SelectionKey key) throws IOException {
        SocketChannel clientChannel = (SocketChannel) key.channel();
        ByteBuffer buffer = (ByteBuffer) key.attachment();

        clientChannel.write(buffer);

        if (!buffer.hasRemaining()) {
            // Response fully sent; close connection
            System.out.println("Response sent. Closing connection: " + clientChannel.getRemoteAddress());
            clientChannel.close();
            key.cancel();
        }
    }
}

Explanation of the Code

Key Points About Non-blocking Design Pattern

How to Run and Test

Limitations and Next Steps

Summary

This tutorial demonstrated building a simple HTTP server using Java NIO’s non-blocking IO:

With this foundation, you can explore building more advanced servers with richer HTTP support and better scalability.

Index

10.4 Logging and Debugging IO Operations

Input/output (IO) operations are fundamental to many Java applications—reading files, writing logs, communicating over networks, or processing streams. However, IO operations are often a common source of bugs and issues, such as silent failures, resource leaks, or encoding mismatches. Effective logging and debugging practices are essential to identify, diagnose, and fix these problems efficiently.

This section explores common IO issues, how to trace them, and how to implement logging strategies using Java’s built-in logging (java.util.logging) as well as the popular SLF4J facade, illustrated with IO-specific examples.

Silent Failures

A very frequent problem is when IO operations fail silently. For example, failing to close a stream due to swallowed exceptions or ignoring error return codes can cause resource leaks or data corruption.

Encoding Mismatches

When reading or writing text files or network data, incorrect or inconsistent character encodings cause garbled text or data loss. For example, reading UTF-8 encoded data using ISO-8859-1 results in corrupted characters.

Buffering and Partial Reads/Writes

Reading or writing in incorrect buffer sizes or misunderstanding the non-blocking nature of some channels can cause incomplete data processing or infinite loops.

Concurrency and Resource Contention

Accessing the same file or stream from multiple threads without synchronization can lead to unpredictable behavior and hard-to-reproduce bugs.

File Not Found or Permission Issues

Common IOExceptions due to missing files or permission denied can halt program execution if not handled or logged properly.

Why Logging is Important in IO

Using Javas Built-in Logging (java.util.logging)

Java provides a built-in logging API in the java.util.logging package. Here’s an example demonstrating logging around an IO operation with proper error handling:

import java.io.*;
import java.nio.charset.Charset;
import java.util.logging.*;

public class LoggingIOExample {
    private static final Logger logger = Logger.getLogger(LoggingIOExample.class.getName());

    public static void readFile(String path, Charset charset) {
        logger.info("Starting to read file: " + path + " with charset: " + charset);

        try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(path), charset))) {
            String line;
            while ((line = reader.readLine()) != null) {
                logger.fine("Read line: " + line);
            }
        } catch (FileNotFoundException e) {
            logger.log(Level.SEVERE, "File not found: " + path, e);
        } catch (IOException e) {
            logger.log(Level.SEVERE, "Error reading file: " + path, e);
        }

        logger.info("Finished reading file: " + path);
    }

    public static void main(String[] args) {
        // Set log level to show info and above; fine logs won't appear by default
        Logger rootLogger = Logger.getLogger("");
        rootLogger.setLevel(Level.INFO);

        readFile("example.txt", Charset.forName("UTF-8"));
    }
}

Explanation:

Configuring Logging Levels

By default, java.util.logging shows logs of level INFO and above. To see DEBUG-level logs (FINE), configure the logging level programmatically or via a properties file.

Using SLF4J for IO Logging

SLF4J (Simple Logging Facade for Java) is widely used in enterprise applications for flexible logging abstraction. SLF4J works with popular backends like Logback or Log4j.

Example of Logging IO Operations with SLF4J

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.*;
import java.nio.charset.StandardCharsets;

public class Slf4jLoggingIOExample {

    private static final Logger logger = LoggerFactory.getLogger(Slf4jLoggingIOExample.class);

    public static void writeFile(String path, String content) {
        logger.info("Writing to file: {}", path);

        try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(path), StandardCharsets.UTF_8))) {
            writer.write(content);
            logger.debug("Written content length: {}", content.length());
        } catch (IOException e) {
            logger.error("Error writing to file: {}", path, e);
        }

        logger.info("Finished writing to file: {}", path);
    }

    public static void main(String[] args) {
        writeFile("output.txt", "Hello SLF4J IO logging!");
    }
}

Key Points:

Debugging Tips for IO Issues

Log Byte Buffers and Content Dumps

When reading raw bytes (e.g., from network sockets or files), logging hex dumps or base64 representations of data helps spot corruption.

private static void logBufferContent(ByteBuffer buffer, Logger logger) {
    byte[] bytes = new byte[buffer.remaining()];
    buffer.get(bytes);
    String hexDump = javax.xml.bind.DatatypeConverter.printHexBinary(bytes);
    logger.debug("Buffer content (hex): {}", hexDump);
    buffer.position(buffer.position() - bytes.length); // reset position after reading
}

Log Charset Details

Always log which charset you are using to read or write text, as mismatches often cause hard-to-detect bugs.

Use Stack Traces and Cause Chains

IOExceptions often wrap root causes. Use logger.log() to print full stack traces and investigate nested exceptions.

Watch for Resource Leaks

Log when streams or channels are opened and closed to detect if resources are not properly freed.

Enable More Verbose Logging Temporarily

In troubleshooting, increase log verbosity to FINE or DEBUG for IO classes or packages.

Summary

By following these guidelines and using logging strategically, you can confidently trace and fix issues in Java IO code—turning black-box problems into understandable and manageable events.

Index

10.5 Best Practices for Efficient IO

Input/output (IO) is a critical aspect of many Java applications, ranging from file processing to network communication. However, IO operations can become bottlenecks if not handled efficiently. Writing efficient IO code helps reduce latency, improve throughput, and conserve system resources.

This guide covers essential best practices to write performant and robust IO code in Java, with practical examples and tips.

Use Buffering to Improve Performance

Reading or writing data byte-by-byte or character-by-character is extremely inefficient because each call may result in an expensive system call.

Why Buffer?

How to Buffer in Java?

Java provides buffered wrappers:

Example: Buffered File Copy

import java.io.*;

public class BufferedFileCopy {
    public static void copyFile(File source, File dest) throws IOException {
        try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(source));
             BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(dest))) {
            byte[] buffer = new byte[8192];  // 8KB buffer
            int bytesRead;
            while ((bytesRead = bis.read(buffer)) != -1) {
                bos.write(buffer, 0, bytesRead);
            }
        }
    }
}

Performance Tip:

Use at least an 8KB buffer size (8192 bytes) for disk IO; smaller buffers may increase overhead, while excessively large buffers waste memory and may cause GC pressure.

Always Use Try-With-Resources to Avoid Resource Leaks

Open IO resources (streams, readers, sockets) must be closed promptly to release system resources like file descriptors.

Why Avoid Leaks?

Javas Solution: Try-With-Resources

Since Java 7, the try-with-resources statement ensures automatic closing:

try (BufferedReader reader = new BufferedReader(new FileReader("input.txt"))) {
    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
}  // reader is automatically closed here, even if exceptions occur

Avoid manual closing or ignoring exceptions in finally blocks. Try-with-resources is safer, cleaner, and less error-prone.

Choose Proper Buffer Sizes Based on Context

Buffer size affects latency and throughput:

General Recommendations:

Experiment with buffer sizes using benchmarks specific to your workload.

Know When to Use Traditional IO vs NIO

Traditional IO (java.io)

NIO (java.nio)

Choosing Between Them

Scenario Recommended API
Simple file read/write Traditional IO
High-performance, large files NIO FileChannel
Network servers with many clients NIO with Selectors
Low concurrency, simple apps Traditional IO

Minimize Disk or Network IO When Possible

IO is often orders of magnitude slower than CPU and memory access. Minimizing the number of IO operations can drastically improve performance.

Strategies:

Use Direct Byte Buffers with NIO for Network IO

Java NIO provides direct byte buffers (ByteBuffer.allocateDirect()) that allocate memory outside the JVM heap and can improve IO performance by avoiding an extra copy between Java heap and OS buffers.

Example:

ByteBuffer buffer = ByteBuffer.allocateDirect(8192);

Use direct buffers cautiously—they are more expensive to create and clean up, but beneficial for long-lived buffers in high-performance networking.

Avoid Blocking Calls in Critical Threads

If you use traditional IO in UI or event-driven applications, blocking calls can freeze the program.

Examples Combining Best Practices

Reading a Text File Efficiently

import java.io.*;
import java.nio.charset.StandardCharsets;

public class EfficientFileReader {

    public static void readFile(String path) {
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
            String line;
            while ((line = reader.readLine()) != null) {
                // Process line
                System.out.println(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Writing to a Network Socket with NIO

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;

public class NIONetworkWriteExample {

    public static void sendMessage(String host, int port, String message) throws IOException {
        try (SocketChannel socketChannel = SocketChannel.open()) {
            socketChannel.connect(new InetSocketAddress(host, port));
            socketChannel.configureBlocking(true);

            ByteBuffer buffer = ByteBuffer.allocate(1024);
            buffer.put(message.getBytes());
            buffer.flip();

            while (buffer.hasRemaining()) {
                socketChannel.write(buffer);
            }
        }
    }
}

Summary of Best Practices

Practice Why It Matters
Use buffering Reduces costly system calls, improves throughput
Use try-with-resources Prevents resource leaks, simplifies cleanup
Choose appropriate buffer sizes Balances memory use and IO efficiency
Pick IO vs NIO based on need Simplifies code or enables scalability
Minimize IO operations Reduces latency and resource consumption
Use direct buffers for NIO Enhances performance in networking and large file IO
Avoid blocking calls on main threads Keeps UI and event loops responsive

By following these best practices, you can write IO code in Java that is not only functionally correct but also efficient and scalable—helping your applications run smoothly in real-world environments.

Index