Back to Advanced Topics
Q8Question 8 of 41

What is off-heap memory, when do you use it, and what are the risks?

Key Concepts
  • Off-heap memory is allocated outside the GC heap, directly from the operating system's native address space.
  • ByteBuffer.allocateDirect is the classic API; the Foreign Function and Memory API (Java 22) with Arena is the modern, safer approach.
  • Direct buffers give zero-copy I/O because the kernel can read straight into non-moving off-heap memory without an intermediate copy.
  • Off-heap memory is not automatically reclaimed by GC, so you must manage its lifetime explicitly or risk native leaks.
  • Use off-heap for large caches that would wreck GC times, high-throughput network I/O, IPC, and native library integration.
  • The Foreign Function and Memory API uses Arena for deterministic deallocation and bounds-checked MemorySegment for safety.

Definition

Off-heap memory lives outside the GC heap for large data, fast I/O, and native interop, but you own its lifetime, so you must free it explicitly or risk native leaks and crashes.
  • Off-heap memory – memory allocated outside the Java garbage-collected heap, directly from the OS native address space.
  • ByteBuffer.allocateDirect – the classic API; returns a small on-heap wrapper pointing to off-heap bytes.
  • Foreign Function and Memory API (Java 22) – the modern API using Arena and MemorySegment for deterministic, bounds-checked off-heap access.
  • Zero-copy I/O – direct buffers are already off-heap and non-moving, so the kernel reads straight into them without an intermediate copy.
  • Risks – native memory leaks, use-after-free crashes, more expensive allocation than heap, and manual serialization to bytes.

The Java heap is like the bank's main hall, swept constantly by cleaners (the garbage collector) who remove anything nobody is using. Off-heap memory is like renting a private warehouse outside the bank. You can store a huge archive of old statements there without the cleaners ever touching it, which keeps the main hall fast and uncluttered. But the cleaners never go into your warehouse, so when you are done you must personally lock it up and hand back the keys. If you forget, you keep paying rent forever (a native leak); if you tear it down while a clerk is still inside reading a file, that clerk gets hurt (a crash).

Understanding the Concepts

What is off-heap memory?

The Java heap is where normal objects live, and the GC automatically frees them. Off-heap memory sidesteps the collector by allocating raw memory from the OS. This avoids GC pressure but requires manual lifetime management.

JAVA
package com.bank.memory;

import java.nio.ByteBuffer;

public class DirectBufferDemo {
    public static void main(String[] args) {
        // Classic: allocate 1 MB off-heap
        ByteBuffer direct = ByteBuffer.allocateDirect(1024 * 1024);

        // Write data
        direct.putLong(0, 150_000_00L); // Rajesh's balance in paise
        direct.putLong(8, 250_000_00L); // Priya's balance in paise

        // Read data
        System.out.println("Rajesh: " + direct.getLong(0) / 100.0);
        System.out.println("Priya: " + direct.getLong(8) / 100.0);

        // The bytes live OFF the heap.
        // The small wrapper object lives ON the heap.
        // When the wrapper is GC'd, a Cleaner frees the native bytes.
    }
}
TEXT
Rajesh: 150000.0
Priya: 250000.0

Why is direct memory faster for I/O?

When you read from a socket or file into a heap buffer, the JVM often has to copy the data through a temporary off-heap staging area because native I/O cannot safely target heap memory that the GC might move. A direct buffer is already off-heap and non-moving, so the kernel reads straight into it with no intermediate copy.

JAVA
package com.bank.memory;

import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class ZeroCopyDemo {
    public static void main(String[] args) throws Exception {
        // Heap buffer: kernel → staging area → heap (2 copies)
        ByteBuffer heapBuf = ByteBuffer.allocate(4096);

        // Direct buffer: kernel → direct buffer (1 copy = zero-copy)
        ByteBuffer directBuf = ByteBuffer.allocateDirect(4096);

        try (RandomAccessFile file = new RandomAccessFile(
                "/tmp/transactions.dat", "r");
             FileChannel channel = file.getChannel()) {

            // Direct buffer avoids the intermediate copy
            int bytesRead = channel.read(directBuf);
            System.out.println("Read " + bytesRead + " bytes (zero-copy)");
        }
    }
}
DIAGRAM
Heap buffer I/O:    kernel → temp off-heap → heap array  (2 copies)
Direct buffer I/O:  kernel → direct buffer              (1 copy)

What is the Foreign Function and Memory API (Java 22)?

The modern, recommended approach uses Arena and MemorySegment for deterministic deallocation and bounds checking.

JAVA
package com.bank.memory;

