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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions