Skip to content
Chapter 45 · Core Java

Java NIO FileChannel, Zero Copy, and Memory Mapped Files ​

If you have already learned about ByteBuffer and the advantages of NIO over traditional IO, then FileChannel is going to feel remarkably natural. Everything clicks once you understand the buffer. This chapter takes you through FileChannel with code for each use case, then zero copy and memory mapped files.

Why One Class Instead of Many ​

Think about your kitchen. In a traditional kitchen you have separate tools for every job. You have a knife for cutting bread, a different knife for cutting vegetables, a separate board for meat, and another for fish. It works but you are managing a lot of different tools.

Traditional Java IO works the same way. You have FileInputStream for reading files, FileOutputStream for writing files, BufferedInputStream to add buffering on top of reads, BufferedOutputStream for buffered writes, FileReader for reading character data, FileWriter for writing character data, BufferedReader for efficient line reading, and BufferedWriter on top of that. That is eight different classes just to do basic file reading and writing.

NIO takes a completely different philosophy. In the NIO world, one class handles everything. That class is FileChannel. You can use it to read a file. You can use it to write a file. You can even use the same channel instance to do both at the same time. One tool, all jobs.

How FileChannel Works With ByteBuffer ​

Before you write any code you need to understand the relationship between FileChannel and ByteBuffer. They are partners. Neither one works without the other.

The flow for reading looks like this. Your application creates an empty ByteBuffer. Think of the buffer like an empty glass. You hand this empty glass to the FileChannel. The FileChannel talks to the operating system and says: hey, read this many bytes from the file starting at this position. The OS checks its page cache, and if the data is there it returns it directly. If not, the OS fetches it from disk into the page cache first, then hands it to the channel. The channel fills up your ByteBuffer with that data. Now your application reads from the ByteBuffer.

The flow for writing is the mirror image. Your application fills a ByteBuffer with data. Think of it like filling a glass with juice. You hand the full glass to the channel. The channel reads from the buffer and tells the OS to write those bytes to disk. The OS writes to its page cache and eventually flushes to the physical disk.

Opening a FileChannel ​

You open a FileChannel using the static FileChannel.open() method. You pass it a Path and one or more StandardOpenOption values that tell it what you intend to do.

java
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

// Open a channel for writing only
// CREATE creates the file if it does not exist
// TRUNCATE_EXISTING empties the file if it already exists
// WRITE allows writing
Path filePath = Paths.get("myfile.txt");

FileChannel writeChannel = FileChannel.open(
    filePath,
    StandardOpenOption.CREATE,
    StandardOpenOption.TRUNCATE_EXISTING,
    StandardOpenOption.WRITE
);

Together these three options mean: if the file already exists, empty it (TRUNCATE_EXISTING); if it does not exist, create it (CREATE); and use the channel only for writing (WRITE), not reading.

For reading you open the channel differently:

java
// Open a channel for reading only
// The file must already exist
FileChannel readChannel = FileChannel.open(
    filePath,
    StandardOpenOption.READ
);

For both reading and writing through the same channel:

java
// Open a channel that can both read and write
FileChannel rwChannel = FileChannel.open(
    filePath,
    StandardOpenOption.CREATE,
    StandardOpenOption.READ,
    StandardOpenOption.WRITE
);

Always open channels inside a try with resources block so they close automatically when you are done.

Use Case One: Writing a File ​

Let's write data to a file. The process is: create a ByteBuffer, fill it with your data, flip it so the channel can read from it, then hand it to the channel.

java
try (FileChannel channel = FileChannel.open(
        Paths.get("output.txt"),
        StandardOpenOption.CREATE,
        StandardOpenOption.TRUNCATE_EXISTING,
        StandardOpenOption.WRITE)) {

    // The text we want to write
    String text = "Hello all, how are you? This is a test with NIO FileChannel.";

    // Convert string to bytes using UTF 8 encoding
    byte[] bytes = text.getBytes("UTF-8");

    // Create a ByteBuffer exactly the right size for our data
    ByteBuffer buffer = ByteBuffer.allocate(bytes.length);

    // Fill the buffer with our data (this moves position to end)
    buffer.put(bytes);

    // Flip the buffer from write mode to read mode
    // Position goes back to 0, limit is set to where we stopped writing
    buffer.flip();

    // Hand the buffer to the channel
    // Channel reads from the buffer and writes to the file
    channel.write(buffer);

    System.out.println("File written successfully.");
}

One common mistake beginners make is forgetting to call flip() before passing the buffer to the channel for writing. After you do buffer.put(bytes), the position is at the end of the data. If you pass this to the channel without flipping, the channel will see position and limit are the same and think there is nothing to write. Flip resets position to zero and sets limit to where your data ends, so the channel knows exactly how many bytes to read.