import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.math.BigDecimal;

public class ForeignMemoryDemo {
    public static void main(String[] args) {
        // Arena: deterministic lifetime, freed on close
        try (Arena arena = Arena.ofConfined()) {
            // Allocate 1024 bytes off-heap
            MemorySegment segment = arena.allocate(1024);

            // Write Rajesh's balance (as long, in paise)
            segment.set(ValueLayout.JAVA_LONG, 0, 150_000_00L);

            // Read back
            long balancePaise = segment.get(ValueLayout.JAVA_LONG, 0);
            BigDecimal balance = BigDecimal.valueOf(balancePaise, 2);
            System.out.println("Rajesh's balance: " + balance);

            // Bounds-checked: this throws IndexOutOfBoundsException
            // segment.get(ValueLayout.JAVA_LONG, 2000); // out of range
        }
        // Arena closed: all segments freed deterministically
        System.out.println("Off-heap memory freed");
    }
}
TEXT
Rajesh's balance: 150000.00
Off-heap memory freed

When should you use off-heap memory?

Four situations justify going off-heap.

| Use Case | Why Off-Heap | Example |
|---|---|---|
| Large caches | Gigabytes on heap wreck GC times | Apache Cassandra, Kafka |
| High-throughput I/O | Zero-copy reads and writes | Netty, NIO file channels |
| Inter-process communication | Shared memory between processes | Memory-mapped files |
| Native library integration | Pass buffers to native code | Foreign Function API |

JAVA
package com.bank.memory;

import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;

public class OffHeapCache {
    // Large price cache: off-heap to avoid GC pressure
    private final Map<String, ByteBuffer> priceCache = new HashMap<>();

    public void put(String symbol, double price) {
        ByteBuffer buf = ByteBuffer.allocateDirect(8);
        buf.putDouble(0, price);
        priceCache.put(symbol, buf);
    }

    public double get(String symbol) {
        ByteBuffer buf = priceCache.get(symbol);
        return buf != null ? buf.getDouble(0) : Double.NaN;
    }

    public static void main(String[] args) {
        OffHeapCache cache = new OffHeapCache();
        cache.put("RELIANCE", 2455.50);
        System.out.println("RELIANCE: " + cache.get("RELIANCE"));
    }
}
TEXT
RELIANCE: 2455.5

What are the risks of off-heap memory?

The risks are the core of a senior answer.

  • Native leak – forgotten buffers are not reclaimed by GC; diagnose with NMT and async-profiler, not heap dumps.
  • Non-deterministic cleanup – with classic direct buffers, freeing is tied to GC of the wrapper, which is non-deterministic.
  • Use-after-free – with old Unsafe, accessing freed memory crashes the JVM; the Foreign API throws an exception instead.
  • Allocation cost – native allocation is more expensive than a fast heap allocation, so pool buffers instead of churning them.
  • Manual serialization – off-heap data must be serialized to bytes; you cannot store rich Java objects directly.
JAVA
package com.bank.memory;

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

public class DirectBufferRisk {
    public static void main(String[] args) {
        List<ByteBuffer> buffers = new ArrayList<>();

        // WRONG: allocating in a tight loop without releasing
        // Can exhaust native memory before GC frees wrappers
        for (int i = 0; i < 100_000; i++) {
            buffers.add(ByteBuffer.allocateDirect(1024 * 1024)); // 1 MB
        }
        // OutOfMemoryError: Direct buffer memory
    }
}
JAVA
// CORRECT: pool and reuse direct buffers
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Deque;

public class BufferPool {
    private final Deque<ByteBuffer> pool = new ArrayDeque<>();
    private final int bufferSize;

    public BufferPool(int bufferSize, int poolSize) {
        this.bufferSize = bufferSize;
        for (int i = 0; i < poolSize; i++) {
            pool.offer(ByteBuffer.allocateDirect(bufferSize));
        }
    }

    public ByteBuffer acquire() {
        ByteBuffer buf = pool.poll();
        if (buf == null) {
            buf = ByteBuffer.allocateDirect(bufferSize);
        }
        buf.clear();
        return buf;
    }

    public void release(ByteBuffer buf) {
        pool.offer(buf);
    }
}

How does Arena compare to ByteBuffer.allocateDirect?

| Feature | ByteBuffer.allocateDirect | Arena + MemorySegment |
|---|---|---|
| Deallocation | Non-deterministic (GC + Cleaner) | Deterministic (try-with-resources) |
| Bounds checking | No (buffer overrun possible) | Yes (throws exception) |
| Thread safety | Not specified | Confined or shared arenas |
| Memory layout | Raw bytes | Typed layouts (JAVA_INT, JAVA_LONG) |
| Native interop | Limited | Full FFI support |

