(note: generated by Claude code)
Discovered while working on #347 (adds afterburner-tests classpath-mode integration test module). Filing separately so the tracking survives the PR merge.
Symptom
Every new ObjectMapper constructed with AfterburnerModule generates a fresh mutator/accessor class per POJO instead of reusing one cached on the bean's parent classloader. In long-running applications that construct many mappers, this leaks classloaders and bloats class metaspace proportional to mapper count. Functional behavior is correct — deserialization works — but the "Afterburner is fast because it caches" promise is silently undermined.
Root cause
MyClassLoader.findLoadedClassOnParent and defineClassOnParent use ClassLoader.class.getDeclaredMethod(...) + setAccessible(true) to reflectively invoke the protected ClassLoader#findLoadedClass and ClassLoader#defineClass methods on the bean's parent classloader. On Java 9+, reflective access to protected methods of java.lang.ClassLoader from the unnamed module is gated by the module system and requires --add-opens java.base/java.lang=ALL-UNNAMED on the JVM launch flags.
Without the open, setAccessible throws InaccessibleObjectException (wrapped as Exception), findLoadedClassOnParent / defineClassOnParent catch it, log at Level.FINE (invisible by default), and return null. loadAndResolveUsingParentClassloader returns null. loadAndResolve then falls back to defining the class in the current MyClassLoader instance — which PropertyMutatorCollector.buildMutator creates fresh on every call (afterburner/src/main/java/tools/jackson/module/afterburner/deser/PropertyMutatorCollector.java:118-119):
if (classLoader == null) {
classLoader = new MyClassLoader(beanClass.getClassLoader(), true);
}
So every invocation of buildMutator → fresh MyClassLoader → fresh generated class → one leaked classloader per ObjectMapper per bean class.
Blast radius
Essentially every Afterburner user on Java 9+. Nobody sets --add-opens java.base/java.lang=ALL-UNNAMED unless they know they need to, and there's no current documentation suggesting they should. Both setUseValueClassLoader(true) (default) and setUseValueClassLoader(false) are affected via different paths — see #347 review notes.
How to reproduce
Run GeneratedClassCachingTest.testSameBeanAcrossMappersReusesSameMutatorClass from the afterburner-tests module (introduced in #347) without the --add-opens java.base/java.lang=ALL-UNNAMED flag currently set in afterburner-tests/pom.xml surefire config. You'll get two distinct Class<?> instances with identical fully-qualified names (something like FooBean$Access4JacksonDeserializer8b6fbfda@16b64a03 vs @59d5c537). With the flag restored, the test passes with a single shared class.
Minimal reproducer without the test module:
public class FooBean { public int a; public String b; }
JsonMapper m1 = JsonMapper.builder().addModule(new AfterburnerModule()).build();
JsonMapper m2 = JsonMapper.builder().addModule(new AfterburnerModule()).build();
m1.readValue("{\"a\":1,\"b\":\"x\"}", FooBean.class);
m2.readValue("{\"a\":2,\"b\":\"y\"}", FooBean.class);
// Inspect each mapper's BeanDeserializer._propsByIndex[0]._propertyMutator.getClass()
// Under JDK 17+ without --add-opens, you get two distinct Class instances
// with the same fully-qualified name.
Severity
- Correctness: unaffected. Deserialization produces correct results.
- Performance: mutator/accessor class generation is re-done on every mapper, so warm-up cost multiplies.
- Memory: classloader count and class metaspace scale with mapper count. In short-lived apps (CLI tools, tests) this is bounded and harmless. In long-running servers that create mappers on hot paths, it is a slow leak.
Not catastrophic, but a real degradation of Afterburner's value proposition — the optimization runs, but its caching doesn't work, and nobody knows.
Possible fixes
Short-term (detection + log): probe the reflective path at module init time and log a WARNING when it fails, telling users to add the flag or accept the degradation. Cheap, makes the issue visible. Doesn't fix the leak.
Medium-term (migrate class injection): switch to ByteBuddy's net.bytebuddy.dynamic.loading.ClassInjector.UsingLookup, which uses MethodHandles.Lookup.defineClass and works on JDK 9+ without opens — provided the bean's module opens its package to afterburner (or the bean is in the unnamed module). Requires propagating a Lookup through MyClassLoader / PropertyMutatorCollector / CreatorOptimizer / PropertyAccessorCollector and deciding the user-facing contract for how lookups are obtained. Real refactor, ~1-2 days of work.
Long-term (JPMS-native): drop MyClassLoader entirely in favor of MethodHandles.privateLookupIn + Lookup.defineClass. Requires users to opens beanPackage to tools.jackson.module.afterburner in their module-info.java. API contract change; probably warrants major-version planning.
See #347 review thread for the full discussion and option comparison.
Verification
Any fix should make GeneratedClassCachingTest.testSameBeanAcrossMappersReusesSameMutatorClass pass without --add-opens java.base/java.lang=ALL-UNNAMED on the surefire argLine. Amplify the signal if desired: add a variant that constructs N mappers and asserts all N resolve to the same Class<?> instance.
(note: generated by Claude code)
Discovered while working on #347 (adds
afterburner-testsclasspath-mode integration test module). Filing separately so the tracking survives the PR merge.Symptom
Every new
ObjectMapperconstructed withAfterburnerModulegenerates a fresh mutator/accessor class per POJO instead of reusing one cached on the bean's parent classloader. In long-running applications that construct many mappers, this leaks classloaders and bloats class metaspace proportional to mapper count. Functional behavior is correct — deserialization works — but the "Afterburner is fast because it caches" promise is silently undermined.Root cause
MyClassLoader.findLoadedClassOnParentanddefineClassOnParentuseClassLoader.class.getDeclaredMethod(...)+setAccessible(true)to reflectively invoke the protectedClassLoader#findLoadedClassandClassLoader#defineClassmethods on the bean's parent classloader. On Java 9+, reflective access to protected methods ofjava.lang.ClassLoaderfrom the unnamed module is gated by the module system and requires--add-opens java.base/java.lang=ALL-UNNAMEDon the JVM launch flags.Without the open,
setAccessiblethrowsInaccessibleObjectException(wrapped asException),findLoadedClassOnParent/defineClassOnParentcatch it, log atLevel.FINE(invisible by default), and returnnull.loadAndResolveUsingParentClassloaderreturnsnull.loadAndResolvethen falls back to defining the class in the currentMyClassLoaderinstance — whichPropertyMutatorCollector.buildMutatorcreates fresh on every call (afterburner/src/main/java/tools/jackson/module/afterburner/deser/PropertyMutatorCollector.java:118-119):So every invocation of
buildMutator→ freshMyClassLoader→ fresh generated class → one leaked classloader perObjectMapperper bean class.Blast radius
Essentially every Afterburner user on Java 9+. Nobody sets
--add-opens java.base/java.lang=ALL-UNNAMEDunless they know they need to, and there's no current documentation suggesting they should. BothsetUseValueClassLoader(true)(default) andsetUseValueClassLoader(false)are affected via different paths — see #347 review notes.How to reproduce
Run
GeneratedClassCachingTest.testSameBeanAcrossMappersReusesSameMutatorClassfrom theafterburner-testsmodule (introduced in #347) without the--add-opens java.base/java.lang=ALL-UNNAMEDflag currently set inafterburner-tests/pom.xmlsurefire config. You'll get two distinctClass<?>instances with identical fully-qualified names (something likeFooBean$Access4JacksonDeserializer8b6fbfda@16b64a03vs@59d5c537). With the flag restored, the test passes with a single shared class.Minimal reproducer without the test module:
Severity
Not catastrophic, but a real degradation of Afterburner's value proposition — the optimization runs, but its caching doesn't work, and nobody knows.
Possible fixes
Short-term (detection + log): probe the reflective path at module init time and log a
WARNINGwhen it fails, telling users to add the flag or accept the degradation. Cheap, makes the issue visible. Doesn't fix the leak.Medium-term (migrate class injection): switch to ByteBuddy's
net.bytebuddy.dynamic.loading.ClassInjector.UsingLookup, which usesMethodHandles.Lookup.defineClassand works on JDK 9+ without opens — provided the bean's module opens its package to afterburner (or the bean is in the unnamed module). Requires propagating aLookupthroughMyClassLoader/PropertyMutatorCollector/CreatorOptimizer/PropertyAccessorCollectorand deciding the user-facing contract for how lookups are obtained. Real refactor, ~1-2 days of work.Long-term (JPMS-native): drop
MyClassLoaderentirely in favor ofMethodHandles.privateLookupIn+Lookup.defineClass. Requires users toopens beanPackage to tools.jackson.module.afterburnerin theirmodule-info.java. API contract change; probably warrants major-version planning.See #347 review thread for the full discussion and option comparison.
Verification
Any fix should make
GeneratedClassCachingTest.testSameBeanAcrossMappersReusesSameMutatorClasspass without--add-opens java.base/java.lang=ALL-UNNAMEDon the surefire argLine. Amplify the signal if desired: add a variant that constructs N mappers and asserts all N resolve to the sameClass<?>instance.