skip to content
everything.io.buffersAndChannels

buffers and channels

ByteBuffer position, limit, flip. and memory-mapped files.

chapter
chapter 10.04
reading time
10 min read

a ByteBuffer is a fixed block of bytes plus three cursors: position (where the next read or write happens), limit (how far you're allowed to go) and capacity (the end of the block). you fill it, flip() it, drain it, compact() or clear() it, repeat.

a channel reads into and writes from buffers, a block at a time. that's NIO: no streams, no per-byte calls, just blocks and cursors. and when you really want speed, the buffer can live outside the heap, or be the file.

the picture

you know how everyone's first NIO bug is writing to a buffer, handing it to a channel, and nothing gets written? that's because one cursor does double duty. writing moves position forward. reading also reads from position. so after a write, position sits right after your data, pointing at garbage, until you flip().

think of a notepad with a pen. capacity is the bottom of the page. while you write, the pen (position) moves down, and limit is the bottom of the page. to read back what you wrote, you draw a line under the last word (limit = where the pen is) and move your finger to the top (position = 0). that's flip(). that's genuinely all it does.

mode =
  1. 1ByteBuffer buf = ByteBuffer.allocate(8);
·
0
·
1
·
2
·
3
·
4
·
5
·
6
·
7
remaining() = 8 · room to write
↑position
↑limitcapacity
position = 0limit = 8capacity = 8read so far = ·

a fresh buffer: 8 zeroed bytes. position 0, limit and capacity 8. everything between position and limit is room to write.

step 1/12
walk through the classic sequence, or switch to play and break it yourself. the green bracket is remaining(): position up to limit, the part the next read or write can touch. blue arrows are the cursors, amber is what just changed, red is java saying no.

the walk-through is a real program. here's the core of it, run for real:

Main.java
import java.nio.ByteBuffer;
 
static void show(String op, ByteBuffer b) {
    IO.println(op + " pos=" + b.position() + " lim=" + b.limit() + " rem=" + b.remaining());
}
 
void main() {
    ByteBuffer buf = ByteBuffer.allocate(8);
    buf.put("HELLO".getBytes());    show("put  ", buf);
    buf.flip();                     show("flip ", buf);
    buf.get(); buf.get();           show("get×2", buf);
    buf.compact();                  show("cmpct", buf);
    IO.println(new String(buf.array(), 0, 5));
}
output
put   pos=5 lim=8 rem=3
flip  pos=0 lim=5 rem=5
get×2 pos=2 lim=5 rem=3
cmpct pos=3 lim=8 rem=5
LLOLO

LLOLO: compact moved LLO to the front and left the old LO behind at 3 and 4. buffers never erase, they only move cursors and overwrite.

methodpositionlimituse it when
flip()0old positiondone writing, about to read what you wrote
clear()0capacitydone reading everything, about to write fresh
compact()after the unread bytescapacitydone reading some, keep the rest and write more
rewind()0unchangedread the same data again
mark() / reset()remember / go backunchangedpeek ahead, then return
predict the output
java
ByteBuffer buf = ByteBuffer.allocate(8);
buf.put((byte) 1).put((byte) 2).put((byte) 3);
IO.println(buf.remaining());   // no flip

channels: the other half

a channel is a connection to something that does I/O (a file, a socket) that speaks in buffers. unlike streams, one channel can both read and write, and read(buf) fills as much of the buffer as it can in one call.

channelfor
FileChannelfiles: positional reads, map, locks, transferTo
SocketChannel / ServerSocketChannelTCP, blocking or not. see sockets and selectors
DatagramChannelUDP
Pipetwo channels connected to each other, inside one JVM

the canonical copy loop is where flip and compact earn their keep. write isn't obliged to take everything (a slow socket might take half), so the loop compacts whatever's left instead of losing it:

Main.java
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.*;
import static java.nio.file.StandardOpenOption.*;
 
void main() throws Exception {
    Path src = Files.createTempFile("src", ".txt");
    Path dst = Files.createTempFile("dst", ".txt");
    Files.writeString(src, "bytes in, bytes out. ".repeat(1000));
 
    ByteBuffer buf = ByteBuffer.allocateDirect(4096);
    int reads = 0;
    try (FileChannel in = FileChannel.open(src, READ);
         FileChannel out = FileChannel.open(dst, WRITE, TRUNCATE_EXISTING)) {
        while (in.read(buf) != -1 || buf.position() > 0) {
            reads++;
            buf.flip();       // switch to draining
            out.write(buf);   // may not write everything
            buf.compact();    // keep the leftovers, switch back to filling
        }
    }
    IO.println(Files.size(dst) + " bytes, " + reads + " loops, identical: " + (Files.mismatch(src, dst) == -1));
    Files.delete(src); Files.delete(dst);
}
output, from jdk 25
21000 bytes, 6 loops, identical: true

this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.

21,000 bytes through a 4 KiB buffer: 6 trips (five full ones and a partial). and if all you're doing is copying channel to channel, skip the loop: in.transferTo(0, in.size(), out) hands the whole job to the OS.

heap or direct?

ByteBuffer.allocate(n) wraps a plain byte[] on the heap. ByteBuffer.allocateDirect(n) grabs native memory outside the heap. the difference only matters once the bytes have to cross into the OS, and it's all about copies.

buffer =
ByteBuffer.allocate(64 * 1024) · CPU copies per read = 2
java heap
GC'd, objects can move
your ByteBuffer (a byte[])
↑copy #2: JDK copies into your array
native memory
your process, outside the heap
temporary direct buffer, borrowed by the JDK
↑copy #1: read() syscall
kernel
page cache, socket buffers
page cache
↑DMA, the CPU isn't involved
disk / network card
file blocks

the OS can't write into the heap safely: the GC is allowed to move your byte[] mid-syscall. so the JDK reads into a native buffer it keeps per thread, then copies over. two CPU copies for every read.

pick a kind of buffer and follow one read from the disk up into your code. amber hops are copies the CPU does. a purple box is the JDK's own machinery. mapped means your buffer points at the OS's pages instead of holding a copy.

how much does that copy cost? less than you'd think, because memory bandwidth is huge. reading a 256 MiB file that's already in the OS page cache, 64 KiB at a time:

read 256 MiB · heap buffer22 ms
read 256 MiB · direct buffer20 ms
read + sum every long · heap45 ms
read + sum every long · mapped33 ms
allocate 1 MiB · heap65 µs
allocate 1 MiB · direct180 µs
median of 11 runs, jdk 25, M4 Pro, macOS. the file was in the page cache, so this measures copies and calls, not the disk. allocation: one 1 MiB buffer, averaged over 2,000.

so: direct buffers win around 10% on raw throughput and lose about 3× on allocation. the rule that falls out is the usual one. allocate direct buffers once, keep them, reuse them (one per connection, one per thread). allocating a fresh direct buffer per request is slower than just using the heap.

memory-mapped files

FileChannel.map asks the OS to map a region of the file straight into your address space. you get a MappedByteBuffer, and reading it is just reading memory: no read() calls, no copies. the OS loads pages lazily on first touch (a page fault) and writes dirty pages back to the file on its own schedule.

Main.java
import java.nio.*;
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.util.HexFormat;
import static java.nio.file.StandardOpenOption.*;
 
void main() throws Exception {
    Path file = Files.createTempFile("mapped", ".bin");
    Files.write(file, new byte[8]);
 
    try (FileChannel ch = FileChannel.open(file, READ, WRITE)) {
        MappedByteBuffer m = ch.map(FileChannel.MapMode.READ_WRITE, 0, 8);
        m.putInt(0, 0xCAFEBABE);       // a plain memory write
        IO.println(HexFormat.ofDelimiter(" ").formatHex(Files.readAllBytes(file)));
 
        Files.write(file, new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 });   // change the file
        IO.println(m.get(7));          // the mapping sees it
    }
    Files.delete(file);
}
output, from jdk 25
ca fe ba be 00 00 00 00
8

