Lesson 48 of 71
ByteBuffer, Direct Buffers, and Memory-Mapped Files
A ByteBuffer is a fixed-size container of bytes with a cursor, used by NIO channels, network libraries, and binary encoders. This lesson covers the buffer state machine, the two kinds of buffer storage, byte order, and memory-mapped files. After it, a reader can read and write binary data through buffers and channels and explain what a mapped file costs and when its memory is released.
| API or idea | Purpose |
|---|---|
position, limit, capacity |
Cursor state of a buffer |
flip, clear, compact, rewind |
Switch between writing and reading |
allocate vs allocateDirect |
Storage on the heap or outside it |
order(ByteOrder) |
Big-endian or little-endian multi-byte values |
FileChannel |
Read, write, and map a file |
MappedByteBuffer, force |
File contents as memory, flushed to disk on request |
Agrona UnsafeBuffer |
Absolute-index access without a cursor |
Files in the examples are created with Files.createTempFile and deleted at the end. The examples print contents and sizes, never paths.
Example 1: position, limit, and capacity
A buffer has three indexes. Capacity is its fixed size. Position is where the next relative put or get happens. Limit is the first index that must not be read or written. flip sets the limit to the current position and the position to 0, which turns a buffer that was just written into one that can be read.
import java.nio.ByteBuffer;
public class Main {
static void show(String step, ByteBuffer b) {
System.out.println(step + ": position=" + b.position() + " limit=" + b.limit()
+ " capacity=" + b.capacity() + " remaining=" + b.remaining());
}
public static void main(String[] args) {
ByteBuffer b = ByteBuffer.allocate(16);
show("new", b);
b.putInt(42).putShort((short) 7);
show("after puts", b);
b.flip();
show("after flip", b);
System.out.println("read int " + b.getInt() + ", short " + b.getShort());
show("after gets", b);
b.clear();
show("after clear", b);
System.out.println("absolute get(0..3) still sees int " + b.getInt(0));
}
}new: position=0 limit=16 capacity=16 remaining=16
after puts: position=6 limit=16 capacity=16 remaining=10
after flip: position=0 limit=6 capacity=16 remaining=6
read int 42, short 7
after gets: position=6 limit=6 capacity=16 remaining=0
after clear: position=0 limit=16 capacity=16 remaining=16
absolute get(0..3) still sees int 42putInt advanced the position by 4 and putShort by 2. After flip, reads can go only up to the 6 bytes written. clear does not erase data: it resets position to 0 and limit to capacity. The absolute form getInt(0) takes an index and leaves the position unchanged.
Example 2: compact for partial reads
A network read often delivers a message only in part. compact copies the unread bytes (from position to limit) to the start of the buffer, sets the position after them, and sets the limit to the capacity, so the next read appends to the leftover bytes. This example decodes length-prefixed messages that arrive in two chunks.
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class Main {
static void decode(ByteBuffer b) {
b.flip(); // switch to reading
while (b.remaining() >= 2) {
int len = b.getShort(b.position()); // peek the length, absolute
if (b.remaining() < 2 + len) break; // message not complete yet
b.getShort();
byte[] body = new byte[len];
b.get(body);
System.out.println("message: " + new String(body, StandardCharsets.US_ASCII));
}
System.out.println("left over bytes: " + b.remaining());
b.compact(); // keep leftovers, switch to writing
}
public static void main(String[] args) {
ByteBuffer wire = ByteBuffer.allocate(32);
for (String s : new String[] {"BUY", "SELL"}) {
wire.putShort((short) s.length()).put(s.getBytes(StandardCharsets.US_ASCII));
}
wire.flip();
byte[] all = new byte[wire.remaining()];
wire.get(all); // 11 bytes on the wire
ByteBuffer in = ByteBuffer.allocate(32);
in.put(all, 0, 7); // first chunk: "BUY" plus half of "SELL"
decode(in);
System.out.println("position after compact: " + in.position());
in.put(all, 7, all.length - 7); // second chunk
decode(in);
}
}message: BUY
left over bytes: 2
position after compact: 2
message: SELL
left over bytes: 0After the first chunk, two bytes of the second message (its length prefix) remain. compact moved them to index 0 and left the position at 2, so the second chunk lands right after them. Forgetting flip or compact is the most common buffer bug: the code reads garbage past the data, or overwrites unread bytes.
Example 3: byte order
Multi-byte values can be stored most significant byte first (big-endian) or least significant byte first (little-endian). A new ByteBuffer is always big-endian, which is network byte order. x86-64 and most ARM servers are little-endian, so ByteOrder.nativeOrder() returns LITTLE_ENDIAN on them.
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.HexFormat;
public class Main {
public static void main(String[] args) {
HexFormat hex = HexFormat.ofDelimiter(" ");
ByteBuffer big = ByteBuffer.allocate(4);
big.putInt(0x01020304);
System.out.println("default order: " + big.order());
System.out.println("big-endian bytes: " + hex.formatHex(big.array()));
ByteBuffer little = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN);
little.putInt(0x01020304);
System.out.println("little-endian bytes: " + hex.formatHex(little.array()));
big.order(ByteOrder.LITTLE_ENDIAN);
System.out.printf("same bytes read little-endian: 0x%08x%n", big.getInt(0));
System.out.println("native order is little-endian: "
+ (ByteOrder.nativeOrder() == ByteOrder.LITTLE_ENDIAN));
}
}default order: BIG_ENDIAN
big-endian bytes: 01 02 03 04
little-endian bytes: 04 03 02 01
same bytes read little-endian: 0x04030201
native order is little-endian: trueThe order is a property of the buffer view, not of the bytes. Reading big-endian bytes with a little-endian view gives a different number, which is how protocol bugs between a Java service and a C service usually appear. Using native order for data that stays on one machine avoids a byte swap on each access.
Example 4: heap buffers and direct buffers
ByteBuffer.allocate creates a heap buffer backed by a byte[]. ByteBuffer.allocateDirect creates a direct buffer whose bytes live in native memory outside the heap. When a channel writes a heap buffer, the JDK first copies it into a temporary direct buffer, because the operating system needs an address that the garbage collector will not move.
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.nio.ByteBuffer;
public class Main {
public static void main(String[] args) {
ByteBuffer heap = ByteBuffer.allocate(1024);
ByteBuffer direct = ByteBuffer.allocateDirect(1024);
System.out.println("heap: isDirect=" + heap.isDirect() + " hasArray=" + heap.hasArray());
System.out.println("direct: isDirect=" + direct.isDirect() + " hasArray=" + direct.hasArray());
direct.putLong(0, 123L);
ByteBuffer slice = direct.slice(0, 8);
System.out.println("slice shares memory: " + (slice.getLong(0) == 123L)
+ ", slice capacity " + slice.capacity());
for (BufferPoolMXBean pool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
if (pool.getName().equals("direct")) {
System.out.println("direct pool holds at least 1024 bytes: " + (pool.getMemoryUsed() >= 1024));
}
}
}
}heap: isDirect=false hasArray=true
direct: isDirect=true hasArray=false
slice shares memory: true, slice capacity 8
direct pool holds at least 1024 bytes: true| Property | Heap buffer | Direct buffer |
|---|---|---|
| Storage | byte[] on the heap |
Native memory |
| Allocation cost | Low | Higher (native allocation, zeroing, cleaner registration) |
| Channel I/O | Copied through a temporary direct buffer | Passed to the OS as is |
| Freed | With the array, by GC | When the buffer object is collected and its cleaner runs |
| Limit | -Xmx |
-XX:MaxDirectMemorySize |
Direct memory is capped by -XX:MaxDirectMemorySize, which defaults to the maximum heap size; Runtime Data Areas shows the OutOfMemoryError when the cap is reached. Because direct buffers are expensive to create and are freed late, applications allocate them once and reuse them, or pool them.
Example 5: FileChannel reads and writes
A FileChannel reads and writes a file through ByteBuffers, at the channel's position or at an absolute file offset. Files and the stream-based I/O APIs are covered in Streams, Readers, and Files; this lesson uses only the channel.
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class Main {
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("orders", ".bin");
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
ByteBuffer out = ByteBuffer.allocateDirect(16);
for (long id = 1; id <= 3; id++) {
out.clear();
out.putLong(id).putLong(id * 100);
out.flip();
while (out.hasRemaining()) ch.write(out); // write may be partial
}
System.out.println("file size: " + ch.size());
ByteBuffer in = ByteBuffer.allocate(16);
ch.read(in, 16); // absolute offset: record 2
in.flip();
System.out.println("record 2: id=" + in.getLong() + " price=" + in.getLong());
System.out.println("channel position unchanged: " + ch.position());
} finally {
Files.delete(file);
}
}
}file size: 48
record 2: id=2 price=200
channel position unchanged: 48write returns the number of bytes written and may write fewer than requested, so the loop runs until the buffer is empty. The positional read(buffer, offset) does not move the channel position, which lets several threads read different parts of one file through the same channel.
Example 6: memory-mapped files and the page cache
FileChannel.map returns a MappedByteBuffer, a direct buffer whose memory is the file itself. Linux keeps recently used file data in the page cache, kernel memory holding file pages. Mapping puts those pages into the process address space, so reads and writes are plain memory accesses with no system call and no copy. force writes changed pages back to the storage device.
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class Main {
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("journal", ".dat");
try {
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
MappedByteBuffer map = ch.map(FileChannel.MapMode.READ_WRITE, 0, 4096);
System.out.println("file size after map: " + ch.size());
for (int i = 0; i < 4; i++) {
map.putLong(i * 8, 1000L + i);
}
map.force(); // flush dirty pages to the device
System.out.println("isDirect: " + map.isDirect());
}
// A new channel sees the data written through memory.
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
MappedByteBuffer ro = ch.map(FileChannel.MapMode.READ_ONLY, 0, ch.size());
long sum = 0;
for (int i = 0; i < 4; i++) sum += ro.getLong(i * 8);
System.out.println("sum of 4 longs: " + sum);
try {
ro.putLong(0, 1);
} catch (java.nio.ReadOnlyBufferException e) {
System.out.println("write to READ_ONLY map rejected");
}
}
} finally {
Files.delete(file);
}
}
}file size after map: 4096
isDirect: true
sum of 4 longs: 4006
write to READ_ONLY map rejectedMapping 4,096 bytes of an empty file in READ_WRITE mode grew the file to 4,096 bytes. Without force, the kernel still writes dirty pages back in the background, and other processes see the changes at once through the shared page cache; force only matters for durability after a power loss or kernel crash. A mapped page that is not in memory causes a page fault on first access, which costs microseconds and shows up as a latency spike. load() touches every page in advance.
Example 7: the unmapping problem
A MappedByteBuffer has no close or unmap method. The mapping stays valid after the channel is closed and is released only when the buffer object is garbage collected. The JDK does this on purpose: unmapping memory that a Java thread still reads would crash the JVM.
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class Main {
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("mapped", ".dat");
MappedByteBuffer map;
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
map = ch.map(FileChannel.MapMode.READ_WRITE, 0, 64);
}
map.putInt(0, 77);
System.out.println("channel closed, mapping still usable: " + (map.getInt(0) == 77));
Files.delete(file);
System.out.println("file deleted, mapping still readable: " + (map.getInt(0) == 77));
}
}channel closed, mapping still usable: true
file deleted, mapping still readable: trueOn Linux the deleted file's disk space is held until the mapping goes away, and on Windows the delete fails while the file is mapped. Code that rolls many journal files can run out of address space or disk space waiting for GC. The workarounds have costs:
sun.misc.Unsafe.invokeCleaner(buffer)unmaps at once, but it is deprecated for removal since Java 23 and crashes the JVM if the buffer is used afterwards.- Mapping with an
Arenathrough the Foreign Function and Memory API gives deterministic unmapping with access checks; The Foreign Function and Memory API shows it.
Example 8: Agrona UnsafeBuffer
Agrona's UnsafeBuffer wraps a byte[], a ByteBuffer, or a raw address and offers only absolute-index access, with no position or limit. Low-latency code (Aeron, SBE codecs) uses it to read and write fields at fixed offsets without the state changes of ByteBuffer. Its default byte order is native order, unlike ByteBuffer. Agrona 2.x reads jdk.internal.misc.Unsafe, so the JVM needs --add-exports java.base/jdk.internal.misc=ALL-UNNAMED; without it the first UnsafeBuffer fails with IllegalAccessError.
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import org.agrona.concurrent.UnsafeBuffer;
public class Main {
public static void main(String[] args) {
UnsafeBuffer buf = new UnsafeBuffer(ByteBuffer.allocateDirect(64));
buf.putLong(0, 42L); // order id
buf.putInt(8, 10_050); // price in cents
buf.putStringAscii(12, "AAPL"); // 4-byte length + chars
System.out.println("capacity " + buf.capacity());
System.out.println("id=" + buf.getLong(0) + " price=" + buf.getInt(8)
+ " symbol=" + buf.getStringAscii(12));
System.out.println("big-endian read of price: "
+ buf.getInt(8, ByteOrder.BIG_ENDIAN));
System.out.println("default order is native: "
+ (buf.getInt(8) == buf.getInt(8, ByteOrder.nativeOrder())));
}
}capacity 64
id=42 price=10050 symbol=AAPL
big-endian read of price: 1109852160
default order is native: trueBounds checks in UnsafeBuffer can be turned off with the system property agrona.disable.bounds.checks=true, which removes the check cost and also the protection against writing past the buffer. The varhandle-based atomic methods it offers (putLongVolatile, compareAndSetLong) follow the rules in VarHandles and Fences.
Interview points
flipsets limit to position and position to 0;compactkeeps unread bytes and switches back to writing;clearerases nothing.- A new
ByteBufferis big-endian regardless of the platform; x86 is little-endian. - Channel I/O with a heap buffer copies through a temporary direct buffer.
- Direct buffers are limited by
MaxDirectMemorySize(default: max heap) and freed only after the buffer object is collected. - A mapped file is backed by the page cache: no system call per access, but the first touch of a page can fault.
forceis about durability, not visibility to other processes.- There is no public
unmapforMappedByteBuffer; FFM with anArenais the supported way to unmap deterministically. UnsafeBuffergives absolute-offset access in native order, the basis of flyweight codecs.
Summary
- A buffer has capacity, limit, and position; relative operations move the position, absolute ones do not.
compactsupports reading framed messages that arrive in pieces.- Byte order belongs to the buffer view;
order(ByteOrder)changes how bytes are interpreted. FileChannel.writecan be partial; loop until the buffer is empty.FileChannel.mapinREAD_WRITEmode grows the file to the mapped size.- A mapping outlives its channel and lasts until GC; deleting the file does not release its space on Linux.
- Agrona
UnsafeBufferhas no cursor and optional bounds checks.
