10 Management of Netty Off Heap Memory in a Double Edged Sword Rationality

10 Management of Netty off-heap memory in a double-edged sword rationality #

In this lesson, we will enter the course of Netty memory management. Before that, we need to understand the basic knowledge of Java off-heap memory, because when you use Netty, you need to deal with off-heap memory all the time. We often see various cases of off-heap memory leaks. Improper use of off-heap memory can increase the probability of application errors and crashes. Therefore, when using off-heap memory, you must be cautious. In this lesson, I will introduce you to off-heap memory and explore how to use it better.

Why do we need off-heap memory #

In Java, objects are allocated in the heap. Usually, when we talk about JVM memory, we mean heap memory managed by the JVM virtual machine itself. The JVM has its own garbage collection algorithm, so users don’t have to care about how objects are recycled.

Off-heap memory corresponds to heap memory. For the entire machine memory, the part other than the heap memory is called off-heap memory, as shown in the following figure. Off-heap memory is not managed by the JVM virtual machine but directly by the operating system.

Image 1.png

Both heap memory and off-heap memory have their advantages and disadvantages. Here, I will explain the important points.

  1. Heap memory is automatically reclaimed by the JVM GC, reducing the mental burden on Java users. However, GC has a time cost, and off-heap memory is not managed by the JVM, so it can reduce the impact of GC on application runtime to some extent.
  2. Off-heap memory needs to be manually released, which is similar to C/C++. A little carelessness can cause application memory leaks. When memory leaks occur, troubleshooting can be relatively difficult.
  3. When performing network I/O operations or file reading and writing, heap memory needs to be converted to off-heap memory before interacting with the underlying device. This is also mentioned in the introduction of the working principle of writeAndFlush. Therefore, using off-heap memory directly can reduce one memory copy.
  4. Off-heap memory can be used for data sharing between processes and multiple instances of the JVM.

From this, it can be seen that if you want to achieve efficient I/O operations, cache commonly used objects, and reduce JVM GC pressure, off-heap memory is a very good choice.

Allocation of off-heap memory #

There are two ways to allocate off-heap memory in Java: ByteBuffer#allocateDirect and Unsafe#allocateMemory.

First, let’s introduce the allocation method of the ByteBuffer class in the Java NIO package. The usage is as follows:

// Allocate 10MB of off-heap memory
ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);

Following the source code of ByteBuffer.allocateDirect, it is found that it directly calls the DirectByteBuffer constructor:

