Skip to content

Immutable AnnotatedTypeMirror with freeze#1798

Merged
wmdietl merged 12 commits into
masterfrom
perf-atm-immutability-pr2
Jun 14, 2026
Merged

Immutable AnnotatedTypeMirror with freeze#1798
wmdietl merged 12 commits into
masterfrom
perf-atm-immutability-pr2

Conversation

@wmdietl

@wmdietl wmdietl commented Jun 13, 2026

Copy link
Copy Markdown
Member

The original plan was larger, but Claude ran into blocking issues.
This is a memory and performance neutral change that allows us to ensure some modifications are avoided.
Originally two caches were serving frozen objects, but then a Guava failure undid one of these changes.
So not a huge impact, but hopefully it will allow us to explore immutability more.

wmdietl and others added 12 commits June 13, 2026 09:50
AnnotatedTypeMirrors are mutable, so every cache in AnnotatedTypeFactory
defensively deep-copies on both store and return. AnnotatedTypeCopier.visit is
~2% self-time and the dominant share of Object[] TLAB allocation on a realistic
checkNullness compile, and the deep-copy tax is what makes the cached types cost
+50-70 MB of retained heap (see docs/developer/performance-notes.md, the
value-semantics program). The goal is to share frozen cached instances instead of
copying them.

This adds the freeze mechanism without using it yet, so it is behavior-neutral:

- A private `frozen` bit plus `isFrozen()`.
- `checkMutable()`, called by the three primary-annotation sinks
  (addAnnotation, removeAnnotation, clearAnnotations), throws BugInCF when the
  type is frozen. Every other annotation mutator routes through these. freeze()
  also calls primaryAnnotations.makeUnmodifiable() as a backstop for the
  getAnnotationsField() and AnnotatedDeclaredTypeNoHierarchy.addAnnotation paths.
- A cycle-safe deep freeze() that freezes only already-initialized structural
  components (the frozen bit doubles as the visited marker). Lazy getters freeze
  any component they create while the owner is frozen, so the reachable graph
  stays frozen without eagerly materializing bounds and type arguments.

Structural setters are intentionally left unguarded: the corruption vector is
annotation mutation, and deep freeze() already freezes every reachable
component's annotations. Nothing calls freeze() yet; freezing cache masters and
sharing them is the subject of follow-up changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…che masters

The annotated-type caches in AnnotatedTypeFactory deep-copy a value on store so
the stored "master" is never aliased. Freezing those masters (rejecting later
in-place mutation) is the first enforceable step of the value-semantics program;
it turns latent cache-corruption bugs into immediate BugInCF instead of silent
wrong results.

Freezing flushed a single root-cause bug: AnnotatedTypeCopier.visitExecutable
set the copy's vararg type to the original's vararg type by reference rather than
copying it, so deepCopy() of an executable type was not fully independent -- the
copy shared the original's vararg array subtree (Object[], Class<?>[],
LinkOption[], ...). Defaulting then mutated that shared subtree. It was harmless
before only because defaulting adds the same deterministic annotations either way.
The copier now copies the vararg type through the visitor (originalToCopy maps it
to the already-made parameter copy when the vararg is the last parameter, else it
is freshly copied).

With that fix, freeze the master at all eight cache store sites via a new
frozenDeepCopy helper (elementCache, elementTypeCache, classAndMethodTreeCache,
fromMemberTreeCache, fromExpressionTreeCache, fromTypeTreeCache,
methodAsMemberOfCache) and on the directSupertypesCache list. The caches still
return a fresh deepCopy() on every hit, so this is behavior-neutral; it only adds
the fail-fast invariant. Removing the copy-on-return (the allocation win) is the
subject of follow-up changes.

Passes the full framework, javacutil, dataflow, and checker JUnit suites
(125 test suites, 0 failures), including AllSystemsTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
VarargCacheAliasing calls JDK vararg methods (Arrays.asList, String.format,
Class.getMethod) whose executable types are cached and re-defaulted. With the
cache masters frozen and the AnnotatedTypeCopier vararg fix reverted, this
crashes with "Attempted to mutate a frozen AnnotatedTypeMirror", because
deepCopy() shared the original's vararg subtree instead of copying it. The test
type-checks cleanly with the fix and guards against a regression of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Update performance-notes.md with the PR #1798 results and the session's
findings:

