Why MappedByteBuffer Beats FileChannel (And When It Doesn’t) – Deep Linux Kernel Insights

This article examines the internal read/write mechanisms of Java's FileChannel and MappedByteBuffer on Linux kernel 5.4, compares their performance through detailed source analysis and benchmarks, and explains why MappedByteBuffer often outperforms FileChannel for small I/O but can be overtaken for larger transfers due to page‑fault and dirty‑page handling.

Bin's Tech Cabin
Bin's Tech Cabin
Bin's Tech Cabin
Why MappedByteBuffer Beats FileChannel (And When It Doesn’t) – Deep Linux Kernel Insights

1. FileChannel read/write process

When a FileChannel#read call is made, the JVM first creates a temporary DirectByteBuffer and copies data from the kernel into it before moving the data to the user‑level HeapByteBuffer. For FileChannel#write, data is copied from the HeapByteBuffer to a temporary DirectByteBuffer and then written to the page cache via a native write system call.

public class IOUtil { 
    static int read(FileDescriptor fd, ByteBuffer dst, long position, NativeDispatcher nd) throws IOException { 
        if (dst instanceof DirectBuffer) 
            return readIntoNativeBuffer(fd, dst, position, nd); 
        ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining()); 
        try { 
            int n = readIntoNativeBuffer(fd, bb, position, nd); 
            if (n > 0) dst.put(bb); 
            return n; 
        } 
    } 
    static int write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd) throws IOException { 
        if (src instanceof DirectBuffer) 
            return writeFromNativeBuffer(fd, src, position, nd); 
        ByteBuffer bb = Util.getTemporaryDirectBuffer(rem); 
        try { 
            int n = writeFromNativeBuffer(fd, bb, position, nd); 
            return n; 
        } 
    } 
}

The temporary DirectByteBuffer is necessary because the JVM’s native layer can only operate on native memory; a HeapByteBuffer resides in the managed heap and may move during garbage collection (GC), breaking the address stability required for system calls.

During a GC pause, the underlying memory address of a HeapByteBuffer can change, so passing it directly to native code could lead to incorrect reads or writes. Using a temporary DirectByteBuffer guarantees a stable address.

Copying between heap and direct buffers is performed with Unsafe#copyMemory, an intrinsic method that runs without a safepoint, ensuring no GC occurs during the copy.

2. MappedByteBuffer read/write process

Mapping a file region with FileChannel#map creates a MappedByteBuffer that points directly to the kernel’s page cache. The initial mmap involves two context switches. When the buffer is first accessed, a page‑fault occurs, the kernel loads the required pages into the cache, and the process’s page tables are updated.

After the mapping is established, reads and writes to the MappedByteBuffer are ordinary memory accesses with no additional system‑call overhead, and they do not consume Java heap memory, thus bypassing -XX:MaxDirectMemorySize limits.

3. MappedByteBuffer vs FileChannel

When the file data is already in the page cache, FileChannel incurs two context switches and one CPU copy per operation, while MappedByteBuffer incurs virtually no context switches or copies. Consequently, MappedByteBuffer shows a clear advantage for small, frequent I/O.

However, as the transfer size grows, the cost of page‑fault handling for MappedByteBuffer becomes comparable to the system‑call cost of FileChannel, and FileChannel can eventually surpass MappedByteBuffer.

4. Benchmark – kernel‑level performance comparison

Two benchmark scenarios were executed on macOS with an Intel i7, 16 GB RAM, and an Apple SSD using OpenJDK 17:

File data pre‑loaded into the page cache (mlock) and locked in memory.

File data not present in the page cache, forcing disk I/O and page‑fault handling.

For each scenario, reads and writes were performed with block sizes ranging from 64 B to 512 M on a 1 GB file. The results show that:

With page cache, MappedByteBuffer outperforms FileChannel up to roughly 4 KB for reads and 8 KB for writes; beyond ~64 MB, FileChannel slightly overtakes.

Without page cache, MappedByteBuffer still leads for very small blocks (≤ 512 B for reads, ≤ 8 KB for writes) but loses advantage as block size increases, with FileChannel becoming up to twice as fast for large transfers.

The benchmark code is available at https://github.com/huibinliupush/benchmark .

4.1 Page‑cache‑resident data

Results illustrate that MappedByteBuffer’s read latency peaks at 2 KB (≈ 73 ms) and then degrades, while FileChannel reaches its best read latency at 64 KB (≈ 167 ms). Write latency shows a similar pattern, with MappedByteBuffer optimal at 8 KB (≈ 160 ms) and FileChannel optimal at 64 KB (≈ 160 ms).

4.2 Non‑cached data

When page faults and disk I/O are involved, MappedByteBuffer incurs an extra ~500 ms overhead, whereas FileChannel adds ~1 000 ms for the smallest blocks and ~100 ms for larger blocks. The crossover point where FileChannel becomes faster occurs around 4 KB for reads and 8 KB for writes.

5. Kernel‑level analysis of dirty‑page handling

FileChannel writes invoke generic_perform_write, which obtains a page from the cache, copies data from user space, marks the page dirty, and may trigger synchronous write‑back based on dirty_ratio or dirty_bytes. MappedByteBuffer writes, on the other hand, mark pages dirty during the page‑fault handling ( do_shared_fault and block_page_mkwrite) and also wait for ongoing write‑back via wait_for_stable_page.

ssize_t generic_perform_write(struct file *file, struct iov_iter *i, loff_t pos) { 
    status = a_ops->write_begin(file, mapping, pos, bytes, flags, &page, &fsdata); 
    copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes); 
    status = a_ops->write_end(file, mapping, pos, bytes, copied, page, fsdata); 
    balance_dirty_pages_ratelimited(mapping); 
}

static vm_fault_t do_shared_fault(struct vm_fault *vmf) { 
    ret = __do_fault(vmf); 
    if (vma->vm_ops->page_mkwrite) { 
        unlock_page(vmf->page); 
        tmp = do_page_mkwrite(vmf); 
    } 
}

int block_page_mkwrite(struct vm_area_struct *vma, struct vm_fault *vmf, get_block_t get_block) { 
    set_page_dirty(page); 
    wait_for_stable_page(page); 
}

After the kernel writes back a dirty page, it clears the dirty flag and makes the page read‑only in the process’s page tables. Subsequent writes to the same MappedByteBuffer trigger a write‑protect page‑fault, which restores write permission, re‑marks the page dirty, and may block if the page is still being written back.

Thus, even with mlock, MappedByteBuffer can suffer from page‑fault and dirty‑page write‑back overheads, explaining why its performance advantage diminishes for larger I/O sizes.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaPerformanceNIOLinux kernelMappedByteBufferFileChannel
Bin's Tech Cabin
Written by

Bin's Tech Cabin

Original articles dissecting source code and sharing personal tech insights. A modest space for serious discussion, free from noise and bureaucracy.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.