this one needs a real JVM (a library class the browser runtime doesn't have), so it doesn't run in the browser. copy it into java Main.java to try it.

see? a memory write showed up in the file without a single write(), and a file write showed up in the buffer. they're the same pages. that's how databases, search indexes (Lucene), Kafka's log index and the JDK's own lib/modules get random access to huge files without reading them.

byte order, slices and views

a buffer that holds bytes still has to decide how to write an int as four of them. ByteBuffer is big-endian by default (network order, what java's own formats use), no matter what CPU you're on:

Main.java
import java.nio.*;
import java.util.HexFormat;
 
void main() {
    var hex = HexFormat.ofDelimiter(" ");
    ByteBuffer b = ByteBuffer.allocate(4);
    b.putInt(1);
    IO.println(b.order() + ": " + hex.formatHex(b.array()));
 
    b.clear().order(ByteOrder.LITTLE_ENDIAN).putInt(1);
    IO.println(b.order() + ": " + hex.formatHex(b.array()));
    IO.println("this CPU: " + ByteOrder.nativeOrder());
}
output, from jdk 25
BIG_ENDIAN: 00 00 00 01
LITTLE_ENDIAN: 01 00 00 00
this CPU: LITTLE_ENDIAN

this one needs a real JVM (a library class the browser runtime doesn't have), so it doesn't run in the browser. copy it into java Main.java to try it.

reading a file format written by C code on x86 or arm? it's little-endian. call order(ByteOrder.LITTLE_ENDIAN) before the first getInt, or every number comes out byte-swapped.

slice(), duplicate() and asReadOnlyBuffer() make views: new cursors over the same bytes, not copies.

b = ByteBuffer.wrap("HELLOWORLD")
H
0
E
1
L
2
L
3
O
4
w
5
↑position
O
6
R
7
L
8
D
9
world = b.slice() · its own position 0, capacity 5
w
0
O
1
R
2
L
3
D
4
one array, two buffers. the slice starts at the parent's position 5 and has its own index 0. writing through either one changes the same byte.
Main.java
import java.nio.ByteBuffer;
 
void main() {
    ByteBuffer b = ByteBuffer.wrap("HELLOWORLD".getBytes());
    b.position(5);
    ByteBuffer world = b.slice();          // a window, not a copy
    world.put(0, (byte) 'w');
    IO.println(new String(b.array()) + " " + world.capacity() + " " + world.position());
 
    ByteBuffer ro = b.asReadOnlyBuffer();
    try {
        ro.put(0, (byte) 'h');
    } catch (Exception e) {
        IO.println(e.getClass().getSimpleName());
    }
}
output, from jdk 25
HELLOwORLD 5 0
ReadOnlyBufferException

this one needs a real JVM (catching a runtime exception), so it doesn't run in the browser. copy it into java Main.java to try it.

notice put(0, ...): the absolute methods take an index and don't move position at all. the relative ones (put(b), get()) use and advance position. mixing them up is the second most common buffer bug.

one level deeper:why big heap buffers can eat native memory+

when you do I/O with a heap buffer, the JDK borrows a temporary direct buffer of the same size (the copy #1 in the figure) and caches it per thread so the next call doesn't have to allocate. if 200 threads each write a 10 MiB heap buffer to a socket once, that's up to 2 GiB of native memory sitting in thread-local caches, invisible in heap dumps. the cap is the system property jdk.nio.maxCachedBufferSize: temporary buffers bigger than it aren't cached. the better fix is not doing big I/O through heap buffers: write in chunks, or keep your own direct buffer per thread.

keylines
  • a buffer is bytes plus cursors. position moves on every relative get and put.
  • write → flip() → read → compact() or clear() → write. that's the loop.
  • channels read and write whole buffers. transferTo lets the OS copy without you.
  • heap buffers cost an extra copy per I/O, direct ones cost more to allocate. allocate direct once and reuse.
  • a mapped buffer is the file's pages in your memory. for deterministic unmapping and >2 GiB, use FFM's MemorySegment.
  • ByteBuffer is big-endian unless you say otherwise.