JAVA
// ByteBuffer: non-deterministic, no bounds checking
ByteBuffer buf = ByteBuffer.allocateDirect(100);
buf.putLong(96, 42L); // silent overrun! writes past end

// Arena: deterministic, bounds-checked
try (Arena arena = Arena.ofConfined()) {
    MemorySegment seg = arena.allocate(100);
    seg.set(ValueLayout.JAVA_LONG, 96, 42L); // IndexOutOfBoundsException!
}

When should you NOT use off-heap memory?

Stay on the heap by default. Go off-heap only when profiling shows GC pressure from large data, or when zero-copy I/O or native interop genuinely requires it. Regular Java objects with references are not suitable for off-heap storage.

What should a senior engineer know?

  • Cap direct memory with -XX:MaxDirectMemorySize so leaks throw clear errors.
  • Pool direct buffers with Netty's PooledByteBufAllocator or a custom pool to avoid allocation churn.
  • The Foreign Function API replaces the old sun.misc.Unsafe for off-heap access with a safe, supported API.
  • Monitor the BufferPool MBean for direct memory usage in production.
JAVA
package com.bank.memory;

import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;

public class DirectMemoryMonitor {
    public static void main(String[] args) {
        for (BufferPoolMXBean pool :
                ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
            System.out.println(pool.getName()
                    + ": count=" + pool.getCount()
                    + ", used=" + (pool.getMemoryUsed() / 1024) + "KB");
        }
    }
}
TEXT
direct: count=5, used=5120KB
mapped: count=0, used=0KB

Visual Overview

DIAGRAM
  On-Heap vs Off-Heap:
  ┌─────────────────────────────────────┐
  │          JVM Process                 │
  │                                      │
  │  ┌──────────────────┐  GC manages   │
  │  │   Java Heap       │  these        │
  │  │   (objects, arrays,│  objects     │
  │  │    BigDecimal,     │             │
  │  │    Account)        │             │
  │  └──────────────────┘              │
  │                                      │
  │  ┌──────────────────┐  YOU manage   │
  │  │   Off-Heap        │  this memory │
  │  │   (direct buffers, │             │
  │  │    Arena segments, │             │
  │  │    Netty pooled)   │             │
  │  └──────────────────┘              │
  └─────────────────────────────────────┘

  I/O Path Comparison:
  Heap buffer:   kernel ──▶ temp off-heap ──▶ heap array  (2 copies)
  Direct buffer: kernel ──▶ direct buffer               (1 copy)

  Foreign Function API Lifecycle:
  ┌──────────────────────────────────┐
  │ try (Arena arena = ...) {         │
  │     MemorySegment seg = allocate  │
  │     seg.set(...)                  │
  │     seg.get(...)                  │
  │ }  ← arena.close() frees all     │
  └──────────────────────────────────┘

Key Takeaway

Off-heap memory is allocated outside the garbage-collected Java heap, straight from the operating system. The classic way is ByteBuffer.allocateDirect, and the modern, recommended way from Java 22 is the Foreign Function and Memory API with Arena and MemorySegment. You reach for off-heap in four situations: large caches where keeping gigabytes on the heap would wreck GC pauses, high-throughput I/O where direct buffers give zero-copy transfers, inter-process communication and memory-mapped files, and passing buffers to native code.

The risks are the heart of the matter. Off-heap memory is not reclaimed by ordinary garbage collection, so a forgotten buffer is a native leak diagnosed with NMT and async-profiler, not a heap dump. With classic direct buffers, freeing is tied to non-deterministic GC of the wrapper, so allocating them in a tight loop can exhaust native memory and throw OutOfMemoryError for Direct buffer memory. The Foreign Function API fixes most of this: Arena wraps in try-with-resources so closing it frees every segment deterministically, and segments are bounds-checked so an out-of-range access throws an exception instead of corrupting memory.

At National Bank of India the market-data service kept a large price buffer off-heap because on the heap it caused long GC pauses that delayed trade pricing. For high-throughput settlement writes, direct buffers let the kernel send straight from off-heap without the extra copy. The guiding principle is to stay on the heap by default and go off-heap only when profiling proves GC pressure from large data or when zero-copy I/O genuinely requires it.

Remember one sentence: off-heap memory lives outside the GC heap for large data, fast I/O, and native interop, but you own its lifetime, so free it explicitly with Arena or pool and release direct buffers to avoid native leaks.

---

Share
Use ← → arrow keys to navigate