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.
- 1ByteBuffer buf = ByteBuffer.allocate(8);
a fresh buffer: 8 zeroed bytes. position 0, limit and capacity 8. everything between position and limit is room to write.
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:
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));
}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.
| method | position | limit | use it when |
|---|---|---|---|
flip() | 0 | old position | done writing, about to read what you wrote |
clear() | 0 | capacity | done reading everything, about to write fresh |
compact() | after the unread bytes | capacity | done reading some, keep the rest and write more |
rewind() | 0 | unchanged | read the same data again |
mark() / reset() | remember / go back | unchanged | peek ahead, then return |
ByteBuffer buf = ByteBuffer.allocate(8);
buf.put((byte) 1).put((byte) 2).put((byte) 3);
IO.println(buf.remaining()); // no flipchannels: 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.
| channel | for |
|---|---|
FileChannel | files: positional reads, map, locks, transferTo |
SocketChannel / ServerSocketChannel | TCP, blocking or not. see sockets and selectors |
DatagramChannel | UDP |
Pipe | two 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:
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);
}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.
ByteBuffer.allocate(64 * 1024) · CPU copies per read = 2the 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.
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:
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.
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);
}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:
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());
}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.
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());
}
}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.
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.
- a buffer is bytes plus cursors. position moves on every relative get and put.
- write →
flip()→ read →compact()orclear()→ write. that's the loop. - channels read and write whole buffers.
transferTolets 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. ByteBufferis big-endian unless you say otherwise.