Summary
JFR profiling of Maven 4 RC5/RC6 on a 4,383-module generated reactor project revealed several performance regressions compared to Maven 3. A clean install -DskipTests that takes ~1:15 on Maven 3.9.16 was taking ~2:45 on Maven 4 RC6.
After a series of targeted optimizations across 4 repositories, Maven 4 now completes the same build in ~1:18 — at parity with Maven 3.
Benchmark Results
Test machine: Apple M4 Pro, JDK 21. Project: 4,383-module diamond-graph reactor, clean install -DskipTests -q.
| Configuration |
Wall time |
vs RC6 |
| Maven 3.9.16 |
1:14 – 1:18 |
— |
| Maven 4 RC6 (unpatched) |
2:45 |
baseline |
| + PathConflictResolver default |
1:45 |
-36% |
| + resolver optimizations |
1:22 |
-50% |
| + all optimizations |
1:18 |
-53% |
Hotspots & Fixes
Each optimization was identified via JFR CPU profiling. The fixes are grouped by repository and listed in order of impact.
1. Conflict Resolver: O(N²) → O(N) — apache/maven#12662
JFR: ClassicConflictResolver.gatherConflictItems — 46.7% CPU
The ClassicConflictResolver performs O(N×M) recursive DFS in gatherConflictItems(). Switching the default from classic to path (PathConflictResolver, available since resolver 2.0.11, same resolution results) reduces this to O(N). Single biggest win: 2:45 → 1:45.
JFR: deriveChildManager + Key.equals + MMap.done — 28% CPU combined
TransitiveDependencyManager (Maven 4 default, deriveUntil=Integer.MAX_VALUE) creates a new child manager at every graph node. Four layered fixes:
- Instance self-reuse — when no new management data is collected (common for transitive POMs without
<dependencyManagement>), return this instead of a new instance. Restores pool cache transparency.
- Key coordinate caching — cache
groupId/artifactId/extension/classifier strings eagerly to avoid RelocatedArtifact virtual dispatch per equals().
- Cons-list parent pointer — replace O(depth)-copied
ArrayList<AbstractDependencyManager> with a single parent reference. O(1) derive, cascading hashCode, identity-shortcircuit equals.
- Varargs elimination — replace
Objects.hash(Object...) with manual 31*h + field.hashCode() chains throughout Key, constructor, Holder, and MMap to eliminate ~10 temporary Object[] allocations per derive call.
- MEMO_CACHE_SIZE 4 → 16 — self-reuse causes long-lived managers to serve children from many BFS wavefronts.
- PathConflictResolver O(1) cycle detection — each
Path node maintains a Set<String> of all conflict IDs from root, making hasConflictIdOnPathToRoot a Set.contains() instead of O(depth) parent walk.
JFR: PluginContainer.getPluginsAsMap — 5.6% CPU
Both InstallMojo.execute() and DeployMojo.execute() scan the entire reactor project list on every module invocation to check which projects use the plugin. In a 4,383-module build this is ~19.2M filter evaluations. Fixed by caching the filtered list in the first reactor project's plugin context on first invocation.
4. Reactor Sort: O(N² log N) → O(N log N) — apache/maven#12652
JFR: MavenProject.equals — 23.5% CPU
DefaultGraphBuilder uses result.sort(comparing(sortedProjects::indexOf)) where ArrayList.indexOf() is O(n), causing O(N² log N) total MavenProject.equals() calls (~230M for 4,383 modules). Replaced with a HashMap<MavenProject, Integer> index for O(1) lookups.
Also includes:
DefaultModelObjectPool (~8% CPU) — cache getPooledTypes() set at construction, inline Objects.hash() varargs in PoolKey, add hashCode fast-rejection in equals()
PhaseComparator (~2% CPU) — pre-build HashMap<String, Integer> for O(1) phase lookups
JFR: DefaultModelObjectPool.PoolKey + Dependency builders — 37% + 18% CPU
- Add Builder getters and
*ToBuilder merger variants to generated model classes
- Defer
Dependency.build() in DefaultDependencyManagementInjector — accumulate as Builder objects, build once at end
- Optimize
computeLocations() — replace Stream.concat().collect() with HashMap.putAll() + Map.copyOf()
- Precompute
locationsHashCode at build time for fast inequality checks
JFR: 1,470ms blocked time on PrintWriter.println()
AsyncDrainWriter wraps the logging Consumer<String> with a lock-free ConcurrentLinkedQueue + non-blocking drain via ReentrantLock.tryLock(). Eliminates all contention during parallel model building (-T1C).
JFR: 97 InputLocation.of() allocations per POM
ModelBuilderRequest.isLocationTracking() existed but was only checked in one place. Now wired through XmlReaderRequest → DefaultModelXmlFactory → MavenStaxReader so the parser actually skips all InputLocation.of() calls when tracking is disabled.
All PRs
| Repository |
PR |
Status |
Description |
| apache/maven |
#12662 |
✅ Ready |
Enable PathConflictResolver by default |
| apache/maven |
#12652 |
✅ Ready |
Optimize reactor sort, model pool, phase comparator |
| apache/maven |
#12653 |
✅ Ready |
Optimize model building pipeline |
| apache/maven |
#12654 |
✅ Ready |
AsyncDrainWriter — lock contention elimination |
| apache/maven |
#12655 |
✅ Ready |
Wire location tracking to XML parser |
| apache/maven-resolver |
#2014 |
✅ Ready |
TransitiveDependencyManager performance |
| apache/maven-install-plugin |
#427 |
✅ Ready |
Cache projectsUsingPlugin — O(N²) → O(N) |
| apache/maven-deploy-plugin |
#684 |
✅ Ready |
Cache projectsWithDeployExecution — O(N²) → O(N) |
Reproducing
# Generate the test project
git clone https://github.com/maven-turbo-reactor/maven-multiproject-generator
cd maven-multiproject-generator && ./generate.sh
# Build with gnodet's bench branch that includes all resolver + maven patches
git clone -b bench/resolver-2.0.22 https://github.com/gnodet/maven
cd maven && mvn install -DskipTests -q
# Benchmark
cd generated && time path/to/patched-maven/bin/mvn clean install -DskipTests -q
See also: benchmark gist
Summary
JFR profiling of Maven 4 RC5/RC6 on a 4,383-module generated reactor project revealed several performance regressions compared to Maven 3. A
clean install -DskipTeststhat takes ~1:15 on Maven 3.9.16 was taking ~2:45 on Maven 4 RC6.After a series of targeted optimizations across 4 repositories, Maven 4 now completes the same build in ~1:18 — at parity with Maven 3.
Benchmark Results
Test machine: Apple M4 Pro, JDK 21. Project: 4,383-module diamond-graph reactor,
clean install -DskipTests -q.Hotspots & Fixes
Each optimization was identified via JFR CPU profiling. The fixes are grouped by repository and listed in order of impact.
1. Conflict Resolver: O(N²) → O(N) — apache/maven#12662
JFR:
ClassicConflictResolver.gatherConflictItems— 46.7% CPUThe
ClassicConflictResolverperforms O(N×M) recursive DFS ingatherConflictItems(). Switching the default fromclassictopath(PathConflictResolver, available since resolver 2.0.11, same resolution results) reduces this to O(N). Single biggest win: 2:45 → 1:45.2. TransitiveDependencyManager — apache/maven-resolver#2014
JFR:
deriveChildManager+Key.equals+MMap.done— 28% CPU combinedTransitiveDependencyManager(Maven 4 default,deriveUntil=Integer.MAX_VALUE) creates a new child manager at every graph node. Four layered fixes:<dependencyManagement>), returnthisinstead of a new instance. Restores pool cache transparency.groupId/artifactId/extension/classifierstrings eagerly to avoidRelocatedArtifactvirtual dispatch perequals().ArrayList<AbstractDependencyManager>with a singleparentreference. O(1) derive, cascading hashCode, identity-shortcircuit equals.Objects.hash(Object...)with manual31*h + field.hashCode()chains throughout Key, constructor, Holder, and MMap to eliminate ~10 temporaryObject[]allocations per derive call.Pathnode maintains aSet<String>of all conflict IDs from root, makinghasConflictIdOnPathToRootaSet.contains()instead of O(depth) parent walk.3. InstallMojo / DeployMojo: O(N²) → O(N) — apache/maven-install-plugin#427, apache/maven-deploy-plugin#684
JFR:
PluginContainer.getPluginsAsMap— 5.6% CPUBoth
InstallMojo.execute()andDeployMojo.execute()scan the entire reactor project list on every module invocation to check which projects use the plugin. In a 4,383-module build this is ~19.2M filter evaluations. Fixed by caching the filtered list in the first reactor project's plugin context on first invocation.4. Reactor Sort: O(N² log N) → O(N log N) — apache/maven#12652
JFR:
MavenProject.equals— 23.5% CPUDefaultGraphBuilderusesresult.sort(comparing(sortedProjects::indexOf))whereArrayList.indexOf()is O(n), causing O(N² log N) totalMavenProject.equals()calls (~230M for 4,383 modules). Replaced with aHashMap<MavenProject, Integer>index for O(1) lookups.Also includes:
DefaultModelObjectPool(~8% CPU) — cachegetPooledTypes()set at construction, inlineObjects.hash()varargs inPoolKey, add hashCode fast-rejection inequals()PhaseComparator(~2% CPU) — pre-buildHashMap<String, Integer>for O(1) phase lookups5. Model Building Pipeline — apache/maven#12653
JFR:
DefaultModelObjectPool.PoolKey+Dependencybuilders — 37% + 18% CPU*ToBuildermerger variants to generated model classesDependency.build()inDefaultDependencyManagementInjector— accumulate as Builder objects, build once at endcomputeLocations()— replaceStream.concat().collect()withHashMap.putAll()+Map.copyOf()locationsHashCodeat build time for fast inequality checks6. PrintWriter Lock Contention — apache/maven#12654
JFR: 1,470ms blocked time on
PrintWriter.println()AsyncDrainWriterwraps the loggingConsumer<String>with a lock-freeConcurrentLinkedQueue+ non-blocking drain viaReentrantLock.tryLock(). Eliminates all contention during parallel model building (-T1C).7. Location Tracking Wire-up — apache/maven#12655
JFR: 97
InputLocation.of()allocations per POMModelBuilderRequest.isLocationTracking()existed but was only checked in one place. Now wired throughXmlReaderRequest→DefaultModelXmlFactory→MavenStaxReaderso the parser actually skips allInputLocation.of()calls when tracking is disabled.All PRs
Reproducing
See also: benchmark gist