Learn Java Io Modern Io Resource Boundaries Part 014 Direct Buffers Offheap Memory
title: Learn Java IO, Modern IO, Streams, Buffers, Resources, Serialization & Data Boundaries - Part 014 description: Direct ByteBuffer and off-heap memory in Java IO: heap vs direct buffers, native memory pressure, allocation cost, pooling, lifetime, GC interaction, diagnostics, and production usage patterns. series: learn-java-io-modern-io-resource-boundaries seriesTitle: Learn Java IO, Modern IO, Streams, Buffers, Resources, Serialization & Data Boundaries order: 14 partTitle: Direct Buffers & Off-Heap IO Memory tags:
- java
- io
- nio
- bytebuffer
- direct-buffer
- off-heap
- native-memory
- performance
- series date: 2026-06-30
Part 014 — Direct Buffers & Off-Heap IO Memory
Target: setelah part ini, kita bisa memutuskan kapan memakai
ByteBuffer.allocateDirect(), kapan tetap memakai heap buffer, dan bagaimana menghindari native memory incident yang tidak terlihat dari heap chart.
Part 013 membahas state machine ByteBuffer. Part ini membahas dimensi lain: di mana bytes disimpan.
Java NIO menyediakan dua kategori besar ByteBuffer:
ByteBuffer heap = ByteBuffer.allocate(8192);
ByteBuffer direct = ByteBuffer.allocateDirect(8192);
Keduanya punya position, limit, capacity, flip(), clear(), compact(), slice(), dan seterusnya. Perbedaannya bukan di API cursor, melainkan di backing memory dan interaction dengan native IO.
1. Kaufman Skill Deconstruction
Skill “direct buffer mastery” bisa dipecah menjadi:
- Membedakan heap buffer dan direct buffer.
- Memahami kenapa direct buffer bisa mengurangi copy pada native IO path.
- Memahami kenapa direct buffer allocation lebih mahal.
- Menghitung native memory budget, bukan hanya heap budget.
- Menghindari per-request direct allocation.
- Memahami lifetime direct buffer dan GC interaction.
- Mengenali
OutOfMemoryError: Direct buffer memory. - Menggunakan pooling secara disiplin.
- Mendiagnosis native memory via MXBean/JFR/NMT.
- Mendesain ownership boundary untuk buffer reuse.
Core mental model:
Direct buffer adalah resource-like memory object. Ia bukan file descriptor, tetapi di production ia harus diperlakukan dengan disiplin lifecycle yang mirip resource.
2. Heap Buffer vs Direct Buffer
2.1 Heap Buffer
ByteBuffer buffer = ByteBuffer.allocate(64 * 1024);
Heap buffer:
- Backed by Java heap memory.
- Bisa punya accessible backing array jika tidak read-only.
- Cepat dialokasikan relatif terhadap direct buffer.
- Dikelola normal oleh GC heap.
- Cocok untuk general application data manipulation.
- Bisa memerlukan copy tambahan ketika digunakan untuk native IO.
2.2 Direct Buffer
ByteBuffer buffer = ByteBuffer.allocateDirect(64 * 1024);
Direct buffer:
- Backed by native memory di luar Java heap.
- Designed for efficient native IO interaction.
- Allocation/deallocation lebih mahal.
- Tidak memiliki ordinary Java backing array.
- Dibatasi oleh native memory budget, bukan hanya
-Xmx. - Lifetime deallocation bergantung pada reachability/cleaning mechanism JVM.
Comparison:
| Dimension | Heap Buffer | Direct Buffer |
|---|---|---|
| Storage | Java heap | Native/off-heap memory |
| Allocation cost | Usually lower | Usually higher |
| GC heap pressure | Yes | Object reference on heap, bytes off-heap |
| Native IO path | May require intermediate copy | Can be used more directly by native IO |
array() support | Often yes | No |
| Best for | App parsing, short-lived buffers, small data | Large/long-lived IO buffers, channels, high-throughput paths |
| Main risk | Heap GC pressure | Native memory pressure invisible to heap-only monitoring |
3. Why Direct Buffers Exist
OS IO APIs work with native memory. If Java data sits in movable managed heap, JVM/native boundary may need extra work.
Simplified path with heap buffer:
Simplified path with direct buffer:
This is the intuition, not a universal guarantee. Actual behavior depends on JVM, OS, channel implementation, file/socket/device, and call path.
Practical interpretation:
Direct buffers are most defensible when bytes frequently cross native IO boundaries and the buffer is large or long-lived enough to amortize allocation cost.
4. Oracle's Practical Guidance in Plain Engineering Terms
The Java API documentation warns that direct buffers should generally be allocated primarily for large, long-lived buffers subject to native IO, and only when they provide measurable performance gain.
Engineering translation:
- Do not allocate direct buffers casually.
- Do not allocate direct buffers per small request.
- Do not assume direct is always faster.
- Benchmark the actual IO path.
- Monitor native memory, not only heap.
- Prefer reuse/pooling if direct buffers are hot-path objects.
5. Allocation Cost and Lifetime
Bad:
void handleRequest(ReadableByteChannel source, WritableByteChannel sink) throws IOException {
ByteBuffer buffer = ByteBuffer.allocateDirect(8 * 1024);
source.read(buffer);
buffer.flip();
sink.write(buffer);
}
If this runs per request at high QPS, it creates pressure on native allocation and deallocation.
Better:
final class ConnectionState {
private final ByteBuffer readBuffer = ByteBuffer.allocateDirect(64 * 1024);
private final ByteBuffer writeBuffer = ByteBuffer.allocateDirect(64 * 1024);
}
Or use a bounded pool:
final class DirectBufferPool {
private final ArrayBlockingQueue<ByteBuffer> buffers;
DirectBufferPool(int poolSize, int bufferSize) {
this.buffers = new ArrayBlockingQueue<>(poolSize);
for (int i = 0; i < poolSize; i++) {
buffers.add(ByteBuffer.allocateDirect(bufferSize));
}
}
BorrowedBuffer borrow() throws InterruptedException {
ByteBuffer buffer = buffers.take();
buffer.clear();
return new BorrowedBuffer(buffer, this::release);
}
private void release(ByteBuffer buffer) {
buffer.clear();
buffers.offer(buffer);
}
record BorrowedBuffer(ByteBuffer buffer, Consumer<ByteBuffer> release) implements AutoCloseable {
@Override
public void close() {
release.accept(buffer);
}
}
}
Usage:
try (DirectBufferPool.BorrowedBuffer borrowed = pool.borrow()) {
ByteBuffer buffer = borrowed.buffer();
// use buffer without retaining reference after close
}
This is intentionally similar to resource lifecycle design from Part 004.
6. Native Memory Budgeting
A heap-only memory budget is incomplete.
Example service:
-Xmx = 2 GiB
connections = 20_000
read direct buffer = 64 KiB
write direct buffer = 64 KiB
Direct buffer budget:
20_000 * (64 KiB + 64 KiB) = 2.5 GiB
That exceeds heap size and may exceed container memory limit even if heap chart looks healthy.
Budget model:
process_memory ≈ heap
+ metaspace
+ code cache
+ thread stacks
+ direct buffers
+ memory mapped files
+ JNI/native libraries
+ allocator fragmentation
+ JVM overhead
Container incident pattern:
- Heap usage looks stable.
- RSS keeps increasing.
- Container is OOM-killed.
- Application logs do not show Java heap OOME.
- Root cause is native/off-heap growth.
Rule:
Any production design using direct buffers needs process memory budget, not only heap budget.
7. MaxDirectMemorySize
HotSpot has a max direct memory setting commonly configured as:
-XX:MaxDirectMemorySize=<size>
This can limit total memory used by direct buffer allocations. However, do not treat it as the whole native memory budget. Other native memory categories still exist.
Production guidance:
- Set heap with container limit in mind.
- Account for direct memory explicitly.
- Leave room for metaspace, thread stacks, JIT, mmap, and native libraries.
- Prefer bounded direct buffer pools.
- Alert on process RSS and direct buffer pool metrics.
Example rough budget:
container limit: 4096 MiB
heap (-Xmx): 2048 MiB
max direct memory: 768 MiB
metaspace/code/etc: 512 MiB
thread stacks: 256 MiB
safety margin: 512 MiB
The exact numbers depend on workload and runtime.
8. hasArray() and array() Trap
Heap buffer often exposes backing array:
ByteBuffer heap = ByteBuffer.allocate(16);
if (heap.hasArray()) {
byte[] array = heap.array();
}
Direct buffer usually does not:
ByteBuffer direct = ByteBuffer.allocateDirect(16);
direct.array(); // UnsupportedOperationException
Bad generic code:
byte[] bytes = buffer.array(); // BUG for direct or read-only buffers
Better:
static byte[] copyRemaining(ByteBuffer source) {
ByteBuffer view = source.duplicate();
byte[] result = new byte[view.remaining()];
view.get(result);
return result;
}
This works for heap, direct, read-only, slice, and mapped buffers.
9. Direct Buffer and GC Interaction
Direct buffer object itself lives on heap. Its byte storage lives outside heap.
Simplified:
Implications:
- Direct memory release is tied to object reachability and JVM cleaning mechanism.
- You cannot rely on immediate release at exact lexical scope exit.
- Many short-lived direct buffers can create delayed native memory pressure.
- Holding references accidentally holds native memory.
- Slices/duplicates can keep the underlying storage reachable.
Important:
ByteBufferdoes not implementCloseable. Try-with-resources cannot directly release a direct buffer.
That is why bounded pooling and stable ownership are important.
10. Slices and Duplicates Keep Storage Alive
Consider:
ByteBuffer large = ByteBuffer.allocateDirect(128 * 1024 * 1024);
large.position(0).limit(16);
ByteBuffer tinySlice = large.slice();
cache.put("header", tinySlice);
large = null;
The tiny slice can keep the large direct memory region alive.
This is the off-heap version of substring-retaining-large-array style bugs.
Rule:
Do not store tiny views into large direct buffers in long-lived structures. Copy the small data.
Safer:
static ByteBuffer copyToSmallHeapBuffer(ByteBuffer source) {
ByteBuffer view = source.duplicate();
ByteBuffer copy = ByteBuffer.allocate(view.remaining());
copy.put(view);
copy.flip();
return copy.asReadOnlyBuffer();
}
11. Pooling Direct Buffers: Useful but Dangerous
Pooling helps when:
- Allocation cost is significant.
- Buffers are large.
- Buffers are used repeatedly on hot IO path.
- Pool size is bounded.
- Ownership is clear.
Pooling hurts when:
- Pool is unbounded.
- Borrowed buffers escape.
- Buffers are returned while still in use.
- Sensitive data is reused without clearing when required.
- Pool fragmentation creates too many size classes.
- Pool hides memory from simple observability.
Correct pool properties:
| Property | Reason |
|---|---|
| Bounded capacity | Prevent native memory explosion |
| Fixed or limited size classes | Avoid fragmentation complexity |
| Borrow/release discipline | Prevent ownership ambiguity |
| Clear state on borrow/release | Avoid cursor contamination |
| Leak detection in tests | Catch forgotten release |
| No long-lived slices | Prevent hidden retention |
12. Minimal Borrowed Buffer Abstraction
public final class BorrowedByteBuffer implements AutoCloseable {
private final ByteBuffer buffer;
private final Consumer<ByteBuffer> releaser;
private boolean closed;
BorrowedByteBuffer(ByteBuffer buffer, Consumer<ByteBuffer> releaser) {
this.buffer = Objects.requireNonNull(buffer);
this.releaser = Objects.requireNonNull(releaser);
}
public ByteBuffer buffer() {
if (closed) {
throw new IllegalStateException("buffer already released");
}
return buffer;
}
@Override
public void close() {
if (!closed) {
closed = true;
buffer.clear();
releaser.accept(buffer);
}
}
}
This does not make underlying ByteBuffer closeable. It makes borrowed ownership closeable.
Usage:
try (BorrowedByteBuffer borrowed = pool.borrow()) {
ByteBuffer buffer = borrowed.buffer();
// read/write
}
Do not return buffer from the method. Do not store slices after close().
13. Data Leakage and Buffer Reuse
If buffers contain sensitive data, clear() is not enough. It only resets metadata.
buffer.clear(); // does not zero old bytes
For sensitive bytes:
static void zero(ByteBuffer buffer) {
ByteBuffer view = buffer.duplicate();
view.clear();
while (view.hasRemaining()) {
view.put((byte) 0);
}
buffer.clear();
}
Caveat:
- Compiler/JVM optimizations and copies may complicate hard guarantees.
- For crypto-grade memory handling, use specialized security guidance.
- This series only covers IO boundary implications, not full cryptographic memory hygiene.
Operationally, if buffers may contain credentials, tokens, PII, or payment data, make reuse policy explicit.
14. Direct Buffers and File IO
For large file copy via channel:
static long copyWithDirectBuffer(Path source, Path target) throws IOException {
try (FileChannel in = FileChannel.open(source, StandardOpenOption.READ);
FileChannel out = FileChannel.open(target,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
long total = 0;
while (true) {
int n = in.read(buffer);
if (n == -1) {
break;
}
if (n == 0) {
continue;
}
total += n;
buffer.flip();
while (buffer.hasRemaining()) {
out.write(buffer);
}
buffer.clear();
}
return total;
}
}
This may or may not beat:
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
or:
FileChannel.transferTo(...)
The best primitive depends on file size, OS, filesystem, storage, and JVM implementation. Part 016 covers zero-copy transfer.
15. Direct Buffers and Socket IO
For high-throughput network servers, direct buffers are common because sockets cross native boundaries frequently.
Connection-level design:
final class NioConnection {
private final SocketChannel channel;
private final ByteBuffer inbound;
private final Deque<ByteBuffer> outboundQueue = new ArrayDeque<>();
NioConnection(SocketChannel channel, int bufferSize) {
this.channel = channel;
this.inbound = ByteBuffer.allocateDirect(bufferSize);
}
}
Risks at scale:
connections * per-connection-direct-buffer-size = native memory floor
If 100k mostly idle connections each hold 128 KiB direct buffer, you have designed a memory incident.
Alternative strategies:
- Smaller per-connection buffers.
- Shared bounded pool for active IO only.
- Adaptive buffer sizing.
- Heap buffers for low-throughput paths.
- Backpressure earlier at accept/session layer.
16. Direct Buffer Size Selection
There is no universal best buffer size.
Factors:
- Syscall overhead.
- Storage/network throughput.
- Page size and cache behavior.
- Number of concurrent operations.
- Max latency per operation.
- Memory budget.
- Allocation/pooling strategy.
Heuristic starting points:
| Use Case | Starting Size | Notes |
|---|---|---|
| Small command protocol | 4–16 KiB | Often heap is enough |
| File streaming | 64 KiB–1 MiB | Benchmark actual storage path |
| TLS/application framing | 16–64 KiB | Consider framework buffer strategy |
| Large sequential transfer | 256 KiB–1 MiB | Compare with transferTo/Files.copy |
| Many idle connections | Small or pooled | Avoid per-connection memory floor |
The correct size is the smallest size that meets throughput/latency targets under realistic concurrency.
17. Diagnostics: Know Where Memory Went
17.1 BufferPoolMXBean
Java exposes buffer pool metrics through management APIs.
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
for (BufferPoolMXBean pool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
System.out.printf(
"%s count=%d used=%d capacity=%d%n",
pool.getName(),
pool.getCount(),
pool.getMemoryUsed(),
pool.getTotalCapacity()
);
}
Typical pools include direct and mapped. Use these as application/runtime telemetry.
17.2 Native Memory Tracking
HotSpot Native Memory Tracking can help categorize native memory, but it must be enabled and has overhead.
Typical commands:
jcmd <pid> VM.native_memory summary
jcmd <pid> VM.native_memory detail
Runtime flags commonly used:
-XX:NativeMemoryTracking=summary
-XX:NativeMemoryTracking=detail
Use in staging/performance testing before enabling in sensitive production paths.
17.3 JFR
Java Flight Recorder can help correlate allocation, IO, GC, and memory behavior. For direct buffer incidents, combine:
- Process RSS.
- Heap usage.
- Direct buffer pool metrics.
- Mapped buffer metrics.
- Thread count and stack size.
- Container OOM events.
- Allocation profiles.
18. Failure Modes
18.1 OutOfMemoryError: Direct buffer memory
Typical causes:
- Too many direct buffers.
- Per-request direct allocation spike.
- Slices/duplicates retaining large regions.
- Pool not bounded.
- Pool leak.
- Container memory too small for heap + native.
MaxDirectMemorySizetoo low for intended workload.
Response:
- Capture heap dump only if it helps find references to buffer objects.
- Check direct buffer pool MXBean.
- Check RSS vs heap.
- Use NMT if enabled.
- Find allocation sites.
- Bound/pool/reduce buffer sizes.
- Review retention of slices and queues.
18.2 Container OOM Kill Without Java OOME
Cause:
Process exceeds cgroup/container memory limit due to total RSS, not Java heap only.
Response:
- Reduce
-Xmxor native memory usage. - Bound direct buffer memory.
- Reduce thread stacks or thread count if relevant.
- Account for mmap.
- Add RSS alerts.
18.3 Latency Spikes from Direct Allocation
Cause:
Direct allocation/deallocation on hot path.
Response:
- Preallocate.
- Pool.
- Use heap buffer if benchmark shows no benefit.
- Reduce allocation frequency.
18.4 Data Corruption from Reuse
Cause:
Buffer returned to pool while slice/view still in use.
Response:
- Copy before async handoff.
- Forbid retaining borrowed buffers.
- Add debug guards in pool wrapper.
- Use ownership-aware types.
19. Direct Buffer API Boundary Design
Bad:
ByteBuffer borrowBuffer();
This says nothing about:
- Who returns it?
- Can it be retained?
- Is it direct?
- Is it cleared?
- Can slices escape?
- Is it thread-confined?
Better:
interface BufferPool {
BorrowedByteBuffer borrow() throws InterruptedException;
}
Document:
/**
* Borrows a direct buffer in write mode.
* The caller must close the returned handle exactly once.
* The ByteBuffer and all derived views must not be retained after close.
* The buffer is thread-confined to the borrower.
*/
BorrowedByteBuffer borrow();
The API should make lifecycle visible.
20. Heap vs Direct Decision Matrix
Use heap buffer when:
- Data is small.
- Buffer is short-lived.
- Main work is application parsing/transformation.
- You need
byte[]frequently. - You are not on hot native IO path.
- Simplicity matters more than theoretical IO gain.
Use direct buffer when:
- Buffer is large or long-lived.
- Buffer is repeatedly used with channels/native IO.
- Benchmark shows measurable improvement.
- Native memory budget is explicit.
- Allocation is amortized through reuse.
- Ownership/lifetime is controlled.
Avoid direct buffer when:
- Per-request allocation is high.
- You cannot monitor native memory.
- You pass buffers through many unknown layers.
- You store small slices long-term.
- You are memory constrained and concurrency is high.
- Performance claim is not measured.
21. Direct Buffer and MemorySegment
Modern Java also has the Foreign Function & Memory API under java.lang.foreign, including MemorySegment and Arena. This gives more explicit lifetime management for foreign/off-heap memory than direct ByteBuffer in some use cases.
However, do not mix abstractions casually:
| Need | Usually Start With |
|---|---|
| Java channel IO buffer | ByteBuffer |
| Native interop / structured foreign memory | MemorySegment |
| Memory-mapped file with modern API | Part 018 topic |
| Legacy NIO API compatibility | ByteBuffer |
This part focuses on direct ByteBuffer. Part 018 will cover modern foreign memory and mapped segments in more detail.
22. Benchmarking Direct Buffers Correctly
Bad benchmark:
for (int i = 0; i < iterations; i++) {
ByteBuffer buffer = ByteBuffer.allocateDirect(8192);
// measure read/write once
}
This measures allocation more than IO.
Better benchmark dimensions:
- Preallocated heap vs preallocated direct.
- Different buffer sizes.
- Same file/socket/storage path.
- Realistic concurrency.
- Warmup.
- Allocation rate.
- Tail latency, not only average throughput.
- RSS/native memory impact.
Hypotheses to test:
- Direct buffer improves channel throughput for large sequential IO.
- Direct buffer hurts latency if allocated per operation.
- Larger buffer improves throughput only until saturation.
- Heap buffer is equal or better for small application parsing.
transferTo/Files.copybeats manual copy for large files.
23. Example: Bounded File Chunk Reader
Goal: read file chunks using a reusable direct buffer but return stable heap chunks to downstream.
final class FileChunkReader implements Closeable {
private final FileChannel channel;
private final ByteBuffer ioBuffer;
FileChunkReader(Path path, int bufferSize) throws IOException {
this.channel = FileChannel.open(path, StandardOpenOption.READ);
this.ioBuffer = ByteBuffer.allocateDirect(bufferSize);
}
Optional<ByteBuffer> nextChunk() throws IOException {
ioBuffer.clear();
int n = channel.read(ioBuffer);
if (n == -1) {
return Optional.empty();
}
ioBuffer.flip();
ByteBuffer stable = ByteBuffer.allocate(ioBuffer.remaining());
stable.put(ioBuffer);
stable.flip();
return Optional.of(stable.asReadOnlyBuffer());
}
@Override
public void close() throws IOException {
channel.close();
}
}
Why copy to heap?
Because the direct buffer is reused internally. Returning a slice would expose unstable mutable storage.
Alternative: process chunk synchronously via callback and do not return it.
void forEachChunk(Consumer<ByteBuffer> consumer) throws IOException {
while (true) {
ioBuffer.clear();
int n = channel.read(ioBuffer);
if (n == -1) {
return;
}
ioBuffer.flip();
consumer.accept(ioBuffer.asReadOnlyBuffer());
}
}
Contract: consumer must not retain the buffer after callback returns.
24. Example: Pool Leak Detection for Tests
final class DebugBufferPool {
private final Set<BorrowedByteBuffer> outstanding = ConcurrentHashMap.newKeySet();
private final ArrayBlockingQueue<ByteBuffer> free;
DebugBufferPool(int count, int size) {
this.free = new ArrayBlockingQueue<>(count);
for (int i = 0; i < count; i++) {
free.add(ByteBuffer.allocateDirect(size));
}
}
BorrowedByteBuffer borrow() throws InterruptedException {
ByteBuffer buffer = free.take();
BorrowedByteBuffer borrowed = new BorrowedByteBuffer(buffer, b -> {
free.offer(b);
});
outstanding.add(borrowed);
return borrowed;
}
void assertNoLeaks() {
if (!outstanding.isEmpty()) {
throw new AssertionError("borrowed buffers not returned: " + outstanding.size());
}
}
}
Real implementation should remove from outstanding on close. This sketch shows the intent: make leaks test-visible.
25. Operational Checklist
For every service using direct buffers:
- What is max number of direct buffers?
- What is max direct buffer capacity total?
- Are buffers allocated per request or reused?
- Is direct memory bounded by configuration/pool?
- Is
MaxDirectMemorySizeexplicitly considered? - Is process RSS monitored?
- Are direct/mapped buffer pool metrics exported?
- Are tiny slices of large buffers retained anywhere?
- Can borrowed buffers cross async/thread boundaries?
- Is there a clear policy for sensitive data reuse?
- Are buffer sizes benchmarked under realistic concurrency?
- Is fallback to heap acceptable if direct memory is constrained?
- Do tests detect pool leaks?
- Are direct buffers actually improving the measured bottleneck?
- Does container memory leave room beyond heap?
26. Code Review Comments You Should Be Able to Make
Per-request Direct Allocation
This allocates a direct buffer per request. Direct allocation is expensive and can create native memory pressure. Use heap buffer unless benchmarked, or borrow from a bounded pool.
Hidden Heap Assumption
This calls
buffer.array(), which fails for direct/read-only buffers. Use duplicate + copy if the method must support genericByteBuffer.
Escaping Borrowed Buffer
This returns a slice of a pooled direct buffer. The caller may observe corrupted data after the buffer is reused. Copy or change the API to process inside the borrow scope.
Missing Memory Budget
This adds per-connection direct buffers. Please include
connections * bufferSizein the native memory budget and container sizing.
Unmeasured Optimization
Direct buffer is not automatically faster. We need a benchmark for this path or use heap buffer for simpler lifecycle.
27. Exercises
Exercise 1 — Budget Calculation
A service has:
container memory: 6 GiB
-Xmx: 3 GiB
connections: 30_000
inbound direct buffer: 32 KiB
outbound direct buffer: 32 KiB
threads: 300
thread stack: 1 MiB
Calculate direct buffer memory:
30_000 * 64 KiB = 1,920,000 KiB ≈ 1.83 GiB
Thread stacks:
300 MiB
Subtotal:
heap 3 GiB + direct 1.83 GiB + stacks 0.29 GiB = 5.12 GiB
Before metaspace, code cache, mapped files, native libraries, allocator overhead, and safety margin, this is already risky.
Exercise 2 — Fix array() Bug
Bug:
byte[] body = buffer.array();
Fix:
ByteBuffer view = buffer.duplicate();
byte[] body = new byte[view.remaining()];
view.get(body);
Exercise 3 — Prevent Slice Retention
Given a 64 MiB direct receive buffer, the parser returns a 32-byte header slice and stores it in a cache.
Fix options:
- Copy header to small heap array.
- Parse header into value object.
- Do not retain buffer-derived views.
Best:
record Header(int version, int flags, long correlationId) {}
Convert bytes to semantic value, not long-lived byte view.
28. Mental Compression
Direct buffer rule of thumb:
Heap buffer = default for application data
Direct buffer = measured optimization for native IO paths
Production invariant:
Direct buffers consume native memory.
Native memory must be budgeted, bounded, monitored, and owned.
Lifecycle invariant:
A pooled direct buffer must not escape its borrow scope.
Aliasing invariant:
slice/duplicate share storage and can keep large direct memory alive.
29. References
- Oracle Java SE 25 API —
java.nio.ByteBuffer - Oracle Java SE 25 API —
java.nio.Buffer - Oracle Java SE 25 API —
java.lang.management.BufferPoolMXBean - Oracle Java SE 25 API —
java.lang.foreign.MemorySegment - HotSpot Native Memory Tracking documentation
30. Part Summary
Kita sudah membahas direct buffers sebagai off-heap IO memory. Hal penting:
- Direct buffer berbeda dari heap buffer pada backing memory, bukan cursor API.
- Direct buffer bisa membantu native IO path, tetapi tidak otomatis lebih cepat.
- Allocation direct buffer relatif mahal; hindari per-request direct allocation.
- Native memory pressure bisa terjadi walaupun heap terlihat sehat.
- Slices dan duplicates bisa menahan large direct memory.
- Pooling hanya aman jika bounded dan ownership jelas.
array()bukan genericByteBufferAPI.- Monitoring harus mencakup direct buffer metrics dan process RSS.
- Direct buffer harus dipilih berdasarkan benchmark dan budget.
Part berikutnya membahas Channels: FileChannel, SeekableByteChannel, positional IO, scattering/gathering, locking, dan transfer APIs.
You just completed lesson 14 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.