Memra

Buffers: four numbers and one invariant

◈ 5 cards

position, limit, capacity and mark, the invariant that orders them, and what flip, clear, rewind and compact each do to those four numbers through one fill-and-drain cycle.

A buffer is a store plus four numbers

In the stream world you push bytes at an OutputStream and pull them from an InputStream, and the stream holds the state. In the channel world every transfer goes through a buffer, and the buffer holds the state. A ByteBuffer is a fixed-size store of bytes plus four indices, and every method on it is defined as a move of those indices — never as a copy, and never as an erase:

  • capacity — how many elements the buffer can hold. Fixed at creation, never changes.
  • limit — the first index you may not touch. Reads and writes stop here.
  • position — the next index that will be read or written. Starts at 0, advances on every relative operation.
  • mark — a remembered position, set by mark(), returned to by reset().

They obey one invariant at all times:

0 <= mark <= position <= limit <= capacity

Learn to state those four numbers after every operation and NIO stops being mysterious. Very nearly every buffer bug in existence is one of them sitting somewhere you did not expect.

Filling

buffer.put(b) and channel.read(buffer) both write at position and then advance it. You fill until position reaches limit; a relative put past the limit throws BufferOverflowException, which is a runtime exception — the compiler will not warn you.

Turning it around: flip()

flip() sets limit = position, then sets position = 0. Read it as one sentence: stop writing; the data ends where I stopped; start reading from the beginning. It is the single most important method in the API and forgetting it is the single most common NIO bug. remaining() returns limit - position, and hasRemaining() is simply remaining() > 0.

Draining

buffer.get() and channel.write(buffer) read at position and advance it. When position reaches limit, hasRemaining() goes false and the buffer is drained. Draining removes nothing — the bytes are all still in the store. Only position moved.

Emptying, rereading, compacting

  • clear()position = 0, limit = capacity. It does not erase. The old bytes are still there and an absolute get(index) will still return them. clear() only says "I am finished with all of that; refill from the top".
  • rewind()position = 0, limit untouched. Reread exactly the same data.
  • compact() — copy the bytes from position up to limit down to index 0, set position to the end of what was copied, and set limit = capacity. This is what you want when you consumed only part of what you read: the unconsumed tail survives, and the next read appends to it.

Worked example — fill eight, flip, drain five, compact

A line-oriented server reads a command from a channel into a ten-byte buffer. The client sends PING 42 followed by a newline — eight bytes, arriving in one packet.

ByteBuffer buf = ByteBuffer.allocate(10);

position 0, limit 10, capacity 10, no mark.

channel.read(buf) returns 8 → position 8, limit 10. The eight bytes sit at indices 0 through 7, and position points at the next free slot.

buf.flip()limit 8, position 0. remaining() is now 8, which is exactly the data that arrived; the two unwritten slots at 8 and 9 have been fenced off.

Five get() calls drain PING and its space → position 5, limit 8, remaining() 3.

buf.compact() → the three undrained bytes move down to indices 0, 1 and 2, and the buffer becomes position 3, limit 10 — ready for the next read() to append to them.

That last step is the whole point. clear() there would have produced position 0 and limit 10, which from the outside looks identical — but the next read() would have written straight over the tail of the command, silently, with no exception raised anywhere.

0123456789after readPINGSP42LFposition8 bytes read inafter flip()PINGSP42LFpositionlimitafter 5 getsPINGSP42LFpositionlimitremaining = 3after compact()42LFGSP42LFpositioncompact() keeps the tail; clear() would overwrite it.
One buffer, one full cycle. After the read, limit is still at capacity, which is index 10 and therefore off the right-hand end of the drawing. flip() pulls position home and drops limit onto index 8. After five gets the three-byte tail is what remains. compact() slides that tail to the front; the greyed cells behind it are stale bytes that are still physically present, which is exactly why clear() would have lost the command.
NORMAL ~/memra/learn/comp-348/nio-buffer-model utf-8 LF