Class ClassLoadingChainAnalyzer
ClassLoader.loadClass() or
Class.forName() through call chains that cannot be statically traced by the
tree-shaker's BFS reachability analysis.
Problem
Many libraries construct class names at runtime using StringBuilder,
invokedynamic StringConcatFactory, or similar mechanisms, and then load
them via ClassLoader.loadClass() or Class.forName() during static
initialization (<clinit>) or constructor execution (<init>). Because
the class name strings are computed dynamically, the tree-shaker's BFS over constant
pool references and method instructions cannot trace which classes will actually be
loaded. Without special handling, these dynamically loaded classes would be incorrectly
removed from the final artifact.
Solution overview
Rather than attempting to reconstruct class name strings from bytecode (which is fragile and incomplete), this analyzer uses a three-phase approach:
Phase 1 -- Seed propagation
Uses a reverse caller index (callee → callers) that is built externally by
JarTreeShaker.scanBytecode(java.lang.String, byte[], boolean, io.quarkus.deployment.pkg.steps.JarTreeShaker.BfsScanResult) during BFS reachability analysis. Starting from seed
methods (ClassLoader.loadClass(String), Class.forName(String),
Class.forName(String, boolean, ClassLoader), and
MethodHandles.Lookup.findClass(String)), propagates backwards through the
caller index using a fixed-point algorithm to identify every application method that
transitively calls a class-loading seed. JDK and infrastructure classes
(java/, javax/, jakarta/, sun/, org/objectweb/)
are excluded from propagation to avoid false positives.
Phase 2 -- Entry point discovery
Scans the class-loading methods identified in Phase 1 for <init> or
<clinit> methods. These represent "entry point" classes whose construction
or static initialization ultimately triggers the dynamic class loading. No additional
graph walk is needed because Phase 1 already computed the full transitive closure.
Phase 3 -- Dynamic execution in a forked JVM
Each entry point class is loaded and instantiated inside a forked JVM process
(see ClassLoadingRecorder) to dynamically capture all class load attempts that
occur during its initialization and construction. Running in a separate process
ensures complete isolation of global JVM state and that Metaspace is reclaimed
when the process exits.
RecordingClassLoader
The
invalid reference
ClassLoadingRecorder.RecordingClassLoaderURLClassLoader with
the platform class loader as its parent, isolating it from the application classpath.
It loads dependency and application classes from URLs built from the forked JVM's
classpath. Every loadClass() attempt is recorded, including attempts that fail
with ClassNotFoundException. After instantiation, if the resulting object
implements java.util.Map, the analyzer also extracts String values from
the map entries and checks them against known class names. This handles patterns like
BouncyCastle's addAlgorithm() which stores class names as map values for
deferred loading rather than loading them immediately.
Fixed-point loop
This analyzer is designed to be called from JarTreeShaker in an outer
fixed-point loop. Each invocation returns newly discovered class names. These are fed
back into the BFS reachability analysis, which may mark additional classes as
reachable, and the analysis repeats. The loop terminates when no new classes are
discovered in an iteration.
Example: BouncyCastle
BouncyCastle's BouncyCastleProvider constructor calls setup(), which
calls loadAlgorithms(), which calls loadServiceClass(), which calls
ClassUtil.loadClass(), which ultimately calls ClassLoader.loadClass().
Phase 1 marks all these methods as class-loading methods. Phase 2 walks up to the
BouncyCastleProvider.<init> constructor and identifies it as an entry point.
Phase 3 instantiates it and records every dynamically loaded class. Additionally, each
$Mappings class stores sub-class names via addAlgorithm(key, className),
which populates the provider's internal map. These class names are captured by
inspecting the map values after instantiation.
-
Field Summary
FieldsModifier and TypeFieldDescriptionJDK methods that load classes by name — the seeds for call chain propagation. -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionexecuteEntryPoints(Set<String> entryPointClasses, Map<String, Supplier<byte[]>> generatedBytecode, Map<String, Supplier<byte[]>> transformedBytecode, Set<String> allKnownClasses, List<Path> depJarPaths, List<Path> appPaths) Phase 3: Execute entry point classes in a forked JVM to capture which classes get loaded during static initialization and construction.Propagates backwards from class-loading seed methods through the pre-built caller index to find entry point classes whose<init>/<clinit>triggers dynamic class loading.(package private) static booleanisJdkOrInfraClass(String methodKey) Checks whether a method key belongs to a JDK or infrastructure class that should not be considered as a class-loading method for propagation purposes.(package private) voidrelease()Releases the class-loading method data to free memory.(package private) static booleanshouldRecordCall(String calleeKey) Returnstrueif a method call tocalleeKeyshould be recorded in the caller index.
-
Field Details
-
SEED_METHODS
JDK methods that load classes by name — the seeds for call chain propagation.
-
-
Constructor Details
-
ClassLoadingChainAnalyzer
- Parameters:
callerIndex- pre-built reverse call index (callee → callers), populated during BFSdepClassNames- dependency class names (dot-separated) for entry point filtering
-
-
Method Details
-
findEntryPoints
Propagates backwards from class-loading seed methods through the pre-built caller index to find entry point classes whose<init>/<clinit>triggers dynamic class loading. Only returns entry points not returned by previous invocations.- Returns:
- newly discovered entry point class names, or empty set if none found
-
release
void release()Releases the class-loading method data to free memory. The caller index is owned externally and cleared by the caller. -
executeEntryPoints
static Set<String> executeEntryPoints(Set<String> entryPointClasses, Map<String, Supplier<byte[]>> generatedBytecode, Map<String, Supplier<byte[]>> transformedBytecode, Set<String> allKnownClasses, List<Path> depJarPaths, List<Path> appPaths) Phase 3: Execute entry point classes in a forked JVM to capture which classes get loaded during static initialization and construction.Running in a separate process ensures complete isolation of global JVM state (System.out/err, security providers, system properties, TCCL) and that all Metaspace consumed by
defineClassis reclaimed when the process exits. This is critical because multiple tree-shaker instances may run in parallel during CI builds with-T2C.Only generated and transformed bytecode is written to a temp directory. Dependency JARs and the app artifact are included on the classpath by path, with the temp dir first so transformed classes override originals.
- Parameters:
entryPointClasses- classes whose init/clinit triggers dynamic class loadinggeneratedBytecode- generated classes (in-memory only, must be written to disk)transformedBytecode- transformed classes that override originals in dep JARsallKnownClasses- all known class names for Map value extractiondepJarPaths- file system paths to dependency JARsappPaths- file system paths to the application artifact
-
isJdkOrInfraClass
Checks whether a method key belongs to a JDK or infrastructure class that should not be considered as a class-loading method for propagation purposes. -
shouldRecordCall
Returnstrueif a method call tocalleeKeyshould be recorded in the caller index. Records calls to seed methods and calls to non-JDK/infra methods.
-