diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/ClassLoaderReflectionProbeTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/ClassLoaderReflectionProbeTest.java new file mode 100644 index 00000000..b357fcd4 --- /dev/null +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/ClassLoaderReflectionProbeTest.java @@ -0,0 +1,73 @@ +package tools.jackson.module.afterburner.inject; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +// Verifies the detection hook for issue #348: afterburner now probes at static +// init time whether ClassLoader#findLoadedClass / ClassLoader#defineClass are +// reflectively accessible. The result is exposed via a static method +// MyClassLoader.isParentClassLoaderReflectionAvailable(). If the probe fails, +// afterburner logs a WARNING once and short-circuits the parent-classloader +// cache path. +// +// This test has two jobs: +// +// 1. Confirm the public accessor exists and is reachable — i.e. a future +// refactor can't silently remove it without breaking this test. +// +// 2. Confirm it returns `true` in this test environment. The afterburner-tests +// pom passes `--add-opens java.base/java.lang=ALL-UNNAMED` on the surefire +// argLine precisely so the probe succeeds. If this assertion ever fails, +// either the argLine was dropped (fix the pom) or afterburner's probe logic +// broke (fix the probe). Either way, the GeneratedClassCachingTest's +// `testSameBeanAcrossMappersReusesSameMutatorClass` assertion will also +// fail — but this test gives a more direct failure message pointing at +// the probe, not the consequence. +public class ClassLoaderReflectionProbeTest +{ + private static final String MY_CL = + "tools.jackson.module.afterburner.util.MyClassLoader"; + + @Test + public void testProbeAccessorExistsAndIsPublic() throws Exception + { + // MyClassLoader is in a non-exported package of the afterburner JPMS + // module, but we're in the unnamed module (classpath), so we can + // reflectively reach it. `isParentClassLoaderReflectionAvailable` is + // a public static method so the probe result is observable without + // poking at private fields. + Class myClassLoader = Class.forName(MY_CL); + Method m = myClassLoader.getMethod("isParentClassLoaderReflectionAvailable"); + assertTrue(java.lang.reflect.Modifier.isPublic(m.getModifiers()), + "isParentClassLoaderReflectionAvailable() should be public"); + assertTrue(java.lang.reflect.Modifier.isStatic(m.getModifiers()), + "isParentClassLoaderReflectionAvailable() should be static"); + assertEquals(boolean.class, m.getReturnType(), + "isParentClassLoaderReflectionAvailable() should return boolean"); + } + + @Test + public void testProbeReturnsTrueInTestEnvironment() throws Exception + { + // The afterburner-tests pom sets + // `--add-opens java.base/java.lang=ALL-UNNAMED` on the surefire + // argLine (see the pom for rationale + issue #348). With that flag + // in place, the probe must succeed — otherwise either the pom was + // tampered with, or afterburner's probe logic is broken. + Class myClassLoader = Class.forName(MY_CL); + Method m = myClassLoader.getMethod("isParentClassLoaderReflectionAvailable"); + Object result = m.invoke(null); + assertEquals(Boolean.TRUE, result, + "Expected parent-classloader reflection to be available in" + + " the afterburner-tests environment, because this" + + " module's surefire argLine sets" + + " `--add-opens java.base/java.lang=ALL-UNNAMED`. If" + + " the probe is returning false, either the argLine" + + " was dropped from the pom or the probe logic in" + + " MyClassLoader's static initializer is broken." + + " See issue #348 for context."); + } +} diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/util/MyClassLoader.java b/afterburner/src/main/java/tools/jackson/module/afterburner/util/MyClassLoader.java index 9277378e..e49d168d 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/util/MyClassLoader.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/util/MyClassLoader.java @@ -18,6 +18,70 @@ public class MyClassLoader extends ClassLoader // when loading classes directly on the same parent. private final static ConcurrentHashMap parentParallelLockMap = new ConcurrentHashMap<>(); + /** + * True if {@link ClassLoader#findLoadedClass(String)} and + * {@link ClassLoader#defineClass(String, byte[], int, int)} are reflectively + * accessible from this class. On JDK 9+ both methods live in a non-open + * package of {@code java.base}, so reflective access requires the JVM to be + * launched with {@code --add-opens java.base/java.lang=ALL-UNNAMED}. Without + * that flag, {@link #loadAndResolveUsingParentClassloader} cannot cache + * generated classes on the bean's parent classloader and each call to + * {@link #loadAndResolve} ends up defining the class in a throwaway + * {@link MyClassLoader} instance — correctness is preserved but class + * metaspace grows with mapper count. See + * issue #348. + * + * Checked once in a static initializer; cached here for the rest of this + * class's lifetime. When {@code false}, {@link #loadAndResolveUsingParentClassloader} + * short-circuits immediately rather than paying the per-call + * try/catch + logging cost. + * + * @since 3.2 + */ + private static final boolean PARENT_CL_REFLECTION_AVAILABLE; + static { + boolean ok = false; + try { + // Probe both methods we'll later try to invoke. Either one failing + // is enough to disable the parent-classloader cache path. + Method find = ClassLoader.class.getDeclaredMethod("findLoadedClass", String.class); + find.setAccessible(true); + Method define = ClassLoader.class.getDeclaredMethod("defineClass", + String.class, byte[].class, int.class, int.class); + define.setAccessible(true); + ok = true; + } catch (Throwable t) { + // Swallow; ok stays false. We log once below at WARNING level so + // users running on JDK 9+ without the required --add-opens flag + // see a clear, actionable message instead of silent degradation. + Logger.getLogger(MyClassLoader.class.getName()).log(Level.WARNING, + "Afterburner: unable to reflectively access ClassLoader#findLoadedClass" + + " / ClassLoader#defineClass on the parent class loader." + + " On JDK 9+ this requires launching the JVM with" + + " `--add-opens java.base/java.lang=ALL-UNNAMED`." + + " Without it, Afterburner cannot cache generated mutator/accessor" + + " classes on the bean's classloader, so each new ObjectMapper" + + " generates fresh classes per POJO — functional behavior is" + + " preserved but classloader count grows with mapper count." + + " See https://github.com/FasterXML/jackson-modules-base/issues/348" + + " for context. Reason: " + t); + } + PARENT_CL_REFLECTION_AVAILABLE = ok; + } + + /** + * Returns {@code true} iff Afterburner can use the parent class loader to + * cache generated mutator / accessor classes across {@code ObjectMapper} + * instances. See {@link #PARENT_CL_REFLECTION_AVAILABLE} for details and + * issue #348 + * for the user-facing symptom when this returns {@code false}. + * + * @since 3.2 + */ + public static boolean isParentClassLoaderReflectionAvailable() { + return PARENT_CL_REFLECTION_AVAILABLE; + } + /** * Flag that determines if we should first try to load new class * using parent class loader or not; this may be done to try to @@ -121,6 +185,12 @@ public Class loadAndResolve(ClassName className, byte[] byteCode) */ private Class loadAndResolveUsingParentClassloader(ClassName className, byte[] byteCode) { + // Short-circuit when we already know the reflective path into ClassLoader + // isn't available (e.g. running on JDK 9+ without --add-opens java.base/java.lang). + // Avoids per-call try/catch overhead and keeps FINE-level log noise down. + if (!PARENT_CL_REFLECTION_AVAILABLE) { + return null; + } ClassLoader parentClassLoader; if (!_cfgUseParentLoader || (parentClassLoader = getParent()) == null) { return null; diff --git a/release-notes/VERSION b/release-notes/VERSION index 03537592..a1bd5e25 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -33,6 +33,9 @@ Active maintainers: #347: (afterburner) Added module "afterburner-tests" for better Afterburner-in-classpath testing (fix by @cowtowncoder, w/ Claude code) +#348 (afterburner) Afterburner parent-classloader caching silently broken on + Java 9+ without `--add-opens java.base/java.lang=ALL-UNNAMED` + (fix by @cowtowncoder, w/ Claude code) #349 (blackbird) Added module "blackbird-tests" for classpath-mode Blackbird coverage (setter specializations, field-access limitation, CrossLoaderAccess fast-path) (fix by @cowtowncoder, w/ Claude code)