Skip to content

Cache Type instances in TypeFactory.getType(...) to avoid redundant work #4034

@filiphr

Description

@filiphr

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.javagetSourceJSpecifyNullability() / 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions