Context
TypeFactory.getType(TypeMirror) and getType(TypeElement) (processor/src/main/java/org/mapstruct/ap/internal/model/common/TypeFactory.java:209-238) allocate a new Type each call and re-run the full type analysis: Types#isSubtypeErased against Iterable/Collection/Map/Stream, declared-kind introspection, component-type computation, etc.
There is no interning. Callers that ask for the same TypeMirror repeatedly pay the full cost every time.
Why this matters now
While reviewing #4033 (JSpecify nullness), two places were flagged that call getType(...) per property and then walk the enclosing-element chain for @NullMarked/@NullUnmarked:
PropertyMapping.java — getSourceJSpecifyNullability() / targetDeclaringTypeIsNullMarked() per property mapping
PresenceCheckMethodResolver.java:98-108 — rebuilds Type for the mapper type on every presence-check resolution
Type#isNullMarked memoizes the walk per Type instance, but since each getType(...) returns a fresh instance, the memoization does not survive across calls. The effect is O(mappings × enclosingDepth) element-chain walks on large mappers, and it is not unique to JSpecify — any code path that asks for the same type twice pays similar overhead today.
Proposal
Intern Type by a stable key:
- For
TypeElement-keyed lookups: IdentityHashMap<TypeElement, Type> is safe within a single processing round.
- For
TypeMirror-keyed lookups: TypeMirror does not have useful equality, so intern via the resolved TypeElement (when DeclaredType) or a fingerprint for primitive/array/wildcard types. Fall back to no-caching where a stable key is not available.
Edges to think about:
alwaysImport / isLiteral flags are currently passed through — the cache key needs to include these, or the memoized Type needs to be immutable w.r.t. them.
- Incremental builds (javac + Gradle/Eclipse):
TypeElement identity is stable within a round, but not across rounds. Scope the cache to the TypeFactory lifetime (one per processing round today) and this is fine.
TypeHierarchyErroneousException must still be thrown for unresolvable mirrors — don't cache the erroneous state in a way that swallows it later.
Acceptance
getType(someElement) called N times returns the same instance (or an equivalent instance from a cache), and measurable reduction in isSubtypeErased calls on a representative mapper (e.g. something in integrationtest with many properties).
- No behavior change for
alwaysImport / literal variants.
Type#isNullMarked (and any future per-Type memoization) becomes effective across repeated lookups.
Related
Context
TypeFactory.getType(TypeMirror)andgetType(TypeElement)(processor/src/main/java/org/mapstruct/ap/internal/model/common/TypeFactory.java:209-238) allocate a newTypeeach call and re-run the full type analysis:Types#isSubtypeErasedagainstIterable/Collection/Map/Stream, declared-kind introspection, component-type computation, etc.There is no interning. Callers that ask for the same
TypeMirrorrepeatedly pay the full cost every time.Why this matters now
While reviewing #4033 (JSpecify nullness), two places were flagged that call
getType(...)per property and then walk the enclosing-element chain for@NullMarked/@NullUnmarked:PropertyMapping.java—getSourceJSpecifyNullability()/targetDeclaringTypeIsNullMarked()per property mappingPresenceCheckMethodResolver.java:98-108— rebuildsTypefor the mapper type on every presence-check resolutionType#isNullMarkedmemoizes the walk perTypeinstance, but since eachgetType(...)returns a fresh instance, the memoization does not survive across calls. The effect is O(mappings × enclosingDepth) element-chain walks on large mappers, and it is not unique to JSpecify — any code path that asks for the same type twice pays similar overhead today.Proposal
Intern
Typeby a stable key:TypeElement-keyed lookups:IdentityHashMap<TypeElement, Type>is safe within a single processing round.TypeMirror-keyed lookups:TypeMirrordoes not have useful equality, so intern via the resolvedTypeElement(whenDeclaredType) or a fingerprint for primitive/array/wildcard types. Fall back to no-caching where a stable key is not available.Edges to think about:
alwaysImport/isLiteralflags are currently passed through — the cache key needs to include these, or the memoizedTypeneeds to be immutable w.r.t. them.TypeElementidentity is stable within a round, but not across rounds. Scope the cache to theTypeFactorylifetime (one per processing round today) and this is fine.TypeHierarchyErroneousExceptionmust still be thrown for unresolvable mirrors — don't cache the erroneous state in a way that swallows it later.Acceptance
getType(someElement)called N times returns the same instance (or an equivalent instance from a cache), and measurable reduction inisSubtypeErasedcalls on a representative mapper (e.g. something inintegrationtestwith many properties).alwaysImport/ literal variants.Type#isNullMarked(and any future per-Typememoization) becomes effective across repeated lookups.Related
Type#isNullMarkedwith per-instance caching that the currentgetTypecontract makes ineffective; the Javadoc there was corrected in a follow-up but the underlying perf concern remains.