Skip to content

Afterburner parent-classloader caching silently broken on Java 9+ without --add-opens java.base/java.lang=ALL-UNNAMED #348

@cowtowncoder

Description

@cowtowncoder

(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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions