ByteBuffer Lifecycle: flip, clear, compact, and duplicate

Part 10 of 11 in Mastering Java Network Programming

ByteBuffer Lifecycle: flip, clear, compact, and duplicate

ByteBuffer is Java NIO’s workhorse for zero-copy I/O, but its cursor-based model is easy to get wrong. Every operation that reads from or writes to a buffer advances position, and when position reaches limit, the buffer appears “full” or “empty” depending on direction. The four methods — flip, clear, compact, duplicate — are the only way to reset those cursors for the next phase of the buffer’s life.

A ByteBuffer has three values: capacity (fixed allocation size), limit (where reading/writing stops), and position (current read/write offset). You fill a buffer by writing at position, then advance position toward limit. To write that data out over a socket or to disk, you need to reset position to 0 and cap reads at the old position — that’s flip.

flip() — Toggling Orientation

A ByteBuffer is unidirectional by default: after filling it with data, position sits at limit and hasRemaining() returns false. flip() reverses direction so the same bytes can be consumed:

  • position → 0
  • limit → old position (where the data ends)

Here’s what that looks like in practice — a 32-byte buffer filled with “Hello, NIO!” (11 bytes), flipped, and read back:

The code

ByteBuffer buf = ByteBuffer.allocate(32);
byte[] message = "Hello, NIO!".getBytes(StandardCharsets.UTF_8);
buf.put(message);           // pos = 11, limit = 32
buf.flip();                 // pos = 0,   limit = 11
while (buf.hasRemaining()) {
    result.append((char) buf.get());
}
// result == "Hello, NIO!"

Running it

Notice the put() advances position from 0 to 11 with limit pinned at capacity (32). After flip(), position resets to 0 and limit becomes 11 — exactly the bytes that were written. The read loop drains them one by one, advancing position back to 11. At this point position equals limit again; hasRemaining() is false and the buffer appears empty.

After consuming everything, clear() resets both position and limit: clear() does not zero any data — it sets position to 0 and limit to capacity (32), so the next put() overwrites from the beginning. The underlying array still contains the old bytes until overwritten, but they’re no longer reachable through the buffer’s cursor.

The same buffer is then filled again with “Goodbye, NIO!” (13 bytes), and position advances to 13 once more — ready for another flip.

clear() vs compact() — Buffer Reuse Strategies

clear() wipes every byte and resets cursors. But in real I/O code you rarely read everything from a buffer in one shot — a socket might deliver partial data, or you’re parsing a protocol that requires lookahead. That’s where compact() differs fundamentally.

Imagine reading an 8-byte chunk where only the first 4 bytes contained complete protocol records. The remaining 4 bytes are unread and must not be lost:

The code

ByteBuffer buf = ByteBuffer.allocate(8);
buf.put("ABCDEFGH".getBytes());   // pos=8, limit=8
// Simulate reading only first 4 bytes
buf.position(4);                    // pos=4, limit=8
buf.compact();                      // shifts EFGH to front
buf.put("WXYZ".getBytes());         // writes after EFGH
// Result: [E,F,G,H,W,X,Y,Z]

Running it

The clear() path (left side) shows the danger: after clearing, position resets to 0 and limit to capacity. Writing “XXXX” from position 0 overwrites the unread bytes entirely. Underlying array inspection confirms — positions 0–3 now hold ‘X’, while E–H remain in slots 4–7 but are no longer reachable because the new write started at position 0.

The compact() path (right side) does something different: it copies the unread slice (bytes from position to limit) to the beginning of the buffer, then sets position right after that copied data. A subsequent write starts at position 4 — immediately after the preserved “EFGH”. The result array shows E,F,G,H,W,X,Y,Z in order: the unread bytes survived and are followed by fresh data.

This is why compact() exists. If you call clear() on a partially-consumed buffer, you lose whatever the remote side sent next. Compact() preserves that tail for the next fill cycle.

duplicate() — Independent Views Over Shared Data

When parsing a message with multiple fields, you might need to hand chunks of the same data to different parsers at different speeds. duplicate() creates a new ByteBuffer with its own position/limit/capacity cursors that share the underlying backing array:

The code

ByteBuffer buf = ByteBuffer.allocate(40);
buf.put(json.getBytes());          // fill buffer
buf.flip();
ByteBuffer reader1 = buf.duplicate();   // independent cursor
ByteBuffer reader2 = buf.duplicate();   // another one
// Both start at pos=0, limit=25
// But advance independently:
reader1.read();           // pos=1 for reader1 only
reader2.read();           // pos=1 for reader2 only - no interference

Running it

The demo fills a 40-byte buffer with JSON, flips it, and creates two duplicate readers. Both start at position 0 with limit 25. Reader 1 slowly parses the “name” field, advancing to position 16 (past the comma). Meanwhile Reader 2 consumes all 25 bytes in one pass.

After modification through the original buffer — writing “override” at position 0 — both duplicates immediately see the changed data because they share the same backing array. The key point: duplicate() shares data, not cursors. Each cursor is independent, but mutations flow through to all views.

This is distinct from asReadOnlyBuffer(), which prevents writes entirely. With duplicate() you get independent read/write access to the same memory — useful when multiple threads need to parse different fields of a shared payload without coordination overhead.

Takeaway

The four operations form a complete lifecycle: flip switches between fill-and-drain phases, clear discards for fresh starts, compact preserves unread data for partial consumption, and duplicate gives multiple cursers over the same content. The mental model is simple — position moves, limit caps, capacity stays fixed — but the real-world trap is forgetting to flip before reading or calling clear() when you needed compact().