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 article digs into every internal detail of ByteBuffer: its four pointer fields, the invariant that ties them together, how writing mode and reading mode differ, what flip() actually does step by step, the difference between compact() and clear(), and when to use absolute versus relative access. Every interview question on this topic is answered here.
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. Below ByteBuffer sit three concrete implementations:
- HeapByteBuffer -- the buffer lives inside the JVM heap
- DirectByteBuffer -- the buffer lives in native (off heap) memory
- MappedByteBuffer -- not a buffer in the traditional sense; it is a window directly into the OS page cache
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 is the "zero copy" path that makes NIO so attractive for high throughput I/O. The trade off is that allocation is slower, and the memory is freed by the OS when the buffer is eventually garbage collected (via a Cleaner mechanism), which can be less predictable than heap allocation.
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 Four Pointer Fields
The Buffer abstract class holds four integer fields that track the internal state of every buffer. Understanding these four fields 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 four fields are: capacity, limit, position, and mark.
Visualize your buffer as an array of bytes laid out in a row:
Index: 0 1 2 3 4 5
Data: [ ] [ ] [ ] [ ] [ ] [ ]Now imagine four invisible markers hovering over this array. Those markers are the four 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 initiallyMark
Mark is an optional saved position. You can call buf.mark() at any moment and the current position is saved. Later, when you call buf.reset(), position jumps back to the saved mark. This is useful when you want to read a section again that you already consumed.
Mark starts as undefined (represented internally as -1). If you call reset() without having called mark() first, you get an InvalidMarkException.
The Invariant: 0 <= mark <= position <= limit <= capacity
This relationship between the four fields must always hold. No NIO method will ever violate it. If you try to set a field in a way that would break this ordering, you get an exception.
Written plainly: mark is always the smallest, capacity is always the largest, and position and limit sit between them. When the mark is undefined it is treated as 0 for the purpose of this ordering rule.
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. Mark is discarded (set back to undefined).
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.
Interview question: Why do we call flip() before reading from a buffer?
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 - Mark is discarded
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.
Interview question: Does clear() delete the data in the buffer?
No. clear() only resets the position and limit pointers. The underlying bytes remain exactly as they were. The next write operations will overwrite them.
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 - Mark is discarded
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.
Interview question: What is the difference between clear() and compact()?
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 completely bypass the position, limit, and mark fields. They do not read them, they do not write them, they do not increment them. You are working directly with raw memory indices.
A critical consequence: flip() is not required with absolute methods. Since position and limit are not involved in the operation, 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
// Peek at the message type without consuming position
int messageType = buf.getInt(0); // absolute, position unchanged
if (messageType != EXPECTED_TYPE) {
throw new IllegalArgumentException("Unknown message type");
}
// Now consume the buffer sequentially
buf.flip(); // needed now because we want to use relative reads
int type = buf.getInt(); // consumes type from position 0
int length = buf.getInt(); // consumes length from position 4
// ... etcInterview question: What is the difference between relative and absolute get/put in ByteBuffer?
Relative methods (get(), put(), getInt(), putInt() without an index argument) operate at the current position and advance position afterward. They respect limit as a boundary.
Absolute methods (get(index), put(index, value), getInt(index), putInt(index, value) with an explicit index) bypass the position, limit, and mark fields entirely. They access a specific byte index directly. Position does not change. No flip() is required. They are useful for random access patterns and for peeking at data without consuming it.
Handling Primitive Types Beyond Byte
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 (Approach One, 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 interpretationintBuffer 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. Approach two (calling putInt() directly on ByteBuffer) is strongly preferred because the ByteBuffer position stays correct automatically.
Interview question: Why is using typed view buffers (like IntBuffer) considered risky with channels?
Because the view buffer and the backing ByteBuffer maintain separate position pointers. Writes through the view buffer advance the view's position but leave ByteBuffer's position unchanged. Since channels only accept ByteBuffer, and since flip() uses ByteBuffer's position to set the limit, a forgotten synchronization step produces a ByteBuffer with limit = 0, causing the channel to write nothing to the OS. Using ByteBuffer.putInt() directly keeps everything in one object and avoids this synchronization burden entirely.
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.
The mark() and reset() Pair
Mark gives you a save point within the buffer:
java
buf.get(); // read byte at 0, position = 1
buf.get(); // read byte at 1, position = 2
buf.mark(); // save position = 2
buf.get(); // read byte at 2, position = 3
buf.get(); // read byte at 3, position = 4
buf.reset(); // restore position = 2
buf.get(); // read byte at 2 again, position = 3Mark is useful when you need to tentatively process some bytes and potentially back up if something goes wrong.
The invariant 0 <= mark <= position means that whenever you move position backward (via reset() or by calling rewind() which resets position to 0 without using mark), mark must be valid relative to that new position. If a method would cause position to move before mark, mark is automatically invalidated (set back to undefined internally).
rewind(): Back to the Start Without Changing Limit
rewind() sets position to 0 and discards mark, 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.
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
}
}Interview Questions and Answers
Q: What are the four pointer fields of a ByteBuffer and what does each one do?
A: The four fields are capacity, limit, position, and mark. Capacity is the total size of the buffer in bytes and never changes after creation. Limit is a boundary; no read or write can occur at or beyond the limit. Position is the cursor marking where the next read or write will happen; it advances after every operation. Mark is an optional saved position you set by calling mark() and restore by calling reset().
Q: What is the invariant that these four fields must always satisfy?
A: 0 <= mark <= position <= limit <= capacity. Mark is always less than or equal to position. Position is always less than or equal to limit. Limit is always less than or equal to capacity. NIO enforces this at every operation.
Q: What does flip() do internally, step by step?
A: First it sets limit = position. Then it sets position = 0. Capacity stays the same. Mark is discarded. The result is that the buffer switches from write mode (where position tracks the write cursor) to read mode (where position starts at 0 and limit defines the boundary of valid data). Any subsequent read starts at index 0 and cannot go past the last byte that was written.
Q: What is the difference between clear() and compact()?
A: clear() resets position to 0 and limit to capacity, discarding any awareness of unread data (though the bytes remain physically in the buffer). Use it when you are fully done with the current data and want to reuse the buffer for a completely fresh write. compact() copies any unread bytes to the front of the buffer, sets position right after them, and sets limit to capacity. Use it when you have partially read a buffer and want to append more data without losing the unread portion.
Q: What happens if you forget to call flip() before passing a ByteBuffer to a FileChannel for writing?
A: The channel will read starting from the current position. If you just finished writing and position is at, say, 8, the channel will see that position equals 8 and limit equals 16. It will try to read bytes 8 through 15, which are uninitialized. It will write garbage to the file, or if position happens to equal limit, nothing at all. The data you actually wrote at indices 0 through 7 will be silently ignored.
Q: What is the difference between relative and absolute get/put methods?
A: Relative methods operate at the current position and advance position after each call. They respect limit as a hard boundary. Absolute methods take an explicit byte index, bypass position entirely, and do not advance it. Limit does not apply to absolute methods either. Absolute methods require no flip before reading, making them useful for random access and for peeking at data without consuming it.
Q: Why does the channel only accept ByteBuffer and not IntBuffer or LongBuffer?
A: The OS works with raw bytes. Channels operate at the OS level and must deal in raw byte sequences. IntBuffer, LongBuffer, and other typed views are a higher level interpretation layer built on top of ByteBuffer. They share the same underlying memory but have their own separate position and limit pointers. Channels cannot track multiple pointer sets; they need a single byte level source of truth, which only ByteBuffer provides.
Q: When you create an IntBuffer view from a ByteBuffer using asIntBuffer(), do they share the same memory?
A: Yes. The underlying byte array is shared. Writing through the IntBuffer modifies the same bytes that the ByteBuffer owns. However, they have completely independent position, limit, and capacity values. The IntBuffer's capacity is the ByteBuffer's capacity divided by 4 (since each int is 4 bytes). This independence of pointer state is the source of the subtle bug where writing through IntBuffer advances IntBuffer's position but leaves ByteBuffer's position at 0, causing a subsequent flip on ByteBuffer to produce limit = 0.
Q: What is the practical use case for absolute (indexed) get methods?
A: Peeking at values before committing to sequential consumption. Since absolute get does not advance position, you can inspect any byte range in the buffer at any time without affecting the sequential read flow. A common pattern is to peek at a packet header to validate the message type, then if valid, use relative get methods to consume the packet byte by byte in order.
Q: What is HeapByteBuffer and how does it differ from DirectByteBuffer?
A: HeapByteBuffer is created with ByteBuffer.allocate(). The backing byte array lives on the JVM heap, managed by the garbage collector. It has fast allocation but requires an extra copy when the channel sends data to the OS (because the OS cannot directly access JVM heap memory; it first copies bytes to native memory). DirectByteBuffer is created with ByteBuffer.allocateDirect(). The backing memory lives outside the JVM heap in native memory. There is no extra copy when talking to the OS, making it faster for high throughput I/O. However, allocation is slower and the native memory is not freed by the GC directly but via a finalizer/Cleaner, so lifecycle management requires care.
Q: What does buf.position() return immediately after ByteBuffer.allocate()?
A: Zero. A freshly allocated buffer has position = 0, limit = capacity, and the buffer is in write mode ready to accept data from the very first byte.
Summary
The foundation of Java NIO is understanding that you are responsible for managing the buffer. The four pointer fields, capacity, limit, position, and mark, tell the buffer exactly where it is in its lifecycle at every moment. The invariant 0 <= mark <= position <= limit <= capacity holds always.
Writing mode has position climbing from 0 toward limit. When you are done writing, call flip() to move limit down to position and reset position to 0, switching to read mode. When you are done reading and want to reuse the buffer, call clear() to reset everything, or call compact() if you have unread data you want to preserve.
Use relative methods (get(), put(), getInt(), putInt() etc.) for normal sequential access. Use absolute methods (get(index), put(index, value) etc.) for random access or peeking without consuming.
Always call flip() before passing a ByteBuffer to a channel. Always use putInt(), putLong(), and the other typed methods directly on ByteBuffer rather than creating view buffers, to keep pointer management simple and correct.
Once you internalize the four pointers and the mode switch, everything else in NIO, channels, selectors, memory mapped files, follows naturally.