Skip to content
19 changes: 13 additions & 6 deletions core/src/main/java/org/jruby/Ruby.java
Original file line number Diff line number Diff line change
Expand Up @@ -3369,11 +3369,12 @@ private void systemTeardown(final ThreadContext context) {
}
}

// Shut down and replace thread service after all other hooks and finalizers have run
threadService.teardown();
threadService = new ThreadService(this);

// Release classloader resources
// Release classloader resources before tearing down the thread service.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is rejected, except for reordering the teardown sequence. LoadService does indeed call getCurrentContext, but it can simply be moved above the ThreadService teardown above. The order of the ThreadService and classloader termination is unimportant, because neither of them depends upon the other.

See #9359

// These steps must run while the current thread still has a valid ThreadContext in the
// thread service, because some of them (e.g. RubyArray.clear via loadedFeatures.clear)
// call getCurrentContext(). If they ran after the thread service is replaced they would
// trigger a spurious thread adoption on the fresh service, re-creating SoftRef/RubyThread
// entries that keep (ThreadContext → Ruby) alive after teardown. (GH-9092)
releaseClassLoader();

// Tear down LoadService
Expand All @@ -3384,7 +3385,13 @@ private void systemTeardown(final ThreadContext context) {
allModules.clear();
constantNameInvalidators.clear();
symbolTable.clear();
javaSupport = loadJavaSupport();
if (javaSupport != null) javaSupport.tearDown();
javaSupport = null;

// Shut down and replace thread service after all other hooks, finalizers, and cleanup
// steps that touch the thread service have run.
threadService.teardown();
threadService = new ThreadService(this);
Comment thread
headius marked this conversation as resolved.
}

private int userTeardown(ThreadContext context) {
Expand Down
6 changes: 6 additions & 0 deletions core/src/main/java/org/jruby/embed/internal/LocalContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ public void remove() {
if (varMap != null) {
synchronized(this) { varMap.clear(); }
}
// Allow the Ruby runtime to be GC-eligible immediately after terminate().
Comment thread
headius marked this conversation as resolved.
// Without this, the strong reference chain
// ScriptingContainer -> SingleThreadLocalContextProvider -> LocalContext -> Ruby
// keeps the runtime (and its JRubyClassLoader) alive until this LocalContext
// is itself collected, which may be delayed by ScriptingContainer.finalize().
runtime = null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is accepted. It may not be necessary, but it's reasonable to clear a reference to the runtime. I'm adding a termination flag to ensure the context is not able to be re-vivified.

See #9359.

}

Ruby getRuntime() {
Expand Down
31 changes: 31 additions & 0 deletions core/src/main/java/org/jruby/internal/runtime/ThreadService.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@
package org.jruby.internal.runtime;

import java.lang.ref.SoftReference;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import java.util.WeakHashMap;
Expand Down Expand Up @@ -131,6 +133,12 @@ public class ThreadService extends ThreadLocal<SoftReference<ThreadContext>> {

private final AtomicLong threadCount = new AtomicLong(0);

/**
* All SoftReferences ever stored into this ThreadLocal (one per thread that has entered Ruby
* space on this runtime). Tracked so that teardown() can clear their referents even on
* threads we cannot schedule work on (adopted / pool threads).
*/
private final List<SoftReference<ThreadContext>> trackedRefs = new CopyOnWriteArrayList<>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is rejected.

  • Despite the Copilot analysis below, the reference chain from the ThreadLocal (ThreadService) to the runtime has at least one more weak link: the ThreadLocalMap's Entry. When the ThreadService is cleared at teardown, the ThreadLocal no longer has any GC roots, and will be collected. This will in turn clear out the SoftReference to the ThreadContext and the rest of the graph.
  • Accumulating SoftReference here introduces a new leak because as threads terminate during runtime there's nothing to clean their references out of this list.

public ThreadService(final Ruby runtime) {
this.runtime = runtime;

Expand Down Expand Up @@ -167,6 +175,29 @@ public void teardown() {

// clear thread map
rubyThreadMap.clear();

// Clear every SoftReference<ThreadContext> that was ever stored into this ThreadLocal.
Comment thread
headius marked this conversation as resolved.
// This covers both the current thread and any adopted / pool threads that called into
// JRuby but are now idle and will never touch their ThreadLocalMap again. Without this,
// each such thread's ThreadLocalMap retains the chain:
// Thread (GC root) -> ThreadLocalMap -> SoftReference -> ThreadContext -> Ruby runtime
// which keeps the runtime (and its JRubyClassLoader with all loaded classes) alive until
// GC heap pressure forces soft-reference collection — which may never happen in a
// well-resourced long-lived process. (see https://github.com/jruby/jruby/issues/9092)
for (SoftReference<ThreadContext> ref : trackedRefs) {
ref.clear();
}
trackedRefs.clear();

// Also call remove() on the current thread so the (now-cleared) SoftReference object
// is expelled from this thread's ThreadLocalMap immediately rather than lazily.
remove();
}

@Override
public void set(SoftReference<ThreadContext> value) {
super.set(value);
if (value != null) trackedRefs.add(value);
}

public void initMainThread() {
Expand Down
32 changes: 22 additions & 10 deletions core/src/main/java/org/jruby/javasupport/JavaSupport.java
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,28 @@ public JavaSupport(final Ruby runtime) {

this.javaClassCache = ClassValue.newInstance(klass -> new JavaClass(runtime, getJavaClassClass(), klass));

this.proxyClassCache = ClassValue.newInstance(this::computeProxyClass);
this.proxyClassCache = ClassValue.newInstance(new ProxyComputer(runtime)::computeProxyClass);

// Proxy creation is synchronized (see above) so a HashMap is fine for recursion detection.
this.unfinishedProxies = new ConcurrentHashMap<>(8, 0.75f, 1);
}

/**
* Because of the complexity of processing a given class and all its dependencies,
* we opt to synchronize this logic. Creation of all proxies goes through here,
* allowing us to skip some threading work downstream.
*/
private synchronized RubyModule computeProxyClass(Class<?> klass) {
RubyModule proxyKlass = Java.createProxyClassForClass(runtime, klass);
JavaExtensions.define(runtime, klass, proxyKlass); // (lazy) load extensions
return proxyKlass;
private static class ProxyComputer {
private final Ruby runtime;

ProxyComputer(Ruby runtime) {
this.runtime = runtime;
}
/**
* Because of the complexity of processing a given class and all its dependencies,
* we opt to synchronize this logic. Creation of all proxies goes through here,
* allowing us to skip some threading work downstream.
*/
private synchronized RubyModule computeProxyClass(Class<?> klass) {
RubyModule proxyKlass = Java.createProxyClassForClass(runtime, klass);
JavaExtensions.define(runtime, klass, proxyKlass); // (lazy) load extensions
return proxyKlass;
}
}

@Deprecated(since = "9.4.0.0")
Expand Down Expand Up @@ -395,6 +402,11 @@ final RubyModule getUnfinishedProxy(Class clazz) {
return null;
}

public void tearDown() {
unfinishedProxies.clear();
objectProxyCache.clear();
}

RubyModule getProxyClassFromCache(Class clazz) {
return proxyClassCache.get(clazz);
}
Expand Down
9 changes: 9 additions & 0 deletions core/src/main/java/org/jruby/javasupport/JavaSupportImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ public static JavaProxyClass saveJavaProxyClass(final Ruby runtime, ProxyClassKe
return ((JavaSupportImpl) runtime.getJavaSupport()).saveJavaProxyClass(classKey, klass);
}

@Override
public void tearDown() {
super.tearDown();

synchronized (javaProxyClasses) {
javaProxyClasses.clear();
}
}

/**
* <p>Note: Internal API - subject to change!</p>
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ public int size() {
return size;
}

public void clear() {
for (Segment<T,A> seg : segments) {
seg.clear();
}
}

public String stats() {
StringBuilder b = new StringBuilder();
int n = 0;
Expand Down Expand Up @@ -310,6 +316,16 @@ static class Segment<T,A> extends ReentrantLock {
this.cache = cache;
}

void clear() {
lock();
try {
entryTable = Entry.newArray(entryTable.length);
tableSize = 0;
} finally {
unlock();
}
}

// must be called under lock
private void expunge() {
Entry<T>[] table = entryTable;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @param <T> type
*/
public final class MapBasedClassValue<T> extends ClassValue<T> {

public MapBasedClassValue(ClassValueCalculator<T> calculator) {
super(calculator);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* occurs at most once.
*/
final class StableClassValue<T> extends ClassValue<T> {
final Object lock = new Object();

public StableClassValue(ClassValueCalculator<T> calculator) {
super(calculator);
Expand All @@ -27,19 +28,21 @@ public T get(Class<?> cls) {
* @param <Input> input of the computation
* @param <Result> result of the computation
*/
private class StableValue<Input, Result> {
private final Function<Input, Result> calculator;
private static class StableValue<Input, Result> {
private Function<Input, Result> calculator;
private volatile Result result;
StableValue(Function<Input, Result> calculator) {
private final Object lock;
StableValue(Object lock, Function<Input, Result> calculator) {
this.calculator = calculator;
this.lock = lock;
}
Result get(Input input) {
Result result = this.result;

if (result != null) return result;

// lock on the StableClassValue so there are not multiple locks potentially in different orders
synchronized (StableClassValue.this) {
synchronized (lock) {
result = this.result;

if (result != null) return result;
Expand All @@ -55,7 +58,7 @@ Result get(Input input) {
private final java.lang.ClassValue<StableValue<Class<?>, T>> proxy = new java.lang.ClassValue<StableValue<Class<?>, T>>() {
@Override
protected StableValue<Class<?>, T> computeValue(Class<?> type) {
return new StableValue<>(calculator);
return new StableValue<>(lock, calculator);
}
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package org.jruby.embed;

import org.jruby.Ruby;
import org.junit.Test;

import java.lang.management.ManagementFactory;
import java.lang.ref.WeakReference;
import java.util.concurrent.atomic.AtomicReference;

import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;

/**
* Regression test for https://github.com/jruby/jruby/issues/9092
*
* Leak mechanism:
* Thread (GC root) → ThreadLocalMap → Entry.value = SoftRef&lt;ThreadContext&gt;
* → ThreadContext → Ruby → JRubyClassLoader → ~98 classes
*
* ThreadService extends ThreadLocal and stores a SoftReference&lt;ThreadContext&gt; per thread.
* SoftReferences are only cleared by the JVM under heap pressure, so in a well-resourced
* server System.gc() will NOT collect them. After the fix, teardown() explicitly calls
* SoftReference.clear() on every tracked ref, making the Ruby runtime weakly reachable
* and therefore collected by a normal System.gc() call.
*/
public class ScriptingContainerClassLeakTest {

/**
* Primary regression test for GH-9092.
*
* After the container is terminated (engine disposed), the Ruby runtime must
* be eligible for collection by a normal GC — not just when the JVM is under
* memory pressure.
*
* Without the fix: the calling thread's ThreadLocalMap retains
* Entry.value = SoftRef&lt;ThreadContext&gt; → ThreadContext → Ruby
* so Ruby remains <em>softly reachable</em> and survives System.gc().
*
* With the fix: teardown() calls SoftReference.clear() on every tracked ref,
* breaking the chain; Ruby becomes only weakly reachable and is collected.
*/
@Test
public void runtimeIsGCdAfterTeardown() throws InterruptedException {
// Create engine and dispose inside a helper method so that the
// ScriptingContainer is guaranteed out-of-scope when we check GC.
WeakReference<Ruby> ref = createEvalAndClose();
forceGC();
assertNull(
"Ruby runtime was not collected after terminate() (GH-9092). " +
"The calling thread's ThreadLocalMap is holding a SoftReference to " +
"the runtime's ThreadContext, keeping it alive across GC cycles.",
ref.get()
);
}

@Test
public void runtimeIsGCdAfterWorkerThreadTeardown() throws InterruptedException {
AtomicReference<WeakReference<Ruby>> refHolder = new AtomicReference<>();

Thread thread = new Thread(() -> {
ScriptingContainer sc = new ScriptingContainer(LocalContextScope.SINGLETHREAD);
Ruby runtime = sc.getProvider().getRuntime();
refHolder.set(new WeakReference<>(runtime));
sc.terminate();
Ruby.clearGlobalRuntime();
});

thread.start();
thread.join();

WeakReference<Ruby> ref = refHolder.get();
forceGC();

assertNull(
"Ruby runtime created and terminated on a worker thread was not collected after terminate() (GH-9092).",
ref.get()
);
}

/**
* Create a container, eval something (so the calling thread's ThreadContext
* is registered via ThreadService.set()), then dispose it. Returning only a
* WeakReference ensures the container itself is out-of-scope at the call site.
*/
private static WeakReference<Ruby> createEvalAndClose() {
ScriptingContainer sc = new ScriptingContainer(LocalContextScope.SINGLETHREAD);
sc.runScriptlet("nil"); // triggers ThreadService.set() → SoftRef tracked
WeakReference<Ruby> ref = new WeakReference<>(sc.getProvider().getRuntime());
sc.terminate();
Ruby.clearGlobalRuntime();
return ref;
// sc and all strong references it owns go out of scope here
}

/**
* Secondary check: loaded-class count must not grow linearly across cycles.
* This catches any classloader leak regardless of the specific mechanism.
*/
@Test
public void classCountDoesNotGrowUnboundedly() throws InterruptedException {
// Warmup
for (int i = 0; i < 3; i++) createAndTearDown();
forceGC();

long classesBefore = ManagementFactory.getClassLoadingMXBean().getLoadedClassCount();

for (int i = 0; i < 10; i++) createAndTearDown();
forceGC();

long classesAfter = ManagementFactory.getClassLoadingMXBean().getLoadedClassCount();
long growth = classesAfter - classesBefore;

// Generous constant budget (lazy JDK internals etc.), but not per-cycle growth.
// Pre-fix behaviour was ~98 new classes per cycle * 10 = ~980.
assertTrue(
"Loaded class count grew by " + growth + " across 10 ScriptingContainer " +
"cycles (max allowed: 200). Classloader leak detected (see GH-9092).",
growth <= 200
);
}

// -------------------------------------------------------------------------

private static void createAndTearDown() {
ScriptingContainer sc = new ScriptingContainer(LocalContextScope.SINGLETHREAD);
try {
sc.runScriptlet("1 + 1");
} finally {
sc.terminate();
Ruby.clearGlobalRuntime();
}
}

@SuppressWarnings({"deprecation", "removal"})
private static void forceGC() throws InterruptedException {
// ScriptingContainer has finalize(), so two GC passes are needed:
// 1st: detects sc is unreachable → enqueues for finalization
// System.runFinalization(): runs sc.finalize() → sc now truly collectable
// 2nd: collects sc + LocalContext → Ruby loses its strong-ref path
// 3rd+: Ruby (with fix: weakly reachable) is collected
for (int i = 0; i < 5; i++) {
System.gc();
System.runFinalization();
Thread.sleep(100);
}
}
}
Loading
Loading