Use Case Two: Reading a File ​

Reading is slightly trickier because your file might be larger than your buffer. Imagine you have a file of 500 bytes but you created a buffer of only 100 bytes. You cannot read the whole file in one shot. This is why you read inside a while loop.

java
try (FileChannel channel = FileChannel.open(
        Paths.get("output.txt"),
        StandardOpenOption.READ)) {

    // Create a buffer smaller than the file to demonstrate looping
    ByteBuffer buffer = ByteBuffer.allocate(100);

    StringBuilder content = new StringBuilder();

    // Keep reading until there is nothing left
    // channel.read() returns -1 when it reaches end of file
    while (channel.read(buffer) != -1) {
        // Flip to read mode so we can get data out of the buffer
        buffer.flip();

        // Read all bytes from the buffer
        while (buffer.hasRemaining()) {
            content.append((char) buffer.get());
        }

        // Clear the buffer to reuse it for the next chunk
        buffer.clear();
    }

    System.out.println("File content: " + content.toString());
}

The channel automatically maintains a position inside the file. When you call channel.read(buffer) the first time, it starts at position zero in the file. After reading 100 bytes the channel position moves to 100. Next call reads from position 100, and so on. You do not have to manage this manually. The channel tracks it for you.

Inside the while loop you have to flip the buffer before reading from it. After the channel fills the buffer, position is at the end of the newly written data. Flip puts position back to zero and sets limit to where the data ends. Then you read bytes out using buffer.get(). After you are done with this chunk you call buffer.clear() to reset position to zero and limit to capacity, making the buffer ready to receive the next chunk.

Use Case Three: Random Access Reading ​

One powerful feature of FileChannel is the ability to jump to any position in the file before reading. Traditional streams only go forward. FileChannel lets you seek.

java
try (FileChannel channel = FileChannel.open(
        Paths.get("output.txt"),
        StandardOpenOption.READ)) {

    // Jump to byte position 16 in the file before reading
    channel.position(16);

    // Create a buffer to hold 11 bytes
    ByteBuffer buffer = ByteBuffer.allocate(11);

    // Read 11 bytes starting from position 16
    channel.read(buffer);

    // Flip to prepare for reading from the buffer
    buffer.flip();

    // Buffer level random access: read specific bytes by index
    // Get byte at index 0 (first byte read = byte 16 of file)
    byte firstByte = buffer.get(0);

    // Get byte at index 9 (tenth byte read = byte 25 of file)
    byte tenthByte = buffer.get(9);

    System.out.println("Byte at index 0: " + (char) firstByte);
    System.out.println("Byte at index 9: " + (char) tenthByte);
}

You get two levels of randomness here. Channel level randomness means you choose where in the file to start reading. Buffer level randomness means once the data is in the buffer, you can jump to any index inside the buffer using buffer.get(index) without moving through all the bytes before it. This combination is extremely powerful for parsing large binary files where you need to jump around to read headers, footers, or specific data structures.

Use Case Four: Same Channel for Read and Write ​

You can open one FileChannel and use it for both reading and writing. This is efficient because you avoid opening the file twice.

java
try (FileChannel channel = FileChannel.open(
        Paths.get("readwrite.bin"),
        StandardOpenOption.CREATE,
        StandardOpenOption.TRUNCATE_EXISTING,
        StandardOpenOption.READ,
        StandardOpenOption.WRITE)) {

    // Write primitives: an int, a char and one byte
    ByteBuffer writeBuffer = ByteBuffer.allocate(32);
    writeBuffer.putInt(100);             // 4 bytes
    writeBuffer.putChar('A');            // 2 bytes
    writeBuffer.put("1".getBytes());     // 1 byte
    writeBuffer.flip();                  // read mode for the channel
    channel.write(writeBuffer);

    // CRITICAL: after writing, the channel position is at the end.
    // Move it back to the start before reading.
    channel.position(0);

    ByteBuffer readBuffer = ByteBuffer.allocate(32);
    channel.read(readBuffer);
    readBuffer.flip();

    System.out.println("Int: " + readBuffer.getInt());          // 100
    System.out.println("Char: " + readBuffer.getChar());        // A
    System.out.println("Byte: " + (char) readBuffer.get());     // 1
}

When you use the same channel for both reading and writing, always reset the channel position to zero before you start reading. After a write operation, the position is pointing to the end of the data you just wrote. If you call channel.read(buffer) at that point, the channel is already at the end of the file and it immediately returns minus one. Your buffer stays empty. Always call channel.position(0) to reset before reading.

Character Encoding and Decoding ​

One thing NIO does not handle for you automatically is character encoding. When you write a String to a file, you need to encode it to bytes first. When you read bytes back and want to turn them into a String, you need to decode them.

