Skip to content

Commit aa7d3ed

Browse files
committed
quarkus.package.jar.tree-shake option to eliminate unused classes from runtime dependencies
1 parent 329f4ff commit aa7d3ed

File tree

164 files changed

+6060
-53
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

164 files changed

+6060
-53
lines changed

core/deployment/src/main/java/io/quarkus/deployment/pkg/PackageConfig.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ interface JarConfig {
182182
@WithDefault("true")
183183
boolean addRunnerSuffix();
184184

185+
/**
186+
* Tree-shaking configuration.
187+
*/
188+
TreeShakeConfig treeShake();
189+
185190
/**
186191
* Indicates a list of dependency for which the jar will use artifactId.type filename scheme
187192
* Each dependency needs to be expressed in the following format:
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package io.quarkus.deployment.pkg;
2+
3+
import java.util.Optional;
4+
import java.util.Set;
5+
6+
import io.quarkus.runtime.annotations.ConfigGroup;
7+
import io.smallrye.config.WithDefault;
8+
9+
/**
10+
* Tree-shaking configuration.
11+
*/
12+
@ConfigGroup
13+
public interface TreeShakeConfig {
14+
15+
/**
16+
* Whether to perform class reachability analysis and exclude non-reachable classes
17+
* from the produced JAR.
18+
* <ul>
19+
* <li>{@code none} - No analysis or exclusion is performed.</li>
20+
* <li>{@code classes} - Exclude non-reachable classes from dependencies.</li>
21+
* </ul>
22+
*/
23+
@WithDefault("classes")
24+
TreeShakeMode mode();
25+
26+
/**
27+
* Dependency artifacts to exclude from tree-shaking. All classes from excluded
28+
* artifacts are preserved regardless of reachability analysis.
29+
* <p>
30+
* This is useful for libraries that perform self-integrity checks (e.g., BouncyCastle FIPS)
31+
* or that load classes in ways the tree-shaker cannot detect.
32+
* <p>
33+
* Each dependency is expressed as {@code groupId:artifactId[:[classifier][:[type]]]}.
34+
* The classifier and type are optional. If the type is missing, {@code jar} is assumed.
35+
*/
36+
Optional<Set<String>> excludedArtifacts();
37+
38+
/**
39+
* Tree shaking mode.
40+
*/
41+
enum TreeShakeMode {
42+
/**
43+
* No analysis or exclusion is performed.
44+
*/
45+
NONE,
46+
/**
47+
* Exclude non-reachable classes from dependencies.
48+
*/
49+
CLASSES;
50+
}
51+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package io.quarkus.deployment.pkg.builditem;
2+
3+
import java.util.Collections;
4+
import java.util.List;
5+
import java.util.Map;
6+
import java.util.Set;
7+
8+
import io.quarkus.builder.item.SimpleBuildItem;
9+
import io.quarkus.maven.dependency.ArtifactKey;
10+
11+
/**
12+
* Build item that holds the results of dependency usage analysis for tree shaking.
13+
* Contains whether class-level tree shaking was performed, the set of reachable class names (dot-separated),
14+
* and the sorted list of removed class resource paths per dependency.
15+
*/
16+
public final class JarTreeShakeBuildItem extends SimpleBuildItem {
17+
18+
private final boolean classesShaken;
19+
private final Set<String> reachableClassNames;
20+
private final Map<ArtifactKey, List<String>> removedClasses;
21+
22+
public JarTreeShakeBuildItem(boolean classesShaken, Set<String> reachableClassNames,
23+
Map<ArtifactKey, List<String>> removedClasses) {
24+
this.classesShaken = classesShaken;
25+
this.reachableClassNames = reachableClassNames;
26+
this.removedClasses = removedClasses;
27+
}
28+
29+
public boolean isClassesShaken() {
30+
return classesShaken;
31+
}
32+
33+
/**
34+
* @return dot-separated class names of all reachable classes
35+
*/
36+
public Set<String> getReachableClassNames() {
37+
return reachableClassNames;
38+
}
39+
40+
/**
41+
* @return sorted list of removed class resource paths (e.g. "com/example/Foo.class") per dependency
42+
*/
43+
public Map<ArtifactKey, List<String>> getRemovedClasses() {
44+
return removedClasses;
45+
}
46+
47+
/**
48+
* Computes a pedigree string for the given dependency describing what was removed.
49+
*
50+
* @return pedigree text or {@code null} if nothing was removed
51+
*/
52+
public String computePedigree(ArtifactKey depKey) {
53+
if (!classesShaken) {
54+
return null;
55+
}
56+
List<String> removed = removedClasses.getOrDefault(depKey, Collections.emptyList());
57+
if (removed.isEmpty()) {
58+
return null;
59+
}
60+
var sb = new StringBuilder("Removed ");
61+
sb.append(removed.get(0));
62+
for (int i = 1; i < removed.size(); ++i) {
63+
sb.append(",").append(removed.get(i));
64+
}
65+
return sb.toString();
66+
}
67+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.quarkus.deployment.pkg.builditem;
2+
3+
import io.quarkus.builder.item.MultiBuildItem;
4+
import io.quarkus.maven.dependency.ArtifactKey;
5+
6+
/**
7+
* Excludes an entire dependency artifact from jar tree-shake class removal.
8+
* All classes from excluded artifacts are treated as reachable and will never
9+
* be removed by the tree-shaker.
10+
* <p>
11+
* This is needed for libraries like BouncyCastle FIPS that perform self-integrity
12+
* checks (checksums over their own classes) and break if any classes are removed.
13+
*/
14+
public final class JarTreeShakeExcludedArtifactBuildItem extends MultiBuildItem {
15+
16+
private final ArtifactKey artifactKey;
17+
18+
public JarTreeShakeExcludedArtifactBuildItem(ArtifactKey artifactKey) {
19+
this.artifactKey = artifactKey;
20+
}
21+
22+
public ArtifactKey getArtifactKey() {
23+
return artifactKey;
24+
}
25+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package io.quarkus.deployment.pkg.builditem;
2+
3+
import io.quarkus.builder.item.MultiBuildItem;
4+
5+
/**
6+
* This build item is meant to be internal to {@link io.quarkus.deployment.pkg.steps.JarTreeShakeProcessor},
7+
* in a sense it's produced and consumed by it.
8+
* <p>
9+
* Declares a class that must be treated as a root for jar tree-shake reachability analysis.
10+
* Any class registered through this build item will not be removed by the tree-shaker,
11+
* and all classes reachable from it will also be preserved.
12+
*/
13+
public final class JarTreeShakeRootClassBuildItem extends MultiBuildItem {
14+
15+
private final String className;
16+
17+
public JarTreeShakeRootClassBuildItem(String className) {
18+
this.className = className;
19+
}
20+
21+
public String getClassName() {
22+
return className;
23+
}
24+
}

core/deployment/src/main/java/io/quarkus/deployment/pkg/jar/AbstractFastJarBuilder.java

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import io.quarkus.deployment.pkg.PackageConfig;
5353
import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem;
5454
import io.quarkus.deployment.pkg.builditem.JarBuildItem;
55+
import io.quarkus.deployment.pkg.builditem.JarTreeShakeBuildItem;
5556
import io.quarkus.deployment.pkg.builditem.OutputTargetBuildItem;
5657
import io.quarkus.deployment.util.FileUtil;
5758
import io.quarkus.maven.dependency.ArtifactKey;
@@ -66,6 +67,7 @@ abstract class AbstractFastJarBuilder extends AbstractJarBuilder<JarBuildItem> {
6667

6768
private final List<AdditionalApplicationArchiveBuildItem> additionalApplicationArchives;
6869
private final Set<ArtifactKey> parentFirstArtifactKeys;
70+
private final JarTreeShakeBuildItem treeShakeResult;
6971

7072
AbstractFastJarBuilder(CurateOutcomeBuildItem curateOutcome,
7173
OutputTargetBuildItem outputTarget,
@@ -80,11 +82,13 @@ abstract class AbstractFastJarBuilder extends AbstractJarBuilder<JarBuildItem> {
8082
Set<ArtifactKey> parentFirstArtifactKeys,
8183
Set<ArtifactKey> removedArtifactKeys,
8284
ExecutorService executorService,
83-
ResolvedJVMRequirements jvmRequirements) {
85+
ResolvedJVMRequirements jvmRequirements,
86+
JarTreeShakeBuildItem treeShakeResult) {
8487
super(curateOutcome, outputTarget, applicationInfo, packageConfig, mainClass, applicationArchives, transformedClasses,
8588
generatedClasses, generatedResources, removedArtifactKeys, executorService, jvmRequirements);
8689
this.additionalApplicationArchives = additionalApplicationArchives;
8790
this.parentFirstArtifactKeys = parentFirstArtifactKeys;
91+
this.treeShakeResult = treeShakeResult;
8892
}
8993

9094
public JarBuildItem build() throws IOException {
@@ -232,7 +236,7 @@ public JarBuildItem build() throws IOException {
232236
copyDependency(parentFirstArtifactKeys, outputTarget, copiedArtifacts, mainLib, baseLib,
233237
fastJarJarsBuilder::addDependency, fastJarJarsBuilder::addParentFirstDependency, true,
234238
appDep, transformedClasses, removedArtifactKeys, packageConfig, manifestConfig,
235-
executorService);
239+
executorService, treeShakeResult);
236240
} else if (includeAppDependency(appDep, outputTarget.getIncludedOptionalDependencies(), removedArtifactKeys)) {
237241
appDep.getResolvedPaths().forEach(fastJarJarsBuilder::addDependency);
238242
}
@@ -311,7 +315,7 @@ public JarBuildItem build() throws IOException {
311315
copyDependency(parentFirstArtifactKeys, outputTarget, copiedArtifacts, deploymentLib, baseLib, p -> {
312316
}, p -> {
313317
}, false, appDep, new TransformedClassesBuildItem(Map.of()), removedArtifactKeys, packageConfig,
314-
manifestConfig, executorService); //we don't care about transformation here, so just pass in an empty item
318+
manifestConfig, executorService, null); //we don't care about transformation or tree shaking here
315319
}
316320
Map<ArtifactKey, List<String>> relativePaths = new HashMap<>();
317321
for (Entry<ArtifactKey, List<Path>> e : copiedArtifacts.entrySet()) {
@@ -398,7 +402,8 @@ private static void copyDependency(Set<ArtifactKey> parentFirstArtifacts, Output
398402
Map<ArtifactKey, List<Path>> runtimeArtifacts, Path libDir, Path baseLib, Consumer<Path> dependenciesConsumer,
399403
Consumer<Path> parentFirstDependenciesConsumer, boolean allowParentFirst, ResolvedDependency appDep,
400404
TransformedClassesBuildItem transformedClasses, Set<ArtifactKey> removedDeps,
401-
PackageConfig packageConfig, ApplicationManifestConfig.Builder manifestConfig, ExecutorService executorService)
405+
PackageConfig packageConfig, ApplicationManifestConfig.Builder manifestConfig, ExecutorService executorService,
406+
JarTreeShakeBuildItem treeShakeResult)
402407
throws IOException {
403408

404409
// Exclude files that are not jars (typically, we can have XML files here, see https://github.com/quarkusio/quarkus/issues/2852)
@@ -450,6 +455,40 @@ private static void copyDependency(Set<ArtifactKey> parentFirstArtifacts, Output
450455
}
451456
}
452457
}
458+
// When tree shake level is CLASSES, add non-reachable classes to removal set.
459+
// Use walkRaw to handle multi-release JARs: we need to add both base and
460+
// versioned entry paths to the removal set for unreachable classes.
461+
if (treeShakeResult != null
462+
&& treeShakeResult.isClassesShaken()) {
463+
try (var pathTree = appDep.getContentTree().open()) {
464+
pathTree.walkRaw(visit -> {
465+
String rel = visit.getRelativePath("/");
466+
String classRel = rel;
467+
// For multi-release entries, extract the actual class path
468+
if (rel.startsWith("META-INF/versions/")) {
469+
String afterVersions = rel.substring("META-INF/versions/".length());
470+
int slash = afterVersions.indexOf('/');
471+
if (slash > 0) {
472+
classRel = afterVersions.substring(slash + 1);
473+
} else {
474+
return;
475+
}
476+
}
477+
if (classRel.endsWith(".class") && !classRel.equals("module-info.class")) {
478+
String className = classRel.substring(0, classRel.length() - 6).replace('/', '.');
479+
if (!treeShakeResult.getReachableClassNames().contains(className)) {
480+
int dollarIdx = className.indexOf('$');
481+
if (dollarIdx < 0
482+
|| !treeShakeResult.getReachableClassNames()
483+
.contains(className.substring(0, dollarIdx))) {
484+
// Add the raw path (base or META-INF/versions/N/...) to removal set
485+
removedFromThisArchive.add(rel);
486+
}
487+
}
488+
}
489+
});
490+
}
491+
}
453492
var appComponent = ApplicationComponent.builder()
454493
.setPath(targetPath)
455494
.setResolvedDependency(appDep);
@@ -461,14 +500,10 @@ private static void copyDependency(Set<ArtifactKey> parentFirstArtifacts, Output
461500
// we copy jars for which we remove entries to the same directory
462501
// which seems a bit odd to me
463502
JarUnsigner.unsignJar(resolvedDep, targetPath, Predicate.not(removedFromThisArchive::contains));
464-
465-
var list = new ArrayList<>(removedFromThisArchive);
466-
Collections.sort(list);
467-
var sb = new StringBuilder("Removed ").append(list.get(0));
468-
for (int i = 1; i < list.size(); ++i) {
469-
sb.append(",").append(list.get(i));
470-
}
471-
appComponent.setPedigree(sb.toString());
503+
}
504+
String pedigree = treeShakeResult != null ? treeShakeResult.computePedigree(appDep.getKey()) : null;
505+
if (pedigree != null) {
506+
appComponent.setPedigree(pedigree);
472507
}
473508
manifestConfig.addComponent(appComponent);
474509
}

0 commit comments

Comments
 (0)