For my tests, I'm using rest-assured which is partially written in Groovy.
In my application, I use a custom ClassLoader
to load rest-assured and Groovy classes, after each test, the ClassLoader
is closed and ready to be collected by the GC.
In case, I launch my test with -Dgroovy.use.classvalue=false
, the class GroovyClassValuePreJava7
is used which is the Groovy implementation of ClassValue
, the ClassLoader
s are properly removed from the heap but when I launch my test with -Dgroovy.use.classvalue=true
, the class GroovyClassValueJava7
is used which is a subtype of ClassValue
, I end up with an OOME.
I can see thanks to my profiler that each ClassLoader
is actually retained by a JNI Global Reference to each primitive class (void.class
, float.class
, boolean.class
, int.class
, double.class
, long.class
, char.class
, byte.class
).
If I naively call InvokerHelper.removeClass(Class)
on each of these classes, to clear the ClassValueMap
, my ClassLoaders
don't have any GC root anymore but unfortunately, they are still in the heap.
My test class is the following:
class ClassLoaderLeakTest {
...
private CustomClassLoader cl;
@BeforeEach
void configure() {
cl = new CustomClassLoader();
}
@AfterEach
void clean() throws Exception {
cl.close();
cl = null;
}
@RepeatedTest(100)
void test() throws Exception {
cl.loadClass("org.example.Main").getMethod("callTest").invoke(null);
}
}
The Main
class is a simple rest-assured test:
public class Main {
public static void callTest() {
RestAssured.given()
.when().get("/hello")
.then()
.statusCode(200)
.body(is("hello"));
}
}
The class CustomClassLoader
is basically an URLClassLoader
that loads jar files from a specific lib folder:
public class CustomClassLoader extends URLClassLoader {
public CustomClassLoader() {
super(urls(), null);
}
private static URL[] urls() {
Function<URI, URL> toURL = uri -> {
try {
return uri.toURL();
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
};
List<URL> l = Arrays.stream(
Objects.requireNonNull(new File("./lib").listFiles())
)
.map(File::getAbsoluteFile)
.map(File::toURI)
.map(toURL).collect(Collectors.toList());
l.add(
toURL.apply(new File("./target/classes").getAbsoluteFile().toURI())
);
return l.toArray(URL[]::new);
}
}
I created a small project to reproduce, any idea/help is more than welcome.
What could be the root cause of this ClassLoader
leak?
I also tried to remove the soft, weak, and phantom references to the ClassLoader
s but it did not change anything, I still have the ClassLoader
s in the heap even without a path to GC roots whatever the type of reference.
To fix the leak, we need to remove the associated value for all existing instances of ClassValue
by calling ClassValue#remove(Class)
.
In Groovy
, the easiest way I found is by getting all the ClassInfo
and calling InvokerHelper.removeClass(Class)
on each underlying class when closing the ClassLoader
as next:
for (ClassInfo ci : ClassInfo.getAllClassInfo()) {
InvokerHelper.removeClass(ci.getTheClass());
}