NIO provides Charset for this:

java
import java.nio.charset.Charset;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.CharsetDecoder;
import java.nio.CharBuffer;

Charset charset = Charset.forName("UTF-8");
CharsetEncoder encoder = charset.newEncoder();
CharsetDecoder decoder = charset.newDecoder();

// Encoding: String to ByteBuffer
String text = "Hello with dollar $ sign";
CharBuffer charBuffer = CharBuffer.wrap(text);
ByteBuffer encoded = encoder.encode(charBuffer);

// Now write encoded to channel
channel.write(encoded);

// Decoding: ByteBuffer to String
ByteBuffer rawBytes = ByteBuffer.allocate(100);
channel.read(rawBytes);
rawBytes.flip();

CharBuffer decoded = decoder.decode(rawBytes);
String result = decoded.toString();

Why does this matter? Consider the dollar sign. In UTF 8, the dollar sign takes exactly one byte. But characters like the euro sign take three bytes. The encoder knows these rules and handles the conversion correctly. The decoder knows that when it sees those three bytes together, they represent a single character. If you handle encoding incorrectly you get garbled text. This manual encoding step is extra work compared to traditional IO. The higher-level NIO.2 Files API (for example Files.readString, Java 11) hides it.

Why compact() matters when decoding ​

When you read a file in chunks, a multi-byte character can be split across two reads: the first chunk ends with only 1 of the 3 bytes of €. The decoder decodes only complete characters and leaves the partial bytes unread. If you called clear() you would lose them; compact() moves them to the front so the next read appends the rest:

java
try (FileChannel channel = FileChannel.open(Paths.get("prices.txt"), StandardOpenOption.READ)) {
    CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
    ByteBuffer bytes = ByteBuffer.allocate(4);   // tiny on purpose: a 3-byte '€' can be split
    CharBuffer chars = CharBuffer.allocate(4);
    StringBuilder text = new StringBuilder();

    while (channel.read(bytes) != -1) {
        bytes.flip();
        decoder.decode(bytes, chars, false);     // decodes only complete characters
        chars.flip();
        text.append(chars);
        chars.clear();
        bytes.compact();                         // keep the unread half-character bytes
    }
    System.out.println(text);                    // e.g. Price: €5, €10, €20
}

Zero Copy: The Most Important Feature for Performance ​

Zero copy is the feature that makes NIO genuinely powerful for high performance scenarios. To understand why it matters, first understand what happens without it.

Imagine you have a server with a large file on disk and a client that wants that file. Without zero copy, here is what happens:

  1. Your application asks the OS to read the file
  2. The OS reads the file into its page cache in kernel memory
  3. The OS copies that data from kernel memory into your JVM heap memory
  4. Your application now has one copy in heap memory
  5. Your application hands this data to a socket or another stream to send to the destination
  6. The OS copies it again from JVM heap back into kernel memory for transmission

You have the data sitting in three places at once. Kernel page cache, your JVM heap, and kernel transmission buffer. Two unnecessary copy operations happen. For small files this barely matters. For large files transferred frequently, this kills performance.

Zero copy tells the OS: do not involve me. You go directly from the source to the destination yourself.

transferTo: Achieving Zero Copy ​

The transferTo method is your tool for zero copy in FileChannel:

java
// Without zero copy: manual read then write
try (FileChannel source = FileChannel.open(Paths.get("source.dat"), StandardOpenOption.READ);
     FileChannel dest = FileChannel.open(Paths.get("dest.dat"),
         StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

    // Old way: manual loop with buffer
    ByteBuffer buffer = ByteBuffer.allocate(8192);
    while (source.read(buffer) != -1) {
        buffer.flip();
        while (buffer.hasRemaining()) {
            dest.write(buffer);   // one write may not drain it all
        }
        buffer.clear();
    }
}

// With zero copy: one line does it all
try (FileChannel source = FileChannel.open(Paths.get("source.dat"), StandardOpenOption.READ);
     FileChannel dest = FileChannel.open(Paths.get("dest.dat"),
         StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

    // Transfer from position 0, for the entire file length, to dest channel
    // The OS handles this entirely without copying data into JVM memory
    source.transferTo(0, source.size(), dest);
    // Note: transferTo may move fewer bytes than asked; for large files,
    // loop on the returned count until everything is transferred.
}

With transferTo, your application never touches the data at all. You issue a command to the OS: hey, take the contents of this file starting at this byte position, read this many bytes, and deliver it to this destination channel. The OS goes from its page cache directly to the destination. Your JVM heap never holds a copy of the data. This eliminates one or two memory copy operations depending on the hardware.

This is exactly how Kafka achieves its legendary throughput. When a Kafka consumer requests messages, the broker does not load the message bytes into JVM heap and then send them. The broker calls transferTo and the OS ships the data directly from the page cache to the consumer's socket. The broker JVM barely participates.

transferFrom works from the other direction, where the destination channel pulls data from a source:

java
// transferFrom: destination pulls from source
try (FileChannel source = FileChannel.open(Paths.get("source.dat"), StandardOpenOption.READ);
     FileChannel dest = FileChannel.open(Paths.get("dest.dat"),
         StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

    // destination channel requests data from source channel
    // starting at position 0 in destination, take source.size() bytes from source
    dest.transferFrom(source, 0, source.size());
}

Memory Mapped Files: Treating a File Like an Array ​

Memory mapped files are another advanced feature of FileChannel. The concept sounds complex but the idea is elegant. You tell the OS: map this file into my address space. Now I will read and write this file as if it were just an array in memory.

Think of it like this. Normally when you want data from a file you make a request, the OS fetches it, and you get a copy. With memory mapping you are saying: just point my pointer directly at where you keep the file data. I will reach in and get it myself.

java
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

try (FileChannel channel = FileChannel.open(
        Paths.get("bigfile.dat"),       // must already exist and be non-empty
        StandardOpenOption.READ,
        StandardOpenOption.WRITE)) {

    // Map the file into memory
    // MapMode.READ_WRITE means we can both read and update
    // 0 means start from the beginning of the file
    // channel.size() means map the entire file
    MappedByteBuffer mmap = channel.map(
        FileChannel.MapMode.READ_WRITE,
        0,
        channel.size()
    );

    // Reading: access bytes by index, just like an array
    byte firstByte = mmap.get(0);
    byte fifthByte = mmap.get(4);
    System.out.println("First byte: " + (char) firstByte);

    // Writing: update bytes by index
    mmap.put(0, (byte) 'N'); // Write 'N' at position 0
    mmap.put(1, (byte) 'I'); // Write 'I' at position 1
    mmap.put(2, (byte) 'O'); // Write 'O' at position 2

    // Force the changes to be written to disk
    // Without this, the OS decides when to flush
    mmap.force();
}

You do not have to map the whole file: channel.map(mode, 100, 100) maps only bytes 100 to 199.

Here is what actually happens internally. When you call channel.map(), the OS does not immediately copy the file contents into RAM. Instead it gives you a range of virtual memory addresses and records that those addresses correspond to this file. No physical RAM is allocated yet.

When your code reads mmap.get(0), the CPU tries to access that virtual address. It checks the page table in the Memory Management Unit. There is no physical mapping yet, so a page fault occurs. The OS handles the page fault by loading that page of the file from disk into the OS page cache, then updates the page table to map your virtual address to that physical RAM page. Now your code can read it.

The key insight is that the page cache is shared. If another process already loaded this file, the data is already in the OS page cache. Your access becomes instant with no disk read required. Multiple processes can all map the same file and share the same physical RAM pages.

When you write to the memory mapped buffer, you are writing directly into the OS page cache. The OS marks those pages as dirty and eventually flushes them to disk. Calling mmap.force() tells the OS to flush immediately rather than waiting.

When to Use NIO vs Traditional IO ​

NIO is not always better.

Use traditional IO when you are working with small files, when you need character level operations and do not want to handle encoding manually, when simplicity matters more than maximum throughput, or when your team is not deeply familiar with buffers and channels.

Use NIO FileChannel when you are working with large files, when you need to copy data between channels as fast as possible and zero copy matters, when you need random access to different parts of a large file, when you are building something like a database, message queue, or log based storage system where you are doing many frequent reads and writes and raw performance is critical.

In short: small files or occasional use → traditional; very large files, frequent access and speed-critical, Kafka-like workloads → NIO.

Check yourself ​

  1. What do CREATE, TRUNCATE_EXISTING and WRITE together do?
  2. What happens if you put data and call channel.write(buffer) without flip()?
  3. Why does channel.read(buffer) sit inside a while loop?
  4. You wrote to a READ+WRITE channel and immediately call read. Why do you get -1, and what is the fix?
  5. When decoding UTF-8 in chunks, why use compact() instead of clear()?
  6. What does transferTo() avoid?
  7. Why call force() on a MappedByteBuffer after writing?
Answers
  1. Create the file if missing, empty it if it exists, and open it for writing only.
  2. position equals limit, so the channel sees 0 bytes and writes nothing.
  3. The file may be bigger than the buffer; read returns -1 at end of file.
  4. The channel position is at the end of what you wrote. Call channel.position(0) first.
  5. A character can be split between reads; compact() keeps the partial bytes instead of throwing them away.
  6. Copying the data into JVM memory; the OS moves it directly from the page cache to the destination.
  7. Writes go to the OS page cache (dirty pages) and reach the disk later; force() flushes them now.