DirectByteBuffer(int cap) {
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);
    long base = 0;
    try {
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

The following figure describes the memory references of DirectByteBuffer to help you better understand the initialization process of the above source code. The DirectByteBuffer object stored in the heap is not large, with only the address and size properties of the off-heap memory. At the same time, it will create the corresponding Cleaner object. The off-heap memory allocated by ByteBuffer does not need to be manually reclaimed; it can be automatically reclaimed by the JVM. When the DirectByteBuffer object in the heap is GCed, the Cleaner will be used to reclaim the corresponding off-heap memory.

Image 2.png

From the constructor of DirectByteBuffer, it can be seen that the logic of actually allocating off-heap memory is through unsafe.allocateMemory(size). Next, let’s get to know the mysterious Unsafe utility class together.

Unsafe is a very unsafe class. It is used to perform sensitive operations such as memory access, allocation, modification, etc. that bypass the locks imposed by the JVM. Unsafe was not originally designed for developers. While using it allows you to gain control over underlying resources, it also loses the guarantee of security. Therefore, when using Unsafe, you must be cautious. Netty relies on the Unsafe utility class because Netty needs to interact with the underlying socket, and Unsafe helps improve Netty’s performance to some extent. In Java, you cannot directly use Unsafe. However, you can obtain an instance of Unsafe through reflection, as shown in the following example.

private static Unsafe unsafe = null;

static {

    try {

        Field getUnsafe = Unsafe.class.getDeclaredField("theUnsafe");

        getUnsafe.setAccessible(true);

        unsafe = (Unsafe) getUnsafe.get(null);

    } catch (NoSuchFieldException | IllegalAccessException e) {

        e.printStackTrace();

    }

}

After obtaining an instance of Unsafe, you can allocate off-heap memory using the allocateMemory method, which returns the memory address. Here is an example of how to use it:

// Allocate 10M off-heap memory
long address = unsafe.allocateMemory(10 * 1024 * 1024);

Unlike DirectByteBuffer, the memory allocated by Unsafe#allocateMemory must be manually freed, otherwise it can cause memory leaks. This is the unsafe aspect of Unsafe. Unsafe also provides a method to release memory:

unsafe.freeMemory(address);

So far, we have learned two ways to allocate off-heap memory. For Java developers, the commonly used method is ByteBuffer.allocateDirect. The memory allocated using this method is related to the issue of off-heap memory leaks. Next, let’s see how off-heap memory allocated by ByteBuffer is reclaimed by the JVM. This is very helpful for debugging off-heap memory leak issues.

Reclaiming Off-Heap Memory #

Let’s consider a scenario where DirectByteBuffer objects may exist in heap memory for a long time, so they may be promoted to the JVM’s old generation. Therefore, the cleanup of DirectByteBuffer objects relies on Old GC or Full GC to trigger the cleanup. If Old GC or Full GC is not executed for a long time, the off-heap memory will continue to occupy memory without release, which can easily lead to depletion of the machine’s physical memory, which is quite dangerous.

So, how can we prevent physical memory depletion when using DirectByteBuffer? Because the JVM is not aware whether off-heap memory is running low, it is best to specify the upper limit of off-heap memory using the JVM parameter -XX:MaxDirectMemorySize. When the size of off-heap memory exceeds this threshold, a Full GC will be triggered for cleanup. If, after Full GC, there is still not enough memory for off-heap memory allocation, the program will throw an OOM exception.

In addition, during the ByteBuffer.allocateDirect allocation process, if there is not enough space to allocate off-heap memory, the Bits.reserveMemory method will proactively call System.gc() to force a Full GC. However, in a production environment, the -XX:+DisableExplicitGC option is usually set, rendering System.gc() ineffective. Therefore, relying on System.gc() is not a good solution.

Through the previous introduction of off-heap memory allocation methods, we know that DirectByteBuffer creates a Cleaner object during initialization, which is responsible for the cleanup of off-heap memory. But how is Cleaner associated with GC?

There are four types of object references in Java: strong reference (StrongReference), soft reference (SoftReference), weak reference (WeakReference), and phantom reference (PhantomReference). Cleaner is a subclass of PhantomReference. Cleaner cannot be used independently and needs to be used in conjunction with a reference queue (ReferenceQueue), as shown in the following source code:

public class Cleaner extends java.lang.ref.PhantomReference<java.lang.Object> {

    private static final java.lang.ref.ReferenceQueue<java.lang.Object> dummyQueue;

    private static sun.misc.Cleaner first;

    private sun.misc.Cleaner next;

    private sun.misc.Cleaner prev;

    private final java.lang.Runnable thunk;

    public void clean() {}

}

First, let’s take a look at the object references in memory when initializing off-heap memory. first is a static variable in the Cleaner class. Cleaner objects are added to the Cleaner linked list during initialization. The DirectByteBuffer object contains references to the address, size, and Cleaner object of the off-heap memory, while the ReferenceQueue is used to store the Cleaner objects that need to be reclaimed.

Image 3

When a GC occurs, the DirectByteBuffer object is reclaimed, and the object references in memory change as follows:

Image 4

Now, the Cleaner object no longer has any references. In the next GC, this Cleaner object will be added to the ReferenceQueue and the clean() method will be called. The clean() method mainly does two things:

  1. Remove the Cleaner object from the Cleaner linked list.
  2. Call the unsafe.freeMemory method to clean up the off-heap memory.

At this point, the reclamation of off-heap memory is complete. The next time you investigate a memory leak problem, remember to review this basic knowledge in order to have a clear understanding.

Conclusion #

Off-heap memory is a double-edged sword. It is more convenient and efficient to use off-heap memory in areas such as network I/O, file I/O, and distributed caching. Furthermore, off-heap memory is not constrained by the JVM and can avoid the pressure of JVM GC, reducing the impact on business applications. However, there is no such thing as a free lunch, and off-heap memory should not be abused. When using off-heap memory, you need to pay attention to memory reclamation issues. Although the JVM helps us to automatically reclaim off-heap memory to some extent, we still need to develop an awareness similar to memory allocation/deallocation in C/C++, so that we know how to analyze and deal with memory leaks when they occur.