- Applied: new "AnnotatedTypeMirror immutability foundation" entry for the
  freeze() mechanism, the AnnotatedTypeCopier vararg-aliasing fix, and frozen
  cache masters (behavior- and perf-neutral; measured allocation +/-0.1%,
  freeze() below the on-CPU sampling threshold).
- Tried and rejected: the cache-boundary flips (the cache-return deepCopy is
  load-bearing because defaulting, flow refinement, type-argument inference, and
  constructorFromUse all mutate the returned type), hashCode caching on frozen
  types (0% of hashCode calls hit frozen types), and the shallow-location
  defaulting shortcut (10.2% fewer scans but over cheap types, allocation flat).
- Value-semantics narrative: the "one copier bug, not pervasive aliasing"
  finding, the benign-to-results finding (the bug only surfaces as the freeze
  crash, so the regression test needs the freeze enforcement), and the
  load-bearing-copy conclusion that the allocation win needs copy-on-write or
  eliminating redundant re-annotation, not a boundary flip.
- Status: the immutability program is paused at its shipped foundation; it is no
  longer the "recommended next direction" until a copy-on-write prototype is
  measured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The freeze() additions sit immediately before AnnotatedTypeVariable.getBounds()
and AnnotatedWildcardType.fixupBoundAnnotations(), so the diff-based javadoc
doclint check attributes those methods' pre-existing "no comment" warnings to
this PR. Add Javadoc to getBounds(), the adjacent getBoundFields(), and the
wildcard fixupBoundAnnotations().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
StructuralEqualityComparer.arePrimaryAnnosEqual compared the primary annotations
of two types. ValueAnnotatedTypeFactory overrode it to first normalize the
operands' annotations to a canonical form (e.g. so @IntRange(1,1) and @intval(1)
compare equal) -- but it did so by calling replaceAnnotation on type1 and type2,
i.e. an equality check with a side effect on its operands. That is incorrect in
principle and prevents a type comparison from being run on a shared, immutable
(frozen) type, which the annotated-type caches will hand out.

Add a non-mutating overload arePrimaryAnnosEqual(anno1, anno2, type1, type2) that
takes the annotations to compare explicitly, and refactor the base method to use
it. The Value override now computes the canonical annotations and compares them
via the overload, without mutating the types. Behavior-preserving: the normalized
annotations were only used for the comparison, not relied upon afterward.

Passes framework, all Value, and all-systems test suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…f copying

getAnnotatedType(Tree) for a class or method declaration deep-copied the cached type
on every hit so callers could mutate it. Most callers only read it, so the copy was
wasted. Because the cached masters are frozen (immutable), the hit path now returns
the shared frozen value and the callers that actually mutate it copy it first:

- getMethodReturnType copies the return type when it is frozen (its overrides mutate it).
- InitializationParentAnnotatedTypeFactory.getSelfType copies before mutating the self
  type and its enclosing types.
- getAnnotatedType(Tree) copies the from* result if frozen before addComputedTypeAnnotations
  (an expression's type can be a class/method type served from the cache).
- constructorFromUse copies the frozen enclosing type before embedding and annotating it.
- ValueVisitor.checkOverride copies the executable types before
  replaceSpecialIntRangeAnnotations mutates them.

Deterministic-allocation A/B (single forked javac): about -1% on a method-heavy
single-class file and within noise on a realistic file -- classAndMethodTreeCache is a
low-volume cache, so this is GC-pressure relief, not a wall-clock win, and it
establishes the copy-on-frozen pattern for sharing cached types. Passes the full
framework and checker JUnit suites (125 test suites, 0 failures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
)

Update the value-semantics narrative and open-venues with the PR #1798 results:
the cross-cutting side-effecting-equality blocker (now fixed non-mutating), the
first shipped boundary flip (classAndMethodTreeCache, green but ~-1%/~0% because
it is low-volume), and the finding that the high-volume elementTypeCache is
mutation-dominated (asMemberOf) so its flip win is also likely modest. The flip is
mechanically unblockable via copy-on-frozen at the enumerated mutating consumers;
the larger win still needs copy-on-write or eliminating redundant re-annotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getAnnotatedType(Element) deep-copied the cached type on every hit so callers
could mutate it freely. With the cache master frozen (the PR #1798 tripwire),
return the shared frozen instance directly and copy-on-frozen only at the
callers that actually mutate the result:

  - computeMethodTypeAsMemberOf (the unsubstituted method type)
  - constructorFromUse (the diamond type, the constructor, and the
    anonymous-class super constructor)
  - AnnotatedTypes.asMemberOf (the member type it may alias and postAsMemberOf
    mutates)
  - SyntheticArrays.replaceReturnType (array clone: mutates then the caller
    mutates again)
  - DependentTypesHelper.atInvocation and KeyForDependentTypesHelper
    .atMethodInvocation (re-fetch the declared method type and viewpoint-adapt
    it in place)
  - AnnotationFileParser.processFakeOverride (the stub parser side-effects a
    type it documents as "fresh")

This removes a deep copy per cache hit for the read-only majority of callers.
elementTypeCache is the largest annotated-type cache, but a full-checknullness
re-trace shows AnnotatedTypeCopier is now only ~0.76% self-time / ~1.5% of
allocation (earlier caches already harvested most of it), so the win is modest:
deterministic ThreadAllocationStatistics drops ~1% on generic-call-heavy code
(Big300 -0.75%, Big600 -0.97%) and is within noise elsewhere. Shipped for the
read-only-copy elimination and the enforced no-mutate-cached-types invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…#1798)

Document the immutability program's measured outcome honestly:

  - the elementTypeCache and classAndMethodTreeCache flips pay ~1% on
    generic-call-heavy code and noise elsewhere -- not the -5.3% an earlier
    estimate suggested, which never reproduced against the post-flip baseline;
  - the directSupertypesCache flip was tried and rejected (its dominant
    consumer, AsSuperVisitor, mutates each returned supertype in place, so the
    flip relocates the per-hit copy rather than removing it -- the same trap as
    the four pre-pipeline caches and methodAsMemberOf);
  - a post-mortem on why the win was modest: a fresh full-checknullness trace
    shows AnnotatedTypeCopier is now ~0.76% self-time / ~1.5% of allocation, far
    below the ~22%-of-Object[] figure that motivated the program -- intervening
    caches (PR #1777), the thread-local copier map, and lazy visitedNodes had
    already harvested it. Lesson: re-trace the current baseline before committing
    to a plan built on an older profile.

Also corrects the stale -1.9%/-5.3% figures previously written into this file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The flip (commit "share frozen elementTypeCache values instead of copying")
returned the shared frozen cache master from getAnnotatedType(Element). A full
Guava nullness build -- which alltests does not cover -- crashed with
"Attempted to mutate a frozen AnnotatedTypeMirror with underlying type
java.lang.Object".

Root cause: a consumer reparents a sub-component of the shared frozen master --
an unbounded wildcard's implicit Object upper bound, derived from a JDK
generic's cached type-parameter bound (Function<?, K> with K extends
Comparable) -- into a fresh, non-frozen result type; addComputedTypeAnnotations
then mutates the frozen child. The copy-on-frozen guards added with the flip
all check the root (if (type.isFrozen()) deepCopy()), so a non-frozen root
holding a frozen child slips through. This hazard is structural to returning a
shared frozen value -- any path that lifts a child out of the shared master is
a latent crash -- and is not worth the flip's ~1% allocation win.

This reverts the flip and its nine copy-on-frozen consumer sites. A
frozen-master tripwire that still returns deepCopy() remains safe (the master
is never handed out), so the frozen cache masters stay. The lower-traffic
classAndMethodTreeCache flip is kept (it survived the Guava build and
alltests). Adds a regression test minimized from
com.google.common.collect.SortedLists.binarySearch; performance-notes records
the embedded-frozen-component hazard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The flip writeup documented the embedded-frozen-component hazard and the
revert, but not how (or whether) the flip could be salvaged. Add the cost
ladder: (1) fix each reparenting site -- cheap but unenumerable, a convention
not enforced; (2) deep guard at the choke point -- complete but the scan
likely erases the ~1% win; (3) copy-on-write ATMs -- the only complete fix,
which would make all eight caches flippable, but is a separate measured
project, not a patch. Verdict: keep reverted; pursue via copy-on-write if at
all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@wmdietl wmdietl enabled auto-merge (squash) June 14, 2026 00:32
@wmdietl wmdietl merged commit 9b3974d into master Jun 14, 2026
50 checks passed
@wmdietl wmdietl deleted the perf-atm-immutability-pr2 branch June 14, 2026 00:56
wmdietl added a commit that referenced this pull request Jun 14, 2026
)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant