ThreadLocal Memory Leak in JRuby 10.0.2.0 with ScriptingContainer
Summary
JRuby 10.0.2.0 has a memory leak when using ScriptingContainer with proper cleanup. Despite calling the recommended cleanup sequence (unregisterCurrentThread(), terminate(), tearDown(true), clearGlobalRuntime()), Ruby Runtime objects accumulate indefinitely. This regression from JRuby 9.3.1.0 makes long-running applications that repeatedly create and destroy ScriptingContainer instances infeasible.
Environment
- JRuby Version: 10.0.2.0 (regression from 9.3.1.0)
- Java Version: 21.0.8 (OpenJDK)
- OS: Linux (also reproducible on other platforms)
- Scope:
LocalContextScope.SINGLETHREAD
Minimal Reproducible Test Case
A complete standalone test is available that demonstrates the issue:
Test Files
- JRubyMemoryLeakTest.java - Minimal reproduction (attached below)
- test_jruby_leak.sh - Test runner script (attached below)
How to Reproduce
# Test with JRuby 9.3.1.0 (baseline, no leak)
./test_jruby_leak.sh 9 50
# Test with JRuby 10.0.2.0 (shows leak)
./test_jruby_leak.sh 10 50
Test Results
JRuby 9.3.1.0 (Baseline - NO LEAK)
Iterations: 50
Ruby Runtime instances: 5 (STABLE)
Total JRuby objects: 130,443 instances, 6.3 MB
Heap memory: ~37 MB (STABLE after warmup)
Memory profile:
- Initial: 5 MB
- After 10 iterations: 28 MB
- After 50 iterations: 37 MB (STABLE)
JRuby 10.0.2.0 (MEMORY LEAK)
Iterations: 50
Ruby Runtime instances: 50 (1 PER ITERATION - LEAK!)
Total JRuby objects: 1,206,254 instances, 61.5 MB (9.2x more!)
Heap memory: 99 MB (GROWING LINEARLY)
Memory profile:
- Initial: 5 MB
- After 10 iterations: 28 MB
- After 50 iterations: 99 MB (GROWING ~2MB per iteration)
Critical findings:
- Ruby Runtime leak: 50 instances after 50 iterations (1:1 ratio) vs 5 in JRuby 9.3.1.0
- Object accumulation: 9.2x more JRuby objects retained
- Linear memory growth: ~2 MB per iteration
- ScriptingContainer cleanup works: 0 instances remaining
Cleanup Code Used
We follow the recommended cleanup pattern, including the critical step of unregistering ThreadContext BEFORE calling tearDown():
Ruby runtime = sc.getProvider().getRuntime();
if (runtime != null) {
// CRITICAL: Unregister current thread BEFORE tearDown
// tearDown() replaces ThreadService, must clean up old one first
ThreadContext currentContext = runtime.getThreadService().getCurrentContext();
runtime.getThreadService().unregisterCurrentThread(currentContext);
}
// Terminate the container
sc.terminate();
// Force runtime teardown
if (runtime != null) {
runtime.tearDown(true);
}
// Clear global runtime reference
Ruby.clearGlobalRuntime();
This cleanup sequence works perfectly in JRuby 9.3.1.0 but fails to cleanup properly in 10.0.2.0.
Impact
This regression affects applications that:
- Repeatedly create/destroy ScriptingContainer instances
- Run long-lived server processes
- Use JRuby as an embedded scripting engine
The leak causes eventual heap exhaustion in long-running applications.
Request
Could the JRuby team please investigate the ThreadLocal cleanup changes between 9.3.1.0 and 10.0.2.0? The minimal test case provided should make it straightforward to reproduce and debug with a memory profiler.
Attachment 1: JRubyMemoryLeakTest.java
import org.jruby.Ruby;
import org.jruby.embed.LocalContextScope;
import org.jruby.embed.ScriptingContainer;
import org.jruby.runtime.ThreadContext;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
/**
* Minimal test demonstrating ThreadLocal memory leak in JRuby 10.0.2.0
*
* Creates ScriptingContainer instances with proper cleanup sequence.
* Despite following recommended cleanup, JRuby 10.0.2.0 leaks Ruby Runtime objects.
*/
public class JRubyMemoryLeakTest {
static {
// Disable bundler and RubyGems to isolate from system Ruby
System.setProperty("jruby.cli.load.gemfile", "false");
System.setProperty("jruby.cli.rubygems.enable", "false");
}
public static void main(String[] args) throws Exception {
int iterations = Integer.parseInt(args.length > 0 ? args[0] : "10");
System.out.println("JRuby ThreadLocal Memory Leak Test");
System.out.println("===================================");
System.out.println("JRuby Version: " + org.jruby.runtime.Constants.VERSION);
System.out.println("Java Version: " + System.getProperty("java.version"));
System.out.println("Iterations: " + iterations);
System.out.println();
printMemoryStats("BEFORE");
for (int i = 1; i <= iterations; i++) {
runScriptWithProperCleanup();
if (i % 5 == 0) {
System.gc();
Thread.sleep(100);
printMemoryStats("After iteration " + i);
}
}
System.out.println("\n=== Final Heap Analysis ===");
printObjectCounts();
}
/**
* Executes Ruby code with proper cleanup sequence:
* 1. unregisterCurrentThread() BEFORE tearDown
* 2. terminate()
* 3. tearDown(true)
* 4. clearGlobalRuntime()
*/
private static void runScriptWithProperCleanup() {
ScriptingContainer sc = null;
try {
// Create container with SINGLETHREAD scope to avoid cross-thread issues
sc = new ScriptingContainer(LocalContextScope.SINGLETHREAD);
// Isolate from system Ruby environment
sc.getEnvironment().put("GEM_HOME", "");
sc.getEnvironment().put("GEM_PATH", "");
sc.getEnvironment().put("BUNDLE_GEMFILE", "");
// Execute some Ruby code (simulates real application usage)
sc.runScriptlet("" +
"def fibonacci(n)\n" +
" return n if n <= 1\n" +
" fibonacci(n-1) + fibonacci(n-2)\n" +
"end\n" +
"\n" +
"result = fibonacci(10)\n" +
"result\n"
);
// Proper cleanup sequence
Ruby runtime = sc.getProvider().getRuntime();
if (runtime != null) {
// CRITICAL: Unregister current thread BEFORE tearDown
// tearDown() replaces ThreadService, must clean up old one first
ThreadContext currentContext = runtime.getThreadService().getCurrentContext();
runtime.getThreadService().unregisterCurrentThread(currentContext);
}
// Terminate the container
sc.terminate();
// Force runtime teardown
if (runtime != null) {
runtime.tearDown(true);
}
// Clear global runtime reference
Ruby.clearGlobalRuntime();
} catch (Exception e) {
e.printStackTrace();
} finally {
sc = null;
}
}
private static void printMemoryStats(String label) {
MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
System.out.printf("%s: Heap Used: %.2f MB, Committed: %.2f MB\n",
label,
heapUsage.getUsed() / 1024.0 / 1024.0,
heapUsage.getCommitted() / 1024.0 / 1024.0
);
}
private static void printObjectCounts() {
try {
String pid = ManagementFactory.getRuntimeMXBean().getName().split("@")[0];
System.out.println("\n=== ScriptingContainer Count ===");
ProcessBuilder pb1 = new ProcessBuilder(
"bash", "-c",
"jmap -histo:live " + pid + " | grep 'org.jruby.embed.ScriptingContainer' || echo 'No instances found'"
);
pb1.inheritIO();
pb1.start().waitFor();
System.out.println("\n=== Ruby Runtime Count ===");
ProcessBuilder pb2 = new ProcessBuilder(
"bash", "-c",
"jmap -histo:live " + pid + " | grep -E 'org.jruby.Ruby[^a-zA-Z]|org.jruby.Ruby$' || echo 'No instances found'"
);
pb2.inheritIO();
pb2.start().waitFor();
System.out.println("\n=== Total JRuby Object Count ===");
ProcessBuilder pb3 = new ProcessBuilder(
"bash", "-c",
"jmap -histo:live " + pid + " | grep 'org.jruby' | awk '{sum += $2; bytes += $3} END {print \"Total instances: \" sum \", Total bytes: \" bytes}'"
);
pb3.inheritIO();
pb3.start().waitFor();
System.out.println("\n=== Top 10 JRuby Classes ===");
ProcessBuilder pb4 = new ProcessBuilder(
"bash", "-c",
"jmap -histo:live " + pid + " | grep 'org.jruby' | head -10"
);
pb4.inheritIO();
pb4.start().waitFor();
} catch (Exception e) {
e.printStackTrace();
}
}
}
Attachment 2: test_jruby_leak.sh
#!/bin/bash
#
# Test script to demonstrate JRuby ThreadLocal memory leak
# Usage: ./test_jruby_leak.sh [9|10] [iterations]
#
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
JRUBY_VERSION=${1:-10}
ITERATIONS=${2:-10}
case $JRUBY_VERSION in
9)
JRUBY_JAR="$SCRIPT_DIR/jruby-complete-9.3.1.0.jar"
;;
10)
JRUBY_JAR="$SCRIPT_DIR/jruby-complete-10.0.2.0.jar"
;;
*)
echo "Usage: $0 [9|10] [iterations]"
echo " 9 - Test with JRuby 9.3.1.0"
echo " 10 - Test with JRuby 10.0.2.0"
echo " iterations - Number of ScriptingContainer cycles (default: 10)"
exit 1
;;
esac
if [ ! -f "$JRUBY_JAR" ]; then
echo "ERROR: JRuby JAR not found: $JRUBY_JAR"
echo ""
echo "Please download:"
echo " JRuby 9.3.1.0: https://repo1.maven.org/maven2/org/jruby/jruby-complete/9.3.1.0/jruby-complete-9.3.1.0.jar"
echo " JRuby 10.0.2.0: https://repo1.maven.org/maven2/org/jruby/jruby-complete/10.0.2.0/jruby-complete-10.0.2.0.jar"
exit 1
fi
echo "========================================"
echo "JRuby Memory Leak Test"
echo "========================================"
echo "JRuby Version: $JRUBY_VERSION"
echo "JRuby JAR: $JRUBY_JAR"
echo "Iterations: $ITERATIONS"
echo "========================================"
echo
# Compile the test
echo "Compiling JRubyMemoryLeakTest.java..."
javac -cp "$JRUBY_JAR" "$SCRIPT_DIR/JRubyMemoryLeakTest.java"
if [ $? -ne 0 ]; then
echo "ERROR: Compilation failed"
exit 1
fi
echo "Compilation successful"
echo
# Run the test with reasonable heap size
echo "Running test with $ITERATIONS iterations..."
echo
java -cp "$JRUBY_JAR:$SCRIPT_DIR" \
-Xms512m \
-Xmx2g \
-XX:+UseG1GC \
JRubyMemoryLeakTest \
"$ITERATIONS"
echo
echo "========================================"
echo "Test completed"
echo "========================================"
echo
echo "To compare results:"
echo " JRuby 9.3.1.0: ./test_jruby_leak.sh 9 50"
echo " JRuby 10.0.2.0: ./test_jruby_leak.sh 10 50"
Note: The test is self-contained with no external dependencies beyond JRuby JAR files. Results are reproducible across different systems and Java versions.
ThreadLocal Memory Leak in JRuby 10.0.2.0 with ScriptingContainer
Summary
JRuby 10.0.2.0 has a memory leak when using
ScriptingContainerwith proper cleanup. Despite calling the recommended cleanup sequence (unregisterCurrentThread(),terminate(),tearDown(true),clearGlobalRuntime()), Ruby Runtime objects accumulate indefinitely. This regression from JRuby 9.3.1.0 makes long-running applications that repeatedly create and destroy ScriptingContainer instances infeasible.Environment
LocalContextScope.SINGLETHREADMinimal Reproducible Test Case
A complete standalone test is available that demonstrates the issue:
Test Files
How to Reproduce
Test Results
JRuby 9.3.1.0 (Baseline - NO LEAK)
Memory profile:
JRuby 10.0.2.0 (MEMORY LEAK)
Memory profile:
Critical findings:
Cleanup Code Used
We follow the recommended cleanup pattern, including the critical step of unregistering ThreadContext BEFORE calling tearDown():
This cleanup sequence works perfectly in JRuby 9.3.1.0 but fails to cleanup properly in 10.0.2.0.
Impact
This regression affects applications that:
The leak causes eventual heap exhaustion in long-running applications.
Request
Could the JRuby team please investigate the ThreadLocal cleanup changes between 9.3.1.0 and 10.0.2.0? The minimal test case provided should make it straightforward to reproduce and debug with a memory profiler.
Attachment 1: JRubyMemoryLeakTest.java
Attachment 2: test_jruby_leak.sh
Note: The test is self-contained with no external dependencies beyond JRuby JAR files. Results are reproducible across different systems and Java versions.