Reduce unnecessary immutable object allocations in model building#12540
Reduce unnecessary immutable object allocations in model building#12540gnodet wants to merge 8 commits into
Conversation
The immutable model pattern in Maven 4 creates excessive intermediate objects during effective model construction. This change addresses the root causes across 7 optimization phases: - merger.vm: Fix false-positive change detection for string, boolean, int, and composite fields that defeated Builder.build() short-circuit - merger.vm: Add identity checks for list merge results before setting on builder - model.vm: Replace Stream.concat().collect() in computeLocations() with direct HashMap merge to avoid Stream pipeline allocations - model.vm: Skip ImmutableCollections.copy() in constructor when field value comes unchanged from the base object - DefaultModelNormalizer: Only create builder and build when changes are actually detected - DefaultProfileInjector: Guard sub-object rebuilds with identity check - DefaultModelBuilder: Replace nested with*() copy-on-write chains with single-builder patterns - DefaultPluginConfigurationExpander: Skip model/build rebuilds when no plugins have configuration to expand - ImmutableCollections: Recognize JDK unmodifiable maps to avoid redundant wrapping Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
5ef63b4 to
d3f572c
Compare
…trips Change model-building SPI interfaces (ModelNormalizer, ModelPathTranslator, ModelUrlNormalizer, PluginManagementInjector, DependencyManagementInjector, PluginConfigurationExpander) to accept a Model.Builder parameter instead of returning a new Model. Implementations write directly to the passed builder, eliminating the build() → newBuilder() round-trip at each pipeline step. Steps that modify different Model fields can safely share a single builder (e.g. mergeDuplicates + urlNormalizer), while steps that both write to the Build sub-object get separate builders to avoid overwrite conflicts. Also update resolverVersion from 2.0.21-SNAPSHOT to 2.0.21 (released). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Complete the builder-passing SPI migration for the two remaining interfaces: - InheritanceAssembler: expose MavenMerger's builder-level mergeModel() through a new mergeIntoBuilder() method on InheritanceModelMerger, eliminating the internal builder+build round-trip in the merger. - ProfileInjector: rework doInjectProfiles to write directly to the passed builder on the last profile iteration. Keep a simplified cache (Boolean instead of Model) for no-change detection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When injecting multiple profiles, the builder-passing SPI optimization wrote only the last profile's fields directly to the caller's builder. Since the builder was created from the original model (before injection), fields added by earlier profiles (like spotless.action from the "format" profile in maven-parent) were lost when build() fell back to base values. Fix: for multiple profiles, process all into intermediate models, then transfer the accumulated result to the passed builder via mergeModelBase. Single-profile injection still writes directly to the builder. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR reduces intermediate immutable Model object allocations during effective model construction (Maven 4), primarily by improving generated merger/model templates and by switching several pipeline SPIs/implementations to a builder-passing pattern to avoid build-then-rebuild round-trips.
Changes:
- Optimizes Modello-generated templates (
model.vm,merger.vm) to avoid false-positive builder writes and reduce map/list copying duringbuild()and location computation. - Updates
ImmutableCollections.copy(Map)to recognize JDK immutable map implementations and skip redundant wrapping. - Refactors model-building pipeline components (injectors/normalizers/translators/assemblers/expanders) and their SPIs to write into a provided
Model.Builderinstead of returning rebuiltModelinstances.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/mdo/model.vm | Reduces redundant collection copying and optimizes location map merging. |
| src/mdo/merger.vm | Adds identity/value guards to reduce unnecessary builder field writes during merges. |
| src/mdo/java/ImmutableCollections.java | Skips copying for JDK immutable map implementations. |
| pom.xml | Updates resolverVersion from snapshot to release. |
| impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/MojoExtension.java | Adapts path alignment to builder-passing translator API. |
| impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelPathTranslatorTest.java | Updates tests for builder-passing path translation API. |
| impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultInheritanceAssemblerTest.java | Updates tests for builder-passing inheritance assembly API. |
| impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultProfileInjector.java | Implements builder-passing profile injection and adds no-op caching. |
| impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultPluginManagementInjector.java | Switches plugin management injection to builder-passing. |
| impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelPathTranslator.java | Switches path translation to builder-passing and avoids unchanged sub-object sets. |
| impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelNormalizer.java | Switches duplicate merge/default injection to builder-passing and avoids unchanged rebuilds. |
| impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java | Updates the effective-model pipeline to create/pass builders through each stage. |
| impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultInheritanceAssembler.java | Switches inheritance assembly to builder-passing and merges into provided builder. |
| impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultDependencyManagementInjector.java | Switches dependency management injection to builder-passing. |
| impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultPluginConfigurationExpander.java | Switches plugin configuration expansion to builder-passing and avoids unchanged sets. |
| impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultModelUrlNormalizer.java | Switches URL normalization to builder-passing. |
| api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ProfileInjector.java | Updates SPI to accept Model.Builder. |
| api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/PluginManagementInjector.java | Updates SPI to accept Model.Builder. |
| api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/PluginConfigurationExpander.java | Updates SPI to accept Model.Builder. |
| api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ModelUrlNormalizer.java | Updates SPI to accept Model.Builder. |
| api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ModelPathTranslator.java | Updates SPI to accept Model.Builder. |
| api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ModelNormalizer.java | Updates SPI to accept Model.Builder. |
| api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/InheritanceAssembler.java | Updates SPI to accept Model.Builder. |
| api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/DependencyManagementInjector.java | Updates SPI to accept Model.Builder. |
Comments suppressed due to low confidence (2)
impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultModelUrlNormalizer.java:63
- The SCM URLs are normalized and written via a newly-built
Scmeven when normalization is a no-op. Because the model builders use identity checks for their short-circuit, writing a value-equal but different String instance can force unnecessary immutable copies. Only rebuild/setscmwhen at least one normalized field actually changes (including value-equality).
Scm scm = model.getScm();
if (scm != null) {
builder.scm(Scm.newBuilder(scm)
.url(normalize(scm.getUrl()))
.connection(normalize(scm.getConnection()))
.developerConnection(normalize(scm.getDeveloperConnection()))
.build());
}
impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultModelUrlNormalizer.java:71
- Site URL normalization is applied via
withSite(site.withUrl(...))without checking whether the normalized URL actually differs. If normalization returns a value-equal String (or no-op), this can still allocate new immutable objects. Guard the update so DistributionManagement/Site are only rebuilt when the URL truly changes (including value-equality).
DistributionManagement dist = model.getDistributionManagement();
if (dist != null) {
Site site = dist.getSite();
if (site != null) {
builder.distributionManagement(dist.withSite(site.withUrl(normalize(site.getUrl()))));
}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| Model.Builder builder = Model.newBuilder(model); | ||
| builder.url(normalize(model.getUrl())); |
gnodet
left a comment
There was a problem hiding this comment.
Solid performance optimization — the benchmark results (28% less GC pause time, 7.6% fewer GC events) demonstrate meaningful improvement. The shared-builder pattern is well-designed. A few observations on behavioral changes embedded in the refactoring:
-
Report plugin expansion bug fix: In
DefaultPluginConfigurationExpander, the old code calledexpandReport(reporting.getPlugins())but discarded the return value — report plugin configuration-to-report-set merging was silently broken. The new code correctly captures and applies the expanded report plugins. This is a genuine bug fix worth calling out explicitly in the PR description so testers are aware of the changed behavior. -
MojoExtension test model alignment: In
MojoExtension.beforeEach, the code changed from aligningtmodel(raw parsed POM) to aligningmodel(merged with defaults). Whentmodel == null, the old code stored null; the new code stores the aligned default model. Both changes look like intentional improvements, but the behavioral delta may affect test expectations. -
SPI interface changes: Seven SPI interfaces (
ProfileInjector,InheritanceAssembler, etc.) changed from returningModelto acceptingModel.Builderand returningvoid. This is source/binary-incompatible for any third-party implementations, though acceptable since Maven 4 is pre-release and these are all@since 4.0.0.
Minor notes:
- The
resolverVersionbump from2.0.21-SNAPSHOTto2.0.21inpom.xmlis unrelated to the optimization work. - The
ImmutableCollectionsJDK-internal class name detection is fail-safe (falls through to defensive copy), which is good.
This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.
Claude Code on behalf of gnodet
Log loadFromRoot and buildEffectiveModels durations at INFO level to help measure model building performance in large reactors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
merger.vm: - String fields: use Objects.equals() instead of reference != check, preventing false positives when parent and child define the same string value as different object instances - Properties/Map: add equality check after merging — skip builder setter when merged result equals target, avoiding unnecessary immutable object rebuilds - List merge: detect no-op merges by comparing content after merge and return original target reference, preserving Builder.build() short-circuit - Add MergingList.contentEquals() for efficient reference-based comparison of list elements DefaultModelUrlNormalizer: - Guard URL, SCM, and Site normalization with equality checks to avoid setting unchanged values on the builder Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
elharo
left a comment
There was a problem hiding this comment.
This is imnportant; ask me about handshoe sometime
| // Check if this is already an immutable JDK map (from Map.of(), Map.copyOf(), etc.) | ||
| // Those throw UnsupportedOperationException on put() and don't need copying | ||
| String className = map.getClass().getName(); | ||
| if (className.startsWith("java.util.ImmutableCollections$")) { |
There was a problem hiding this comment.
Is this guaranteed by the JDK or could the actual class names used here change in the future?
Is there any other way we could test this? Maybe addAll(Collections.emptyList()) and see if it throws or not.
There was a problem hiding this comment.
Good catch — the JDK internal class names are indeed not guaranteed and could change between versions. Rather than switching to a mutation-based test (try/catch on put() has its own downsides: performance overhead on the hot path, reliance on exception semantics), I've simply removed the check entirely. In practice, JDK Map.of()/Map.copyOf() maps rarely appear in the model builder — most maps are either our own AbstractImmutableMap (already short-circuited) or HashMap from merging. The wrapping cost for the occasional JDK immutable map is a single array copy of the entries, which is negligible.
Address review feedback: the check for "java.util.ImmutableCollections$" class name prefix relies on JDK internal implementation details that are not guaranteed to remain stable across versions. Remove the check entirely — JDK Map.of()/Map.copyOf() maps rarely appear in the model builder pipeline, and the wrapping cost (array copy of entries) is negligible. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
gnodet
left a comment
There was a problem hiding this comment.
Re-review after new commits (83a37f7, 7adf18c, 2f1c0a5)
Looks great — the new commits cleanly address elharo's feedback (fragile JDK class name check removed) and the merger template false-positive fix (Objects.equals instead of reference check for String fields) is an important correctness improvement.
Two minor observations:
-
Timing log level (
DefaultModelBuilder.java:838): The[TIMING]log usesINFOlevel, which means every Maven 4 reactor build prints internal timing info. All other timing/diagnostic logging inDefaultModelBuilderusesDEBUG— consider aligning this one too, unless you want it visible in production builds. -
Minor cache note (
DefaultProfileInjector.java:155):doInjectSingleProfilealways returnstruefor non-null profiles, so the cache never short-circuits single-profile no-op injections. Not a correctness issue — just a minor missed optimization opportunity.
Overall this is a well-executed performance optimization with solid benchmark results.
This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.
Claude Code on behalf of gnodet
| } | ||
|
|
||
| long t2 = System.nanoTime(); | ||
| logger.info( |
There was a problem hiding this comment.
Suggestion: this timing log uses INFO level, but all other timing/diagnostic logging in this class uses DEBUG. Consider changing to logger.debug(...) to keep production build output clean — unless you specifically want this visible for users.
| logger.info( | |
| logger.debug( |
Skip the entire substVars pipeline (HashSet allocation, delimiter
scanning, unescape processing) for strings that contain no '$'
character. Since both the placeholder syntax "${...}" and the escape
marker "$__" require a dollar sign, strings without one cannot contain
any interpolation targets. This benefits the majority of POM values
(groupId, artifactId, paths, etc.) which are plain literals.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
gnodet
left a comment
There was a problem hiding this comment.
Re-review after commit 357cf69 ("Short-circuit interpolation for strings without variable references")
Clean optimization — skipping the full substVars pipeline (HashSet allocation, delimiter scanning, unescape processing) for strings without $ is sound. The null guard is consistent with existing behavior, and the $ check correctly covers both ${...} placeholders and $__ escape markers.
Looks good. 👍
This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.
Claude Code on behalf of gnodet
Summary
Maven 4's immutable model pattern creates excessive intermediate objects during effective model construction. This PR addresses the root causes through optimizations in both the Modello-generated templates and the hand-written pipeline code, plus builder-passing SPI changes.
Changes
merger.vm — reduce false-positive change detection (highest impact)
Objects.equals()instead of!=reference check — prevents false positives when parent and child define the same string value as different object instancesBuilder.build()short-circuitmerged != target.getField()identity guard (unchanged)DefaultModelNormalizer: avoid always-build pattern
mergeDuplicates(): only create builder and callbuild()when duplicate plugins/dependencies are actually foundinjectDefaultValues(): only build when scope injection changes somethinginjectPlugin(): skip rebuild when plugin dependencies don't need scope injectionDefaultProfileInjector: guard sub-object rebuilds
newBuild != buildidentity check before setting Build on Model.BuilderDefaultModelBuilder: replace nested with() chains*
model.withParent(parent.withRelativePath(x))(2 full immutable copies) with single-builder patternswithProfiles(List.of()).withParent(null)into single builder callwithPomFile()DefaultPluginConfigurationExpander: skip unchanged rebuilds
model.withBuild(build)even when nothing expandedDefaultModelUrlNormalizer: guard normalization
ImmutableCollections.copy(Map): detect JDK unmodifiable maps
copy(Map)only recognized customAbstractImmutableMapas already-immutableMap.of(),Map.copyOf()) to avoid redundant wrappingSPI changes — Pass Model.Builder through pipeline
ProfileInjector.injectProfiles()andInheritanceAssembler.assembleModelInheritance()to acceptModel.Builderparameter, avoiding build-then-rebuild round-tripsDefaultModelNormalizer,DefaultPluginConfigurationExpander,DefaultModelUrlNormalizeralso adapted to builder-passing patternTiming log
[TIMING]INFO log inbuildBuildPom()that reportsloadFromRootandbuildEffectiveModelsdurations for the reactorImpact
These changes target the ~16-20 intermediate
Modelobjects created per POM duringbuildEffectiveModel()/readEffectiveModel(). Thebuild()short-circuit (if base != null && all fields unchanged → return base) was being defeated by false-positive field assignments in the merger, causing unnecessary object allocation chains (Model → Build → Plugin → Dependency → ...).Benchmark results — reactor model building (Apache Camel, 676 modules)
Measured with
[TIMING]log inbuildBuildPom(),mvn validate -o, interleaved runs, outliers excluded:buildEffectiveModelstotal (load+build)Benchmark results — full build GC pressure (Apache Camel, ~1000 modules,
mvn validate -o)Wall-clock improvement is moderate because model building is a fraction of total build time (which includes dependency resolution, reactor sorting, plugin init). The primary benefits are: (1) reduced GC pressure — 28% less time in GC pauses, fewer allocations, lower peak heap; (2) faster model building phase — ~3.6% less CPU time in
buildEffectiveModels.Test plan
mvn verify— all modules pass (with spotless enabled)mvn test -pl impl/maven-impl— all tests passmvn test -pl compat/maven-model-builder— all tests pass🤖 Generated with Claude Code