Skip to content

Possible classloader leak through incomplete clearing of annotation caches #31170

@henryju

Description

@henryju

Hi,

We are using Spring 5.3.28 in our application. This application supports dynamic plugins, so we do something like this:

var pluginJar = Paths.get("/path/to/plugin.jar");
try (var ucl = new URLClassloader(pluginJar.toURI().toURL()) { // Create a classloader to load plugin classes
  Class pluginClass = ucl.findClass("com.foo.MyPluginEntryPoint"); // Use reflection to load the plugin entry point
  MyPluginInterface pluginInstance = (MyPluginInterface) pluginClass.newInstance(); // MyPluginInterface is part of the API and exists in the application classloader

  try (var springContext = new AnnotationConfigApplicationContext()) { // Create a dedicated Spring context for the plugin
    pluginInstance.getBeans().forEach(springContext::registerBean);
    springContext.refresh();
    
    // Do something with the plugin springContext, like finding extension point by querying per interface
  }
  
}
Files.delete(pluginJar); // Fail on Windows, file is locked

The issue I am facing is that even if I close properly the Spring context and the URLClassloader, there is still a class leak. If I run the previous code in a loop, I can see the metaspace and the number of loaded classes continuously increasing. Triggering a gc doesn't help.
The URLClassloader instance (and all associated classes) is never collected.

I tried to find the reason for this, and I might be wrong at this point, but here are my findings.

The leak seems to be caused by one (or all) of the caches that are used in Spring annotation processing. My understanding is that one class inside one of our plugin we later add as a bean is annotated with a custom annotation. This annotation is part of the plugin JAR, and so belongs to the plugin classloader. But Spring will store the annotation (and transitively the classloader I'm trying to free) in one of the static top-level annotations caches.

image

Am I on the right track?

I managed to unload properly my plugin only after doing this:

    ReflectionUtils.clearCache();
    try {
      var orderCacheField = OrderUtils.class.getDeclaredField("orderCache");
      orderCacheField.setAccessible(true);
      Map orderCahce = (Map) orderCacheField.get(OrderUtils.class);
      orderCahce.clear();

      Class<?> standardRepeatableContainersClass = Class.forName("org.springframework.core.annotation.RepeatableContainers$StandardRepeatableContainers");
      var cacheField = standardRepeatableContainersClass.getDeclaredField("cache");
      cacheField.setAccessible(true);
      Map cache = (Map) cacheField.get(standardRepeatableContainersClass);
      cache.clear();

      Class<?> attributeMethodClass = Class.forName("org.springframework.core.annotation.AttributeMethods");
      var attCacheField = attributeMethodClass.getDeclaredField("cache");
      attCacheField.setAccessible(true);
      Map attCache = (Map) attCacheField.get(attributeMethodClass);
      attCache.clear();

      Class<?> annotationTypeMappingsClass = Class.forName("org.springframework.core.annotation.AnnotationTypeMappings");
      var noRepeatablesCacheField = annotationTypeMappingsClass.getDeclaredField("noRepeatablesCache");
      noRepeatablesCacheField.setAccessible(true);
      Map noRepeatablesCache = (Map) noRepeatablesCacheField.get(annotationTypeMappingsClass);
      noRepeatablesCache.clear();

      var standardRepeatablesCacheField = annotationTypeMappingsClass.getDeclaredField("standardRepeatablesCache");
      standardRepeatablesCacheField.setAccessible(true);
      Map standardRepeatablesCache = (Map) standardRepeatablesCacheField.get(annotationTypeMappingsClass);
      standardRepeatablesCache.clear();

      Class<?> annotationScannerClass = Class.forName("org.springframework.core.annotation.AnnotationsScanner");
      var declaredAnnotationCacheField = annotationScannerClass.getDeclaredField("declaredAnnotationCache");
      declaredAnnotationCacheField.setAccessible(true);
      Map declaredAnnotationCache = (Map) declaredAnnotationCacheField.get(annotationScannerClass);
      declaredAnnotationCache.clear();
    } catch (Exception e) {
      throw new RuntimeException(e);
    }

(not sure they are all required, I made so many attempts...)

Before I move further, do you have some insights to share? Shouldn't we have a clean way to clean all those caches?

I can probably provide a reproducer, but since this will need quite some effort, I want to be sure first I did not miss something obvious.

Thanks.

Metadata

Metadata

Assignees

Labels

in: coreIssues in core modules (aop, beans, core, context, expression)status: backportedAn issue that has been backported to maintenance branchestype: regressionA bug that is also a regression

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions