Appearance
Java NIO Buffer Internals: ByteBuffer, Position, Limit, Capacity, and Everything In Between
Java NIO gives you a fundamentally different way to work with files and I/O. Instead of reading one byte at a time through streams, you work with chunks of data packed into a structure called a buffer, and you move those chunks through a channel. To use NIO correctly you must understand exactly how buffers work internally, because unlike streams, you are the one who creates and manages the buffer yourself. The framework no longer hides the details from you. That is both the power and the responsibility that NIO puts in your hands.
This chapter covers how ByteBuffer works inside: its three pointer fields, write mode vs read mode, what flip() does step by step, clear() vs compact(), and relative vs absolute access.
The Big Picture: Buffer and Channel
Before you touch a single line of NIO code, you need a mental model of the two moving parts.
Think of a two way highway. Your application sits on one side, the operating system sits on the other. Channel is the highway itself, the medium through which your application connects to the OS and the OS connects back. Buffer is the vehicle that carries data along that highway.
Data always flows inside a buffer. This is not optional. In NIO, everything goes through a buffer. Nothing travels byte by byte the way it does in classic streams.
The critical distinction from streams is who creates the buffer. With BufferedInputStream or BufferedOutputStream, the framework creates the internal buffer for you. You never see it. In NIO, you create the buffer. You size it. You manage its internal state. That extra responsibility is exactly what gives you the extra capability.
Here is how the two operations play out:
During a write: Your application fills the buffer with data, then passes the buffer to the channel. The channel reads the buffer and sends the bytes down to the OS, which writes them to disk.
During a read: Your application creates an empty buffer and passes it to the channel. The channel asks the OS to fetch data from disk. The OS fills the buffer. Once the buffer is full, your application reads from it.
Notice the word "reads" appears on both sides. When your application writes to disk, the channel reads your buffer. When your application reads from disk, your application reads from the buffer. This is not a typo. The buffer is always the middleman, and whether it is being filled or drained depends on who is acting on it at that moment.
The Class Hierarchy
Java gives you an abstract class called Buffer. This class holds the internal pointer fields that every buffer type shares. Below Buffer sits another abstract class, ByteBuffer, which is what you will actually use. (There are also IntBuffer, CharBuffer, LongBuffer and so on, but channels work only with ByteBuffer.) There are three kinds of ByteBuffer you will meet:
- HeapByteBuffer: the buffer lives inside the JVM heap
- DirectByteBuffer: the buffer lives in native (off heap) memory
- MappedByteBuffer: a window directly into the OS page cache for a file
(In the JDK, DirectByteBuffer is implemented as a subclass of MappedByteBuffer, and a mapped file is returned as a DirectByteBuffer instance.) You create instances using static factory methods, never constructors directly.
allocate vs allocateDirect
java
// HeapByteBuffer: buffer lives on the JVM heap
ByteBuffer heapBuffer = ByteBuffer.allocate(64);
// DirectByteBuffer: buffer lives in native memory outside the heap
ByteBuffer directBuffer = ByteBuffer.allocateDirect(64);ByteBuffer.allocate(n) creates a buffer backed by a byte[] array inside the JVM heap. The garbage collector manages its lifetime. Allocation is fast. However, when the channel needs to send this buffer to the OS, the JVM must first copy the data into a temporary native memory region. That extra copy is the "double copy" cost you may have heard about.
ByteBuffer.allocateDirect(n) creates a buffer backed by native memory, completely outside the JVM heap. There is no garbage collector involvement in the buffer itself. When the channel passes this buffer to the OS, no extra copy is needed: this avoids the double copy. ("Zero copy" is a different feature, transferTo(), from the previous chapter.) The trade off is that allocation is slower, and the native memory is freed by the Cleaner only after the small heap object is garbage collected, which is less predictable.
MappedByteBuffer is created through FileChannel.map(), not through ByteBuffer factory methods directly. No buffer memory is allocated at all. Instead, the OS page cache is mapped into your process address space. Reading and writing the buffer is literally reading and writing OS memory. No JVM copy ever happens.
The Three Pointer Fields
The Buffer abstract class holds integer fields that track the internal state of every buffer. Understanding the three important ones is the single most important thing you can learn about NIO. Every method you call on a buffer either reads or modifies these fields.
The three fields are: capacity, limit, and position.
Visualize your buffer as an array of bytes laid out in a row:
Index: 0 1 2 3 4 5
Data: [ ] [ ] [ ] [ ] [ ] [ ]Now imagine three invisible markers hovering over this array. Those markers are the pointer fields.
Capacity
Capacity is the total number of bytes the buffer can hold. It is set when the buffer is created and never changes for the lifetime of the buffer. If you call ByteBuffer.allocate(6), the capacity is 6. Period. Nothing you call on the buffer afterward can change that number.
java
ByteBuffer buf = ByteBuffer.allocate(6);
System.out.println(buf.capacity()); // always 6Limit
Limit is a boundary marker. You cannot read or write beyond the limit. The limit divides the buffer into two zones: the active zone (indices 0 through limit minus 1) and the restricted zone (indices from limit onward).
When a buffer is freshly allocated and ready for writing, the limit equals the capacity. This means the entire buffer is available for you to fill.
When the buffer switches to reading mode (after you call flip()), the limit moves to wherever the last write stopped. This prevents you from reading past the last byte of valid data.
java
System.out.println(buf.limit()); // 6 initially, moves after flip()Position
Position is the cursor. It marks where the next read or write will happen. Every time you read a byte or write a byte, position advances by one (or by more bytes if you are reading/writing multi byte types like int or long).
Position starts at 0 when the buffer is freshly allocated. As you write into the buffer, position climbs toward the limit. When you switch to reading mode, position resets to 0 so that reading starts from the beginning of the valid data.
java
System.out.println(buf.position()); // 0 initiallyThe Rule: 0 <= position <= limit <= capacity
This ordering always holds. No buffer method will break it; if you try (for example buf.position(10) when limit is 6), you get an exception.
Writing Mode: Filling the Buffer
When you first allocate a buffer, it is ready for writing:
position = 0
limit = 6 (equals capacity)
capacity = 6java
ByteBuffer buf = ByteBuffer.allocate(6);You write bytes using put():
java
buf.put((byte) 10);
buf.put((byte) 20);
buf.put((byte) 30);After these three writes:
Index: 0 1 2 3 4 5
Data: [ 10] [ 20] [ 30] [ ] [ ] [ ]
^
position is now 3
limit = 6
capacity = 6Position advanced by one with each put() call. Limit and capacity did not change.
Now suppose you call buf.get() without doing anything else first:
java
byte b = buf.get(); // WRONG: you get garbage or uninitialized dataPosition is at index 3. Index 3 holds no meaningful data. You will read an uninitialized zero byte, not the 10, 20, or 30 you wrote. This is one of the most common NIO bugs beginners make.
flip(): The Mode Switch
flip() is the method that prepares a buffer for reading after you finish writing. You must call it every time you want to switch from writing to reading. Calling flip() does exactly two things, in this order:
- Set
limit = position(the limit drops to where the last write ended) - Set
position = 0(the cursor moves back to the start)
Capacity never changes.
Here is the step by step walkthrough with the example above:
Before flip():
position = 3
limit = 6
capacity = 6flip() executes:
- limit = 3 (was position)
- position = 0
After flip():
position = 0
limit = 3
capacity = 6Index: 0 1 2 3 4 5
Data: [ 10] [ 20] [ 30] [ ] [ ] [ ]
^ ^
position limit (cannot read here or beyond)Now when you call buf.get():
- First call: reads index 0, gets 10, position becomes 1
- Second call: reads index 1, gets 20, position becomes 2
- Third call: reads index 2, gets 30, position becomes 3
- Fourth call: position equals limit,
BufferUnderflowExceptionis thrown
The limit acts as a hard stop. You cannot accidentally read the uninitialized bytes at indices 3, 4, and 5.
Why flip() is needed: because the buffer uses a single position cursor for both reading and writing. After writing, position points past the last written byte. If you tried to read from there, you would get garbage. flip() sets the limit to where valid data ends, then resets position to the beginning so reading starts at the first valid byte. Without flip(), you would read from the wrong location with no useful data boundary.
clear(): Resetting for Another Write Cycle
After you have finished reading all the data from a buffer, you might want to reuse the buffer for another round of writes. clear() does this:
- Set
position = 0 - Set
limit = capacity
java
buf.clear();After clear():
position = 0
limit = 6 (back to capacity)
capacity = 6This looks identical to the state right after allocate(). The buffer is in write mode again, ready to accept new data from the start.
Critical detail: clear() does not erase the data. The bytes at indices 0 through 5 still hold whatever values were written before. clear() only moves the pointers. When you start writing again, new data overwrites the old data as position advances. If you write fewer bytes than before and then flip() and read, you will only read the new bytes because limit stops at the new write position.
compact(): Keeping Unread Data
There is a situation clear() cannot handle well. Imagine you filled a buffer, flipped it, read some of the data, but still have unread bytes left. If you call clear() now and start writing, the unread bytes get overwritten and lost.
compact() solves this. It:
- Copies all bytes from the current position to the limit (the unread bytes) down to the start of the buffer (index 0)
- Sets
position = limit - old_position(right after the copied data) - Sets
limit = capacity
In plain terms: compact() squeezes the unread data to the front of the buffer, then positions the write cursor right after it so that new data you write goes in after the saved data.
java
// Buffer after partial read:
// position = 1 (read one byte), limit = 3, capacity = 6
// bytes: [10, 20, 30, ?, ?, ?]
buf.compact();
// After compact():
// bytes: [20, 30, ?, ?, ?, ?] (unread bytes copied to front)
// position = 2 (right after the two compacted bytes)
// limit = 6 (capacity)You are now in write mode again, with the unread data preserved at the start.
In short: clear() simply resets the pointers and discards all awareness of previously written data (though the bytes physically remain). Use it when you are done with all the data in the buffer and want to start fresh.
compact() preserves the bytes that have not been read yet by copying them to the front of the buffer, then positions the write cursor after them. Use it when you have processed some data but more remains and you want to make room for more incoming data without losing the tail end.
Relative vs Absolute Access
ByteBuffer offers two families of read and write methods. The difference is whether the operation uses and advances the position pointer, or bypasses it entirely.
Relative Methods (Use Position, Advance It)
These are the methods you use in normal sequential operation:
java
// Write
buf.put((byte) 42); // writes at position, advances position
buf.putInt(65); // writes 4 bytes at position, advances by 4
buf.putLong(12345678L); // writes 8 bytes at position, advances by 8
// Read
byte b = buf.get(); // reads at position, advances position
int i = buf.getInt(); // reads 4 bytes at position, advances by 4
long l = buf.getLong(); // reads 8 bytes at position, advances by 8Every call reads or writes at the current position and then increments position. These are the bread and butter of NIO development.
Absolute Methods (Use Explicit Index, Bypass Position)
These methods take an explicit byte index as the first argument:
java
// Write at specific index, position NOT changed
buf.putInt(0, 100); // write 100 as int starting at byte index 0
buf.putInt(4, 200); // write 200 as int starting at byte index 4
buf.putInt(8, 300); // write 300 as int starting at byte index 8
// Read from specific index, position NOT changed
int a = buf.getInt(0); // read int from byte index 0
int b = buf.getInt(4); // read int from byte index 4
int c = buf.getInt(8); // read int from byte index 8Absolute methods do not use or change position. The index you pass must still be inside the limit (otherwise IndexOutOfBoundsException).
A consequence: flip() is not required with absolute methods. Since position is not involved, you can write at index 0 and immediately read back from index 0 with no mode switch in between.
java
ByteBuffer buf = ByteBuffer.allocate(16);
buf.putInt(0, 100);
buf.putInt(4, 200);
// No flip() needed
int first = buf.getInt(0); // 100
int second = buf.getInt(4); // 200Practical Use Case: Peek Without Consuming
The absolute get methods are perfect for peeking at data before committing to reading it. Because they do not advance position, calling an absolute get is like looking at a value without consuming it.
A real world pattern: you need to validate a header field in an incoming network packet before deciding how to process the rest of the data. You peek at the first four bytes using buf.getInt(0) to read a message type code. If the type code is invalid, you reject the packet. If valid, you then switch to sequential reading with buf.getInt() (relative), which advances position properly as you consume the packet.
java
// buf is already in read mode (flipped) and holds a packet
// Peek at the message type without consuming it
int messageType = buf.getInt(0); // absolute, position unchanged
if (messageType != EXPECTED_TYPE) {
throw new IllegalArgumentException("Unknown message type");
}
// Now consume the buffer sequentially; position is still 0
int type = buf.getInt(); // consumes type from position 0
int length = buf.getInt(); // consumes length from position 4
// ... etcHandling Primitive Types Beyond Byte
Why is this needed? An int is 4 bytes. With only put(byte) you would have to split 65 into bytes yourself (put((byte)0) three times, then put((byte)65)) and glue 4 bytes back together when reading. That is the same problem DataOutputStream solved for streams.
The ByteBuffer name is a bit misleading. Yes, it works at the byte level internally, but it has built in support for every Java primitive type. You do not need separate classes to handle int, long, double, short, char, or float.
java
ByteBuffer buf = ByteBuffer.allocate(100);
// Writing different primitive types
buf.putInt(42); // writes 4 bytes
buf.putLong(999L); // writes 8 bytes
buf.putDouble(3.14); // writes 8 bytes
buf.putShort((short) 7); // writes 2 bytes
buf.putChar('A'); // writes 2 bytes
buf.flip(); // switch to read mode
// Reading back in the same order
int i = buf.getInt(); // reads 4 bytes
long l = buf.getLong(); // reads 8 bytes
double d = buf.getDouble(); // reads 8 bytes
short s = buf.getShort(); // reads 2 bytes
char c = buf.getChar(); // reads 2 bytesByteBuffer handles all the encoding and decoding internally. You write an int, four bytes are stored. You read an int, four bytes are decoded back into an int. This is the approach you should use in practice.
The View Buffer Approach (Less Preferred)
Java also lets you create a typed "view" over the same backing memory:
java
ByteBuffer byteBuffer = ByteBuffer.allocate(100);
IntBuffer intBuffer = byteBuffer.asIntBuffer();
// same memory, different interpretation
// others: asCharBuffer() and asShortBuffer() (2 bytes each), asFloatBuffer() (4),
// asLongBuffer() and asDoubleBuffer() (8)intBuffer is not a new allocation. It shares the exact same underlying byte array as byteBuffer. But it has its own separate position, limit, and capacity values. The capacity of intBuffer is 25 (100 bytes divided by 4 bytes per int). Its position starts at 0.
When you write through intBuffer:
java
intBuffer.put(65); // position of intBuffer advances to 1
// byteBuffer.position() is still 0!This is the trap. The channel only accepts ByteBuffer. Before passing a buffer to a channel you must flip() the ByteBuffer. But byteBuffer.position() is still 0, so flip() would set limit = 0, and the channel could read nothing.
If you use this view approach, you must manually synchronize the ByteBuffer position before calling flip():
java
// After writing through intBuffer:
byteBuffer.position(intBuffer.position() * Integer.BYTES); // sync the position
byteBuffer.flip(); // now limit = actual written bytes, position = 0
channel.write(byteBuffer); // works correctlyThis is fragile, easy to forget, and a common source of subtle bugs. Calling putInt() directly on ByteBuffer is preferred because the ByteBuffer position stays correct automatically.
Channels Only Speak ByteBuffer
This is worth repeating as its own section because the confusion is so common.
The FileChannel, SocketChannel, and every other channel implementation in Java NIO only accepts ByteBuffer. They do not accept IntBuffer, LongBuffer, CharBuffer, or any other typed view. Raw bytes are what the OS understands, and channels work at that level.
When you pass a ByteBuffer to a channel for writing, the channel reads from the buffer (draining it toward the channel/OS). Therefore the buffer must be in read mode before you hand it to the channel. That means you must have called flip() after your last write so that the channel reads valid data from position 0 up to the limit.
java
ByteBuffer buf = ByteBuffer.allocate(64);
buf.putInt(42);
buf.putLong(999L);
buf.flip(); // REQUIRED before passing to channel
fileChannel.write(buf); // channel reads buf from position 0 to limitForgetting flip() before a channel write is one of the most common NIO bugs. The channel will write garbage (reading from position onward, which may be empty) or nothing at all.
rewind(): Back to the Start Without Changing Limit
rewind() sets position to 0 but leaves limit where it is. It is useful when you want to read the same data again without recalculating the limit.
java
// After reading all data with flip() already called:
buf.rewind(); // position = 0, limit unchanged
// Read again from the startUnlike flip(), rewind() does not change the limit. It assumes you are already in the right mode and just want to start the cursor over.
After reading everything, calling flip() again also works for a second read: position equals limit at that point, so flip() sets limit to the same value and position to 0.
Complete Code Walkthrough
Here is a complete example showing the full lifecycle of a ByteBuffer:
java
import java.nio.ByteBuffer;
public class ByteBufferDemo {
public static void main(String[] args) {
// Step 1: Allocate a buffer on the heap
// capacity = 16, limit = 16, position = 0
ByteBuffer buf = ByteBuffer.allocate(16);
System.out.println("After allocate:");
System.out.println(" position=" + buf.position() +
" limit=" + buf.limit() +
" capacity=" + buf.capacity());
// Step 2: Write data (relative puts, position advances)
buf.putInt(100); // 4 bytes, position = 4
buf.putInt(200); // 4 bytes, position = 8
System.out.println("After two putInt() calls:");
System.out.println(" position=" + buf.position() +
" limit=" + buf.limit() +
" capacity=" + buf.capacity());
// Step 3: Flip to switch from write mode to read mode
// limit = 8 (was position), position = 0
buf.flip();
System.out.println("After flip():");
System.out.println(" position=" + buf.position() +
" limit=" + buf.limit() +
" capacity=" + buf.capacity());
// Step 4: Read data sequentially
int first = buf.getInt(); // reads 4 bytes, position = 4
int second = buf.getInt(); // reads 4 bytes, position = 8
System.out.println("Read values: " + first + ", " + second);
// Step 5: Compact (pretend we only read 'first', reset to mid-read)
// Re-demonstrate compact
buf.clear();
buf.putInt(100);
buf.putInt(200);
buf.putInt(300); // 12 bytes written
buf.flip();
int a = buf.getInt(); // read 100, position = 4
// We read one value. Two remain (200 and 300). Now compact.
buf.compact();
// Unread bytes [200, 300] copied to front
// position = 8 (right after the 8 bytes of compacted data)
// limit = 16 (capacity)
System.out.println("After compact():");
System.out.println(" position=" + buf.position() +
" limit=" + buf.limit());
// Now write more data into the compacted buffer
buf.putInt(400);
buf.flip();
System.out.println("After compacting and writing 400, then flip:");
System.out.println(" Reads: " + buf.getInt() + // 200
", " + buf.getInt() + // 300
", " + buf.getInt()); // 400
// Step 6: Absolute access -- no flip required
ByteBuffer absBuf = ByteBuffer.allocate(16);
absBuf.putInt(0, 1000); // write at index 0, position unchanged
absBuf.putInt(4, 2000); // write at index 4, position unchanged
System.out.println("Absolute reads (no flip needed):");
System.out.println(" " + absBuf.getInt(0)); // 1000
System.out.println(" " + absBuf.getInt(4)); // 2000
System.out.println(" position still = " + absBuf.position()); // 0
}
}Check yourself
- A new
ByteBuffer.allocate(8): what are position, limit and capacity? - You
put3 bytes and callflip(). What are position and limit now? - You then call
get()4 times. What happens on the 4th call? - Does
clear()erase the bytes? - After
flip()you read 1 of 3 bytes and callcompact(). What are position and limit (capacity 6)? - Does
buf.getInt(4)change position? Do you needflip()before it? - Why is writing through
asIntBuffer()risky before a channel write? - What is the difference between
flip()andrewind()?
Answers
- position 0, limit 8, capacity 8.
- position 0, limit 3.
BufferUnderflowException, because position equals limit.- No. It only resets position to 0 and limit to capacity.
- The 2 unread bytes move to the front; position 2, limit 6.
- No, absolute methods leave position alone, so no
flip()is needed (index must be within the limit). - The view has its own position; the
ByteBuffer's position stays 0, soflip()sets limit 0 and the channel writes nothing unless you sync the position. flip()sets limit = position and position = 0;rewind()only sets position = 0.