Skip to content

Memory leak (High-Water Mark) due to unbounded StringBuilder reuse in Message.java ThreadLocal #1137

@QiuYucheng2003

Description

@QiuYucheng2003

Describe the bug
I identified a High-Water Mark memory leak in quickfix.Message.

The class uses a private static final ThreadLocal stringContexts to cache a StringBuilder for message string generation. In the toString() method, the buffer is reset using stringBuilder.setLength(0) in the finally block.

The Issue: StringBuilder.setLength(0) resets the logical character count but does not release the underlying internal char[] array capacity. If a thread processes a single exceptionally large message (e.g., 10MB), the StringBuilder attached to that thread will expand to 10MB and never shrink back, effectively holding that memory indefinitely even when processing subsequent small messages.

Source Code Location:
// quickfix/Message.java

// 1. ThreadLocal Definition
private static final ThreadLocal stringContexts = new ThreadLocal() { ... };

// 2. Usage in toString()
public String toString() {
Context context = stringContexts.get();
StringBuilder stringBuilder = context.stringBuilder;
try {
// ... append large data ...
return stringBuilder.toString();
} finally {
// ISSUE: Only resets count, keeps the expanded capacity forever
stringBuilder.setLength(0);
}
}

To Reproduce

  1. Initialize a thread pool (e.g., Fix Application threads).

  2. Construct a quickfix.Message with a large payload (e.g., 20MB body).

  3. Call message.toString() on a thread. (The ThreadLocal buffer expands to ~20MB).

  4. Discard the message and let the thread process small heartbeat messages.

  5. Observation: Inspect the Heap Dump. The thread's ThreadLocalMap still holds a StringBuilder with a ~20MB char[] backing array. Multiplied by the number of threads, this causes significant heap waste.

Expected behavior
The cached StringBuilder should manage its capacity. Ideally, in the finally block, check if the stringBuilder.capacity() exceeds a reasonable threshold (e.g., 8KB or 64KB). If it does, either call trimToSize() or replace it with a new, smaller instance to release the excess memory.

system information:
OS: All

Java version: All

QFJ Version: (Based on analysis of provided Message.java)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions