← all posts

Contributing to Netty: Why Check the Direct Memory Limit?

Netty is a Java framework for building network servers and clients. It abstracts common work such as accepting connections, reading data, and sending responses, so application developers can focus on what to do with incoming data.

Java heap contains the DirectByteBuffer object, pointing to off-heap byte data

Direct memory is allocated outside the heap, where ordinary Java objects live. It can help reduce intermediate copying when reading and writing network data.

A networking framework and memory that holds data: the connection seems natural. But reading Netty's code takes you a little deeper.

Netty doesn't just use memory. It wants to determine the direct memory limit available in the current runtime, even reading JVM launch options when necessary.

Why should a networking framework inspect memory settings?

That question led me to a PR changing the lookup logic. While reviewing it, I found an edge case where a string inside a system property could be mistaken for a memory option. The bug was in a string-checking condition. To understand why that condition mattered, I first needed to understand where network data lives.

Handling connections means managing buffers

A server receives a request, reads data, processes it, and sends a response. Application code may express this in a few lines, but data doesn't move in one indivisible operation.

For a TCP connection, one client request isn't guaranteed to arrive in one server read. If only part arrives, it must be retained until the rest comes in. Responses are similar: creating data doesn't mean it can all be written to a socket immediately. Unwritten data has to stay somewhere. Netty uses ByteBuf to manage it.

A buffer temporarily holds data. In a server, though, it's more than scratch space. Receiving, application processing, and sending happen at different rates. Buffers hold data while the system bridges those differences.

From this perspective, networking and memory management are hard to separate. More connections and more data awaiting processing or transmission make buffer management increasingly important.

Netty's ByteBuf supports both heap and direct memory as backing storage. Behind the same byte-reading and byte-writing interface, implementations can put the actual data in different places.

Why use direct memory instead of the familiar, simple heap?

Not faster memory, but a path with less copying

The familiar way to store bytes in Java is a byte[]: allocate an array on the heap and fill it. But passing those bytes to operating-system I/O can require extra work.

A heap buffer is copied into a temporary direct buffer before an OS write
For example, JDK NIO has a path that copies a heap buffer into a temporary direct buffer before invoking the OS write function. If the data is already in a direct buffer, that intermediate copy may be avoided.

Direct buffers can reduce an extra copy into a temporary buffer before transmission. But allocation and deallocation can cost more than with heap buffers. Creating and discarding a new buffer for each request can spend the savings right back on memory management.

That's why Netty provides buffer pools that reuse memory after a task finishes. Rather than acquiring fresh space every time, it reuses allocated space. Reference counts track when that space can be returned, because memory still used by another task must not be overwritten.

But reuse reduces allocation frequency; it doesn't eliminate the memory required. More concurrent data needs more space, and pooled space retained for later work still occupies memory.

Even with buffer reuse, the total permitted memory usage needs a separate limit. That's why Netty checks direct memory limits rather than simply allocating memory.

Choosing where memory lives also requires a budget

Off-heap memory is still a resource consumed by the process. Leaving the heap doesn't remove capacity constraints.

HotSpot offers an option to set the allocation limit for NIO direct buffers.

-XX:MaxDirectMemorySize=64m

Here, 64m means 64 × 1024 × 1024 bytes. This is not a limit on all off-heap memory in a Java process. It applies to NIO direct-buffer allocations, not every other form of native-memory allocation.

Netty also has paths that track direct-memory usage and enforce their own limits. Depending on configuration, they use the limit discovered from the JVM. Inspecting JVM memory settings isn't just gathering information for logs; it's establishing the budget for memory Netty manages.

This is where PlatformDependent.estimateMaxDirectMemory() comes in.

The word estimate matters. This isn't a real-time measurement of free OS memory. It tries internal lookup paths, then parses JVM arguments under the relevant conditions if those fail. If no valid value is found, it falls back to maximum heap size. It accounts for environments where the exact limit can't be determined.

The PR I was reading reorganized that lookup process.

The author explained that some allocation modes paid a startup cost to discover a JVM direct-buffer limit that wasn't relevant to them. The change introduced a setting to skip argument lookup and a RuntimeJvmArgs class separating argument parsing.

The issue I found was how that new class identified memory options.

Is a string that looks like an option actually an option?

The proposal scanned JVM arguments from the end and used indexOf to find the following substring in each argument.

"-XX:MaxDirectMemorySize="

If found, it parsed the trailing number and unit and returned the value.
For an actual argument like -XX:MaxDirectMemorySize=64m, that works naturally: extract 64m and convert it to a size.

But the list being read isn't a list containing only memory options.

It contains all JVM arguments returned by ManagementFactory.getRuntimeMXBean().getInputArguments(), including system properties supplied with -D.

System properties pass configuration values to a program. For example, the following argument stores a string under the name note.

-Dnote=-XX:MaxDirectMemorySize=64m

That argument doesn't set a memory limit. Its property value happens to resemble a memory option, but indexOf can't distinguish the two.

It can find -XX:MaxDirectMemorySize= in the middle of the argument and interpret the following 64m as the actual limit. This was the counterexample I provided in the review.

Numeric conversion doesn't catch it either: 64m is a valid size notation. Reading the value from the wrong place still produces a perfectly valid number.

We need to distinguish whether a value has a valid format from whether that value may be interpreted as this setting. The proposal handled the first well but didn't sufficiently check the second.

With only genuine memory options, both implementations return the same result. The difference appears with another valid but unusual input that isn't a memory option.

I don't see this as merely defending against a strange string. The input is valid as a property. The memory-settings parser is what gives it the wrong meaning.

The JVM setting stays unchanged; Netty's interpretation does not

The false match can also matter when a real memory option is present. Consider launching the JVM as follows.

java -XX:MaxDirectMemorySize=256m \
     -Dnote=-XX:MaxDirectMemorySize=64m \
     -jar app.jar

The real memory option is 256m. But in this order, the proposed reverse scan can encounter the note property first and return 64m, stopping before it reaches the actual option.

The JVM's actual setting doesn't change to 64m. What changes is the limit Netty believes it may use.

This doesn't happen in every runtime. A valid value from an earlier internal lookup avoids argument parsing altogether. The problem is specific to entering this parsing path with an option-shaped string inside another argument.

Netty PR review identifying a false MaxDirectMemorySize match and suggesting startsWith

I suggested checking whether an argument starts with the option, rather than searching for the string anywhere inside it.

if (!arg.startsWith(MAX_DIRECT_MEMORY_SIZE_ARG)) {
    continue;
}

The PR author adopted the suggestion and changed the check to startsWith. The PR containing the fix was subsequently merged for a Netty release.

The behavior to preserve was clear: adding a property unrelated to memory must not change how the memory limit is interpreted.

From buffers to launch options

Netty checks direct memory because beneath networking APIs it must establish how real resources are used. Where data lives, the cost of allocation and reuse, and the budget within which memory is managed are all connected to networking.

This issue sat in a small part of that chain. Determining a memory limit required reading launch arguments, which required interpreting strings. The condition was small, but its result wasn't just a string-processing result: it could become a memory-management value.

Correctness here isn't only about computing a valid value. It's also about accepting the right inputs as configuration and leaving unrelated inputs alone. Changing an implementation must preserve not only return values, but also the meaning of the inputs that produce them.

Before converting 64m to a number, we had to ask whether that 64m was actually a memory setting. That's why one line of string checking mattered to Netty's memory management.