Skip to content

Commit c7dda61

Browse files
authored
Fix #348: Afterburner parent-classloader caching silently broken on Java 9+ (#351)
1 parent 790e15b commit c7dda61

3 files changed

Lines changed: 146 additions & 0 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package tools.jackson.module.afterburner.inject;
2+
3+
import java.lang.reflect.Method;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
import static org.junit.jupiter.api.Assertions.*;
8+
9+
// Verifies the detection hook for issue #348: afterburner now probes at static
10+
// init time whether ClassLoader#findLoadedClass / ClassLoader#defineClass are
11+
// reflectively accessible. The result is exposed via a static method
12+
// MyClassLoader.isParentClassLoaderReflectionAvailable(). If the probe fails,
13+
// afterburner logs a WARNING once and short-circuits the parent-classloader
14+
// cache path.
15+
//
16+
// This test has two jobs:
17+
//
18+
// 1. Confirm the public accessor exists and is reachable — i.e. a future
19+
// refactor can't silently remove it without breaking this test.
20+
//
21+
// 2. Confirm it returns `true` in this test environment. The afterburner-tests
22+
// pom passes `--add-opens java.base/java.lang=ALL-UNNAMED` on the surefire
23+
// argLine precisely so the probe succeeds. If this assertion ever fails,
24+
// either the argLine was dropped (fix the pom) or afterburner's probe logic
25+
// broke (fix the probe). Either way, the GeneratedClassCachingTest's
26+
// `testSameBeanAcrossMappersReusesSameMutatorClass` assertion will also
27+
// fail — but this test gives a more direct failure message pointing at
28+
// the probe, not the consequence.
29+
public class ClassLoaderReflectionProbeTest
30+
{
31+
private static final String MY_CL =
32+
"tools.jackson.module.afterburner.util.MyClassLoader";
33+
34+
@Test
35+
public void testProbeAccessorExistsAndIsPublic() throws Exception
36+
{
37+
// MyClassLoader is in a non-exported package of the afterburner JPMS
38+
// module, but we're in the unnamed module (classpath), so we can
39+
// reflectively reach it. `isParentClassLoaderReflectionAvailable` is
40+
// a public static method so the probe result is observable without
41+
// poking at private fields.
42+
Class<?> myClassLoader = Class.forName(MY_CL);
43+
Method m = myClassLoader.getMethod("isParentClassLoaderReflectionAvailable");
44+
assertTrue(java.lang.reflect.Modifier.isPublic(m.getModifiers()),
45+
"isParentClassLoaderReflectionAvailable() should be public");
46+
assertTrue(java.lang.reflect.Modifier.isStatic(m.getModifiers()),
47+
"isParentClassLoaderReflectionAvailable() should be static");
48+
assertEquals(boolean.class, m.getReturnType(),
49+
"isParentClassLoaderReflectionAvailable() should return boolean");
50+
}
51+
52+
@Test
53+
public void testProbeReturnsTrueInTestEnvironment() throws Exception
54+
{
55+
// The afterburner-tests pom sets
56+
// `--add-opens java.base/java.lang=ALL-UNNAMED` on the surefire
57+
// argLine (see the pom for rationale + issue #348). With that flag
58+
// in place, the probe must succeed — otherwise either the pom was
59+
// tampered with, or afterburner's probe logic is broken.
60+
Class<?> myClassLoader = Class.forName(MY_CL);
61+
Method m = myClassLoader.getMethod("isParentClassLoaderReflectionAvailable");
62+
Object result = m.invoke(null);
63+
assertEquals(Boolean.TRUE, result,
64+
"Expected parent-classloader reflection to be available in"
65+
+ " the afterburner-tests environment, because this"
66+
+ " module's surefire argLine sets"
67+
+ " `--add-opens java.base/java.lang=ALL-UNNAMED`. If"
68+
+ " the probe is returning false, either the argLine"
69+
+ " was dropped from the pom or the probe logic in"
70+
+ " MyClassLoader's static initializer is broken."
71+
+ " See issue #348 for context.");
72+
}
73+
}

afterburner/src/main/java/tools/jackson/module/afterburner/util/MyClassLoader.java

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,70 @@ public class MyClassLoader extends ClassLoader
1818
// when loading classes directly on the same parent.
1919
private final static ConcurrentHashMap<String, Object> parentParallelLockMap = new ConcurrentHashMap<>();
2020

21+
/**
22+
* True if {@link ClassLoader#findLoadedClass(String)} and
23+
* {@link ClassLoader#defineClass(String, byte[], int, int)} are reflectively
24+
* accessible from this class. On JDK 9+ both methods live in a non-open
25+
* package of {@code java.base}, so reflective access requires the JVM to be
26+
* launched with {@code --add-opens java.base/java.lang=ALL-UNNAMED}. Without
27+
* that flag, {@link #loadAndResolveUsingParentClassloader} cannot cache
28+
* generated classes on the bean's parent classloader and each call to
29+
* {@link #loadAndResolve} ends up defining the class in a throwaway
30+
* {@link MyClassLoader} instance — correctness is preserved but class
31+
* metaspace grows with mapper count. See
32+
* <a href="https://github.com/FasterXML/jackson-modules-base/issues/348">issue #348</a>.
33+
*
34+
* Checked once in a static initializer; cached here for the rest of this
35+
* class's lifetime. When {@code false}, {@link #loadAndResolveUsingParentClassloader}
36+
* short-circuits immediately rather than paying the per-call
37+
* try/catch + logging cost.
38+
*
39+
* @since 3.2
40+
*/
41+
private static final boolean PARENT_CL_REFLECTION_AVAILABLE;
42+
static {
43+
boolean ok = false;
44+
try {
45+
// Probe both methods we'll later try to invoke. Either one failing
46+
// is enough to disable the parent-classloader cache path.
47+
Method find = ClassLoader.class.getDeclaredMethod("findLoadedClass", String.class);
48+
find.setAccessible(true);
49+
Method define = ClassLoader.class.getDeclaredMethod("defineClass",
50+
String.class, byte[].class, int.class, int.class);
51+
define.setAccessible(true);
52+
ok = true;
53+
} catch (Throwable t) {
54+
// Swallow; ok stays false. We log once below at WARNING level so
55+
// users running on JDK 9+ without the required --add-opens flag
56+
// see a clear, actionable message instead of silent degradation.
57+
Logger.getLogger(MyClassLoader.class.getName()).log(Level.WARNING,
58+
"Afterburner: unable to reflectively access ClassLoader#findLoadedClass"
59+
+ " / ClassLoader#defineClass on the parent class loader."
60+
+ " On JDK 9+ this requires launching the JVM with"
61+
+ " `--add-opens java.base/java.lang=ALL-UNNAMED`."
62+
+ " Without it, Afterburner cannot cache generated mutator/accessor"
63+
+ " classes on the bean's classloader, so each new ObjectMapper"
64+
+ " generates fresh classes per POJO — functional behavior is"
65+
+ " preserved but classloader count grows with mapper count."
66+
+ " See https://github.com/FasterXML/jackson-modules-base/issues/348"
67+
+ " for context. Reason: " + t);
68+
}
69+
PARENT_CL_REFLECTION_AVAILABLE = ok;
70+
}
71+
72+
/**
73+
* Returns {@code true} iff Afterburner can use the parent class loader to
74+
* cache generated mutator / accessor classes across {@code ObjectMapper}
75+
* instances. See {@link #PARENT_CL_REFLECTION_AVAILABLE} for details and
76+
* <a href="https://github.com/FasterXML/jackson-modules-base/issues/348">issue #348</a>
77+
* for the user-facing symptom when this returns {@code false}.
78+
*
79+
* @since 3.2
80+
*/
81+
public static boolean isParentClassLoaderReflectionAvailable() {
82+
return PARENT_CL_REFLECTION_AVAILABLE;
83+
}
84+
2185
/**
2286
* Flag that determines if we should first try to load new class
2387
* using parent class loader or not; this may be done to try to
@@ -121,6 +185,12 @@ public Class<?> loadAndResolve(ClassName className, byte[] byteCode)
121185
*/
122186
private Class<?> loadAndResolveUsingParentClassloader(ClassName className, byte[] byteCode)
123187
{
188+
// Short-circuit when we already know the reflective path into ClassLoader
189+
// isn't available (e.g. running on JDK 9+ without --add-opens java.base/java.lang).
190+
// Avoids per-call try/catch overhead and keeps FINE-level log noise down.
191+
if (!PARENT_CL_REFLECTION_AVAILABLE) {
192+
return null;
193+
}
124194
ClassLoader parentClassLoader;
125195
if (!_cfgUseParentLoader || (parentClassLoader = getParent()) == null) {
126196
return null;

release-notes/VERSION

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ Active maintainers:
3333
#347: (afterburner) Added module "afterburner-tests" for better Afterburner-in-classpath
3434
testing
3535
(fix by @cowtowncoder, w/ Claude code)
36+
#348 (afterburner) Afterburner parent-classloader caching silently broken on
37+
Java 9+ without `--add-opens java.base/java.lang=ALL-UNNAMED`
38+
(fix by @cowtowncoder, w/ Claude code)
3639
#349 (blackbird) Added module "blackbird-tests" for classpath-mode Blackbird coverage
3740
(setter specializations, field-access limitation, CrossLoaderAccess fast-path)
3841
(fix by @cowtowncoder, w/ Claude code)

0 commit comments

Comments
 (0)