Raju Gupta created LANG-1839:
--------------------------------

             Summary: ToStringStyle.VISITED ThreadLocal grows without bound 
when a ReflectionToStringBuilder is created but toString() is never called 
(memory leak since 3.21.0)
                 Key: LANG-1839
                 URL: https://issues.apache.org/jira/browse/LANG-1839
             Project: Commons Lang
          Issue Type: Bug
          Components: lang.builder.*
    Affects Versions: 3.21.0, 3.22.0
         Environment: Apache Commons Lang 3.21.0 (also reproduced on master 
3.22.0-SNAPSHOT, commit 61837eb), OpenJDK 17, macOS
            Reporter: Raju Gupta


Since 3.21.0, \{{ToStringStyle.VISITED}} (a ThreadLocal \{{IdentityHashMap}}) 
can grow without bound and cause an \{{OutOfMemoryError}}. It happens when a 
\{{ReflectionToStringBuilder}} is constructed but \{{toString()}} is never 
called on it. Reported by Oli Gillespie on the user list ("Memory leak in 
ReflectionToStringBuilder/ToStringStyle"). I reproduced it on current master 
(3.22.0-SNAPSHOT).

h3. Cause

* Constructing a \{{ToStringBuilder}} calls \{{style.appendStart(...)}}, which 
calls \{{register(object)}}. The root object is added to the thread-local 
\{{REGISTRY}} in the constructor.
* The root is only removed by \{{unregister(object)}}, called from 
\{{appendEnd}} (i.e. \{{toString()}}). A builder that is never stringified 
leaves its root in \{{REGISTRY}} for the life of the thread.
* \{{VISITED}} is only cleared in \{{unregister()}}, and only when 
\{{REGISTRY.isEmpty()}}.
* \{{RecursiveToStringStyle.appendInternal}} calls \{{markVisited(value)}} for 
every object it renders.

Once one orphaned builder exists on a thread, \{{REGISTRY}} is never empty 
again, so \{{VISITED}} is never cleared. Every later \{{toString()}} on that 
thread adds its whole object graph to \{{VISITED}}, and none of it is released.

The stale \{{REGISTRY}} entry was already possible before 3.21.0, but it cost 
one small entry per thread. \{{VISITED}} ties the cleanup of a large per-call 
set to that set being empty, which turns the orphan into unbounded growth.

h3. Real-world trigger

{code:java}
log.debug("...", new ReflectionToStringBuilder(...));
{code}

The builder is created eagerly. If debug logging is disabled, \{{toString()}} 
is never called.

h3. Reproducer

{code:java}
import org.apache.commons.lang3.builder.*;
import java.lang.reflect.Field;
import java.util.*;

public class VisitedLeak {
    static class Graph \{ List<Object> items = new ArrayList<>() {{ for (int i 
= 0; i < 999; i++) add(new Object()); }}; }

    static int size(String field) throws Exception {
        Field f = ToStringStyle.class.getDeclaredField(field);
        f.setAccessible(true);
        return ((Map<?, ?>) ((ThreadLocal<?>) f.get(null)).get()).size();
    }

    public static void main(String[] args) throws Exception {
        if (args.length == 0) new ReflectionToStringBuilder(new Graph(), new 
RecursiveToStringStyle()); // start leak
        for (int i = 0; i <= 1000; i++) {
            if (i % 100 == 0) System.out.printf("Invocation %d: 
REGISTRY.size=%d VISITED.size=%d%n", i, size("REGISTRY"), size("VISITED"));
            new ReflectionToStringBuilder(new Graph(), new 
RecursiveToStringStyle()).toString();
        }
    }
}
{code}

Run without arguments (leaks) and with \{{clean}} (control, no orphan builder).

h3. Actual result

* Leak run: \{{Invocation 1000: REGISTRY.size=1 VISITED.size=1000000}}
* Control run: \{{REGISTRY.size=0 VISITED.size=0}}
* With \{{-Xmx64m}} the leak run fails with \{{java.lang.OutOfMemoryError: Java 
heap space}} in under 2,000 iterations.

h3. Expected result

{\{VISITED}} is empty after every top-level \{{toString()}} completes, 
regardless of builders that were created but never stringified.

h3. Suggested fix

Clear \{{VISITED}} when the outermost \{{ReflectionToStringBuilder.toString()}} 
completes (in a \{{finally}}), instead of relying on \{{REGISTRY}} becoming 
empty. A nested call always starts after the nested value was marked visited, 
so an empty \{{VISITED}} on entry identifies the top-level call and no new 
state is needed. I have a patch and a regression test and can open a GitHub PR.

h3. Workaround

Don't construct the builder unless \{{toString()}} will be called, e.g. guard 
with \{{if (log.isDebugEnabled())}} or pass a lazy supplier.



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to