diff --git a/GROOVY-12281-assessment.html b/GROOVY-12281-assessment.html new file mode 100644 index 00000000000..5023cd47d13 --- /dev/null +++ b/GROOVY-12281-assessment.html @@ -0,0 +1,336 @@ + + + +
+ + +ClassInfo.globalClassValue Loader PinningInvestigation follow-up from GROOVY-12142 review · v1: 19 August 2026 (recompute declined) · v2: 20 August 2026, reopened on review by Jochen Theodorou · v3: 21 August 2026, revised on his PR #2820 review (§12: ephemeron pinning replaces the root set) · Status: proposed pending team review
+ +v1's blanket claim that every drop-and-recompute scheme is "structurally illegal" is withdrawn. Re-grounding each of its two failure arguments in the code — prompted by the review's six questions — shows Failure 1 (DGM loss) cannot occur for any collectible ClassInfo, and Failure 2 (split-brain guards) is real but preventable by construction. The result is a new candidate the v1 option list missed:
Option E — soft values + resurrection + dirty roots + per-Class indy domains prototyped & measured
+-Dgroovy.use.classvalue=soft: java.lang.ClassValue keeps the per-Class fast path for all keys, but each association stores a bootstrap-loaded SoftReference to the ClassInfo, cutting the only strong chain from immortal platform classes to Groovy's loader. Identity is owned by a weak side map (resurrection): a value still reachable anywhere is re-associated, never replaced, so a fresh instance can exist only when no guard can still observe the old one. Non-reconstructible state (installed MetaClasses, per-instance MetaClasses, MOP method arrays) pins its ClassInfo inside its own association (v3 — the association's slot flips from soft to strong, an ephemeron: the instance then lives exactly as long as its class, no longer), and indy SwitchPoint domains are keyed by Class so invalidation deterministically reaches guards linked under a collected predecessor.
Measured: the container-undeploy acceptance test — which no earlier option had actually demonstrated — shows the dropped loader is pinned forever under today's default and collected under soft mode; dispatch and compilation are at parity within noise, with the only measurable cost ≈+0.6 ns on a raw getClassInfo lookup (the map escape hatch costs ≈+3.5 ns and 5% dispatch geomean). All correctness suites pass in both modes.
Recommendation: land Option E as an opt-in mode alongside the existing escape hatch, gather soak experience, and revisit the default with the team (the full 57-benchmark idiom sweep from the v1 Option-D gate should be re-run before any default flip). v1's measured declines of Option B (hybrid) and Option D (map default) stand unchanged.
+The v1 record closed the recomputation avenue on two claimed correctness failures. The review (JIRA, 19 Aug 2026) argued the assessment conflated "the current implementation does not do X" with "X is impossible", and posed six questions. Each is answered below with code evidence; three of the review's positions are conceded outright, one is sharpened into the concrete failure sequence the review asked for, and two become design inputs of the prototype.
+ +| Review point | v2 finding | Where |
|---|---|---|
| 1. ClassInfo is not intrinsically immortal | Conceded. The reference graph confirms it: everything outside the identity residue is already soft/weak managed, and DomainReclaim treats ClassInfo death as an anticipated event. | §2 |
| 2. The split-brain argument needs a concrete reachable-object sequence | Provided — and it is sharper than v1's version. Classic sites are held via a soft chain ($callSiteArray), clearing is per-referent, and linked sites bypass the ClassValue, so its reference goes LRU-stale while sites are hot. Naive soft values are therefore genuinely unsafe; resurrection makes the sequence impossible by construction. | §4 |
| 3. Indy must be analysed separately from legacy call sites | Done. Indy resolves and invalidates through the canonical instance, but installed guards keep only the SwitchPoint's internal invoker alive; POJO direct-dispatch guards capture no Groovy object and can outlive their ClassInfo. | §5 |
| 4. "Pristine"/DGM state needs precision: written-once ≠ non-reconstructible | Conceded, and it resolves better than reconstruction: every registration path strongly roots the very ClassInfo it writes into, so collectible ⇒ empty MOP arrays. The prototype turns this from an audited fact into an enforced invariant. | §3 |
| 5. MetaClass/CachedClass state is deliberately recreated already | Conceded. Default MetaClasses are soft-held and recreated today; the identity residue is only version, the strong/per-instance MetaClass slots, and the MOP arrays. | §2, §6 |
6. Should indySwitchPointDomain be owned by a stable per-Class identity? | Yes — implemented (soft mode). Domains are adopted from a weak-Class-keyed map, so a successor ClassInfo shares its predecessor's domain and invalidation reaches straggler guards deterministically. | §5 |
ClassInfo, and how hard| # | Holder → path to ClassInfo | Strength | Lifetime / consequence |
|---|---|---|---|
| 1 | globalClassValue association: Class → per-Class ClassValueMap entry value → ClassInfo (GroovyClassValueJava7) | strong (default mode) | Key-class lifetime — immortal for platform keys: this is the pin (JDK-8136353). Soft mode replaces this edge with a bootstrap SoftReference; the JDK-17 entry otherwise references the defining ClassValue only weakly (ClassValue.Entry extends WeakReference<Version>, value strong, Identity key is a bootstrap object — java.base/java/lang/ClassValue.java:264,304,324), so no strong edge from an immortal key to the Groovy island remains. |
| 2 | Classic call site: caller class → static SoftReference $callSiteArray (CallSiteWriter.java:105-106,173-190) → CallSiteArray → site's final ClassInfo classInfo + final int version (PojoMetaClassSite.java:33-39, same shape in StaticMetaClassSite, MetaClassConstructorSite, …) | strong from the site, but the whole chain is soft | Guard reads version == classInfo.getVersion() from the captured instance — the fast path never touches the ClassValue (§4). Dies with the caller class or when the array's own soft ref clears. |
| 3 | Registry method inventory: GroovySystem → MetaClassRegistryImpl.instanceMethods/staticMethods (never pruned, MetaClassRegistryImpl.java:83-84) → each MetaMethod's declaring CachedClass → CachedClass.classInfo (strong, CachedClass.java:231) | strong | Groovy-loader lifetime. Roots every DGM/extension declaring target (String, Object, Collection, …): §3. Contributes no foreign pinning — it all dies with Groovy itself. |
| 4 | Any strongly-held MetaClass: MetaClassImpl → theCachedClass → classInfo | strong from the MC | Whoever holds a MetaClass holds its ClassInfo — includes indy guards that bind mc (§5). |
| 5 | User-state registries: modifiedExpandos (ClassInfo.java, weak bundle), MetaClassRegistryImpl.metaClassInfo (weak bundle) | weak | Neither roots anything: under the default mode a user-modified ClassInfo survives only via holder #1. This is the silent-loss hazard naive soft values would create, and what the dirty-root set (§6) covers. |
| 6 | Indy installed guard chains: hold the SwitchPoint's internal invoker only, never the SwitchPoint object or its owner (SwitchPointInvalidator.java class javadoc); GroovyObject/MOP paths additionally bind mc (Selector.java:1257 SAME_MC.bindTo(mc), MOP handles bindTo(mc)), POJO direct paths bind only Class objects (Selector.java:1289-1321) | none → strong, path-dependent | A POJO direct-dispatch guard can outlive its ClassInfo — the indy-specific hazard §5 addresses. |
| 7 | GlobalClassSet (ClassInfo.java), DomainReclaim owner ref | weak | Enumeration and post-mortem cleanup only. |
| 8 | (soft mode) resurrection side map in GroovyClassValueSoft: weak identity keys, weak values | weak/weak | Identity authority that keeps nothing alive by itself. |
| 9 | (soft mode, v3) pinned association slot: Class → entry → slot (bootstrap AtomicReference) → ClassInfo (was ClassInfo.nonReclaimableRoots, a global strong set, until §12); IndyInvalidation.CLASS_DOMAINS (weak Class keys → strong domains, no ClassInfo refs) | strong from the key class only (ephemeron) | §5, §6, §12. A pinned instance lives exactly as long as its class — an immortal platform key retains it, a dropped script class releases it with its loader. The v2 global set got the second half wrong (§12). |
Also verified: category state lives in GroovyCategorySupport thread-locals keyed by method name, not in ClassInfo — the ticket description's "category data" concern does not add identity-bearing state.
POGO self-pinning (found by the stress probe's liveness check): generated Groovy classes root their own ClassInfo while the class lives — each POGO instance holds its MetaClass in an instance field and the generated $staticMetaClass field holds it from the Class — chaining strongly through CachedClass.classInfo. So under soft mode the genuinely collectible population is Java/platform-class ClassInfos (plus dead-Class Groovy islands, which is the container-undeploy case itself). This shrinks the recreate-risk surface further: live Groovy classes are never subject to collection/recreation at all.
v1's Failure 1: a recomputed ClassInfo for String would lack its one-time DGM arrays. The review asked whether that state is fundamentally non-reconstructible. The answer is that the question never arises, because of an invariant v1 missed:
+dgmMetaMethods/newMetaMethods is permanently strongly rooted by the registry, and therefore can never be soft-collected. Collectible ⇒ empty MOP arrays ⇒ nothing to reconstruct; a fresh instance is complete as created (inherited DGM methods come from the rooted ancestor/interface ClassInfos through the normal hierarchy walk).Evidence, per registration path — in each case the object retained forever in instanceMethods/staticMethods strongly holds the same CachedClass instance the methods are written into (via ReflectionCache.getCachedClass, which canonicalizes through ClassInfo.getCachedClass()):
GeneratedMetaMethod.Proxy stores declaringClass = the registration map key (MetaClassRegistryImpl.java:285-295, GeneratedMetaMethod.java:43).NewInstanceMetaMethod/NewStaticMetaMethod keep bytecodeParameterTypes[0], which is the map key (MetaClassRegistryImpl.java:306-321, NewMetaMethod.java:35-60).static final CachedClass fields (e.g. NumberNumberMetaMethod.NUMBER_CLASS, ArrayMetaMethod.INTEGER_CLASS).MetaMethod.getDeclaringClass() objects (MetaClassRegistryImpl.DefaultModuleListener).Enforced, not just audited: a third-party ExtensionModule could theoretically supply a MetaMethod whose getDeclaringClass() recomputes. The prototype therefore roots on the write itself: CachedClass.updateSetNewMopMethods/updateAddNewMopMethods call ClassInfo.updateReclaimability(), which adds any ClassInfo holding non-empty MOP arrays to the strong root set. The invariant holds by construction for every possible registration path.
This also inverts v1's pristine-gating objection: the never-pristine platform classes don't need collection protection through the association — they are rooted Groovy-side either way, and that rooting ends with Groovy's loader, which is exactly the lifetime the container fix requires.
+ +The review argued: if a live call site strongly retains ClassInfo A, then A is strongly reachable, so GC cannot clear a soft reference to it — making v1's Failure 2 unconvincing as stated. That objection misses one link, and fixing it produces the concrete sequence the review asked for:
+PojoMetaClassSite.java:33-39). The site is reachable only through the caller's static SoftReference $callSiteArray — so "retention by a live call site" is itself only soft reachability.classInfo.getVersion() from the captured instance (PojoMetaClassSite.java:58) — the ClassValue is never touched, so under naive soft values its SoftReference<A> goes LRU-stale precisely while the site is hottest.SoftReference<A> while keeping the fresh SoftReference<CallSiteArray> (touched every caller invocation).getClassInfo(String) → fresh B is created.So naive soft values are indeed unsafe — v1's conclusion, now with the demonstration it lacked. The structural fix:
+GroovyClassValueSoft stores values behind bootstrap SoftReferences but owns identity in a weak-key/weak-value side map. computeValue consults the map first: a value still reachable anywhere (the site chain above is weak-visible) is re-associated as-is. A fresh instance can only be created once the old one is weakly unreachable — a state in which no guard, cache, or call site can ever observe it again. Two live generations of one association cannot arise; version continuity is automatic; step 4 above returns A and steps 5–6 behave exactly as today. The cleared-wrapper retry uses the GROOVY-12280 pattern (one remove-and-recompute, then an identity-safe uncached fallback served from the side map). ClassInfo.remove(Class) remains a hard detach — it purges the identity entry (and the dirty root), preserving the documented undeploy semantics.The forced-clear tests exercise a superset of GC behaviour (a collector never clears a reference to a strongly reachable object; the tests do), so the passing probe covers every clearing schedule, including the sequence above — ClassInfoSoftModeProbe.classicCallSiteStaysSoundAcrossClearAndRelinksOnChange is that sequence verbatim, and fails without resurrection.
Capture audit (Selector.setGuards, Selector.java:1249-1328): GroovyObject receivers install SAME_MC.bindTo(mc) and MOP fallbacks bind mc/method — those chains strongly reach the ClassInfo (holder #4/#6). But POJO direct dispatch — exactly the platform-receiver traffic this ticket is about — installs a bare direct MethodHandle guarded by Class-binding tests plus the class-domain SwitchPoint, and an installed guard keeps only the SwitchPoint's internal invoker reachable, not the SwitchPoint object or its owner (SwitchPointInvalidator javadoc, LIVE-registry design). Conclusion: an indy site can outlive its ClassInfo, confirming the review's request to analyse indy separately.
Under soft values that would reopen a window v1 only half-identified: A dies, its domain's retirement waits on the lazy weak-bundle pump (DomainReclaim), successor B gets a fresh domain, and a mutation through B never reaches the straggler guard until the pump runs — unbounded stale dispatch.
Fix (implemented, soft mode only): domain re-homing. The domain becomes a per-Class object: ClassInfo.indyDomain() resolves through IndyInvalidation.classDomainFor(type, localDomain) (weak identity Class keys → strong domains; the map holds no classes and no ClassInfos). The first ClassInfo seeds it; a successor adopts it; every invalidation path already funnels through the canonical instance's four domain methods, so a mutation through B deterministically retires guards linked under A. Domain retirement moves from ClassInfo-death to Class-death (ClassDomainReclaim), because instances are replaceable and classes are not — which also keeps the LIVE SwitchPoint registry free of zombies. ClassInfoSoftModeProbe.indyDomainContinuityAcrossRecreation verifies the predecessor's SwitchPoint is invalidated by a mutation through the successor; it fails without re-homing.
Classic-callsite deprecation path: this improvement is deliberately indy-only. Classic sites need no equivalent machinery — resurrection preserves the very instance their guards captured — so nothing breaks when legacy-compiled jars put groovy-callsite on the classpath, and indy design is not constrained by the deprecated path.
What remains identity-bound after §3–§5: an installed class-level MetaClass (strongMetaClass — user EMCs land only there; modifiedExpandos is weak, and the registry keeps no strong side map — MetaClassRegistryImpl.setMetaClass) and per-instance MetaClasses (perInstanceMetaClassMap, whose instances do not back-reference the ClassInfo). Losing either to soft collection would silently discard user customizations — the ticket description's core caveat, confirmed.
Fix (implemented; revised in v3 — §12): ClassInfo.updateReclaimability() evaluates the condition strongMetaClass set ∨ per-instance MetaClasses present ∨ non-empty MOP arrays on every mutation of those slots (setStrongMetaClass, setWeakMetaClass, setPerInstanceMetaClass, the two CachedClass MOP writers, and finalizeReference/clearModifiedExpandos via setStrongMetaClass(null)) and pins or unpins the instance inside its own association (GroovyClassValue.pin/unpin): the association's slot flips between a bootstrap SoftReference and the value itself. Because the strong hold lives in the entry — an ephemeron — a pinned instance has exactly a plain ClassValue association's lifetime: an immortal platform key retains it (it must — the state is not reconstructible), a dropped script class releases it together with its loader. v2's global strong root set got that second half wrong (the "reverse" leak, §12). Removal is conservative (lingering weak per-instance entries only delay unpinning — the safe direction). Weak-held default MetaClasses are not pinned: they are soft-collectible and recreated on demand today, so soft mode changes nothing for them — the review's point 5 exactly.
| Piece | Where | Role |
|---|---|---|
GroovyClassValueSoft | org.codehaus.groovy.reflection (new) | ClassValue of per-Class slots (bootstrap AtomicReference holding a bootstrap SoftReference, or the value itself when pinned — v3); weak/weak canonical side map; striped creation locks (the map's putIfAbsent reports success without storing over a collected-value entry, so the miss path double-checks under a lock and uses an unconditional put); getIfPresent for non-creating inspection. |
| Mode selection + capability | GroovyClassValueFactory, GroovyClassValue | groovy.use.classvalue=soft; consumers never ask the factory for the mode (v3) — they query the created store's capability (valuesReclaimable(), default false) and use the default-no-op pin/unpin contract. |
| Ephemeron pinning (ex "dirty roots") | ClassInfo.updateReclaimability() → GroovyClassValue.pin/unpin; hooks in CachedClass | §3 invariant enforcement + §6 user-state protection, held inside the association (v3, §12). ClassInfo.remove() drops the pin with the association — no store-specific handling. |
| Domain continuity | ClassInfo.indyDomain(); IndyInvalidation.classDomainFor / anchorClassDomainToClass / ClassDomainReclaim / CLASS_DOMAINS | §5. Default mode keeps today's per-instance domain + DomainReclaim unchanged. |
Default-mode behaviour is byte-identical except for no-op hook calls; all soft-mode structures are unallocated or unpopulated unless the flag is set.
+ +GroovyClassValueSoftTest (9 tests, v3): memoization; resurrection identity without recompute; fresh-after-true-death; hard-detach remove(); non-creating getIfPresent; the valuesReclaimable capability; pin holds the value in its own slot until unpin; remove() releases the pin; foreign unpin is a no-op.ClassInfoSoftModeTest → child-JVM ClassInfoSoftModeProbe (8 scenarios under -Dgroovy.use.classvalue=soft, v3): resurrection identity + version continuity; DGM-target pinned in its slot; EMC pins and its removal unpins; per-instance MetaClass pins; pristine ClassInfo truly collected then recreated with working dispatch; classic CallSiteArray soundness across clear + EMC change (the §4 sequence, on a clean POJO receiver — a pinned receiver has no clearable reference); predecessor SwitchPoint retirement across recreation (the §5 window); the reverse scenario — an EMC-dirty script class dies with its loader under memory pressure (§12).groovy.lang MOP suite (1070) — all pass.:test suite run with -Pgroovy.use.classvalue=soft (forwarded to test JVMs by the build-logic change on this branch) — 16,800 passed, 66 skipped, 0 failed.ClassInfoSoftModeStressTest → child-JVM ClassInfoSoftModeStressProbe, 128 MB heap, -XX:SoftRefLRUPolicyMSPerMB=0 so every collection clears unprotected soft references): 20 s of concurrent indy + classic dispatch racing the collector — 2 POGO dispatchers (megamorphic site over 100 Groovy classes), 1 Java-receiver dispatcher (36 platform classes), 2 indy + 1 classic dispatcher on an EMC-mutated platform class through 129 generations, plus allocation pressure. Result: 66.6M dispatches, 33/36 Java-receiver ClassInfos genuinely collected and recreated mid-run, zero invariant violations — no lost customization, per-thread generation monotonicity held, the dirty-rooted ClassInfo kept its identity throughout, and every dispatcher (classic included) converged on the final generation under continuing pressure. Unlike the deterministic probe, this exercises clearing at collector-chosen instants against the resurrection lock, reclaimability updates and domain adoption.Groovy12281LoaderSpike.java, on the branch)Groovy loaded in a child URLClassLoader, platform-receiver-heavy script run, loader dropped; JDK 17, -Xmx256m. "After pressure" = allocate-to-OOME so soft references are cleared (their contract); residual soft-reachable paths (e.g. the category thread-local) delay collection until pressure in all modes, so pressure is the correct acceptance condition.
| Mode | Collected after plain GC | Collected after soft-clearing pressure |
|---|---|---|
true — default ClassValue (today) | no | no — pinned forever (reproduces GROOVY-12142) |
soft — Option E | no | yes — unpinned |
false — map escape hatch | no | yes — unpinned (control) |
Indicative harness (Groovy12281PerfSpike.groovy, on the branch; 3 JVMs × 7 rounds, indy codegen):
| ns/op (median of run-medians) | ClassValue (today) | soft (E) | map (escape hatch) |
|---|---|---|---|
micro: getClassInfo(String) hot loop | 1.18 | 1.74 (+0.56) | 4.70 |
micro: getClassInfo, 5 mixed keys | 44.5 | 49.4 (+11%, loop-dominated) | 49.3 |
| macro: String-heavy dynamic loop | 90.7 | 85.5 (parity/noise) | 102.9 (+13%) |
| macro: POGO dynamic loop | 8.95 | 8.50 (parity/noise) | 9.03 |
Compiler-performance harness (4-file corpus pleac02–04 + script.groovy, 50 warmup + 300 rounds, modes alternated per run, 3 runs each): ClassValue ≈ 19.56 ms overall vs soft ≈ 19.54 ms — +0.0%, parity (per-run means 21.98/18.12/18.57 vs 21.40/18.52/18.71, σ ≈ 3–5 ms).
+JMH classic-bytecode dispatch gate (-Pindy=false fat jar; Callsite/Fibo/DynamicDispatchCold benchmarks; annotation defaults; -jvmArgsAppend -Dgroovy.use.classvalue=…; significance = disjoint 99.9% CIs, the same pre-set rule that declined Option D):
| soft vs ClassValue (classic bytecode) | Result |
|---|---|
| Geomean over 37 benchmarks (16 dispatch-cold, 10 Fibo, 11 call-site dispatch) | +1.7% |
| Statistically significant deltas (disjoint CIs) | 0 of 37 — gate passes (Option D failed this rule with 12, worst 1.38×) |
| Noise floor evidence | pure-Java benchmarks (mode-independent) moved up to ±10% between sweeps |
| Focused re-measurement of the two directional outliers (poly/mega classic dispatch): one 4-fork paired run showed poly +17% significant but mega −7% (contradicting a systematic mechanism), so poly was re-run as three alternating 2-fork pairs | poly pooled +3.7% (per-pair +0.3% / +8.1% / +3.0%, all CIs overlapping); consistent with one SoftReference.get per classic inline-cache miss (~42 misses per 64-dispatch op). A low-single-digit cost on classic polymorphic miss traffic is plausible and should be re-checked in the idiom-suite sweep before any default flip; irrelevant to indy bytecode, whose linked sites do not call getClassInfo per miss. |
Memory: one bootstrap SoftReference per association, one weak/weak side-map entry per touched class, root-set entries bounded by classes with user MetaClass state or MOP arrays (≈ hundreds) — all Groovy-owned.
What Groovy 6 would offer in this area, consolidated for the adoption discussion:
+true — default | soft — new opt-in (Option E) | false — map escape hatch | |
|---|---|---|---|
| Store | java.lang.ClassValue, strong values | ClassValue fast path; SoftReference values + resurrection + dirty roots + per-Class indy domains | weak-key identity map, strong values |
| Groovy loader unpinned on undeploy | no — leaks per redeploy (JDK-8136353) | yes — collected under memory pressure | yes — structurally (no platform-key association) |
| Release promptness | n/a (never) | pressure-driven (soft-reference contract; -XX:SoftRefLRUPolicyMSPerMB tunes it) | at loader death |
Raw getClassInfo | 1.2 ns | 1.7 ns | 4.7 ns |
| ClassInfo ever collected/recreated? | never | only pristine platform-class ClassInfos and dead-Class islands (POGOs self-pin §2; user MetaClass state dirty-rooted §6; DGM targets registry-rooted §3) | never |
Orthogonal to the mode: ClassInfo.remove(Class) remains the programmatic prompt detach for undeploy hooks, with identical semantics in all three modes (in soft mode the pin travels with the association, so remove() needs no store-specific handling). The hybrid selector stays a declined prototype on this branch — not a shipped option.
@CompileStatic) code: direct invocations — all three modes perf-indistinguishable (compile +0.0% soft / +1% map). Mode choice is lifecycle-only, and it still matters: the registry writes DGM state into platform-class ClassInfos at startup, so even a fully static app pins under true.groovy-callsite): the linked-site fast path is identical in all modes — the version guard reads the captured instance, not the store (§2 holder #2). Miss/relink traffic pays: soft ≈ +3.7% pooled on polymorphic sites (below the significance gate, §8.3); map = the v1 verdict (+4.8% geomean, platform-receiver idioms to 1.38×). Correctness under soft is carried by resurrection and verified by the classic dispatchers in both probes — the deprecation path for classic call sites is not constrained by, and does not constrain, the soft design.true.true, optionally ClassInfo.remove() on undeploy for promptness.soft: essentially default performance, unpins under pressure.false (cost quantified above) plus remove().The guide itself is deliberately not touched on this branch; this section is the draft content for it.
+ +Entry<T> extends WeakReference<Version<T>> in all three) and confirmed empirically by the §8.2 spike on all three (default PINNED / soft UNPINNED in each). Re-check on 25 via CI when the branch gets a PR; the shape is load-bearing, so any future JDK rewrite of ClassValue internals (e.g. the JEP for ClassValue improvements, if revived) should re-trigger this verification.ClassInfo.remove() remains the prompt remedy and its semantics are unchanged.subprojects/performance) for soft-vs-ClassValue before proposing any default change. The in-repo gates above are necessary but not sufficient.=false once soaked. Guide update deliberately deferred until the mode's fate is decided.=false at 0/20 loaders collected on current master while released 5.0.6 and 6.0.0-beta-2 collect 20/20 — the GROOVY-12142 escape-hatch rework's weak-key/strong-value map is not an ephemeron, so a value reaching its own key (ClassInfo → installed MetaClass → theClass) revives the key forever. Pre-existing relative to PR #2820; JIRA text drafted, to be filed against the 12142 change.Unchanged from v1: GROOVY-12280 is the worked SoftReference example this design's retry loop borrows in shape (sentinel-free here since ClassValue memoizes the wrapper; one remove-and-recompute then an uncached identity-safe fallback); no shared helper, per the groovy-concurrent-java layering constraint recorded on both tickets. Options B (hybrid) and D (map default) remain declined on the v1 measurements; the hybrid prototype stays on the branch as the measured artifact.
Jochen's PR review posed one scenario question ("the reverse memory pressure test") and six inline comments. The scenario question found a real leak; three of the inline comments share one structural answer with it.
+ +The question: spin up many GroovyShells whose scripts set an EMC on classes they create; on memory pressure the old script instances and their metaclasses should be collectable while the Groovy runtime stays alive (the "reverse" of the §8.2 acceptance test, where the runtime itself is dropped).
+The finding: under the v2 design the expectation failed. The probe (20 × GroovyClassLoader → parseClass → install EMC → drop → pressure → GC):
+| Configuration | Loaders collected |
|---|---|
default true (branch and released) | 20/20 |
soft, v2 (global root set) | 0/20 |
soft, v2, no EMC installed (clean classes) | 20/20 |
soft, v2, EMC + explicit ClassInfo.remove() | 20/20 |
soft, v3 (ephemeron pinning) | 20/20 |
false on current master (and on the branch) | 0/20 — pre-existing, separate ticket (§10) |
false on released 5.0.6 / 6.0.0-beta-2 | 20/20 |
The diagnosis: v2's nonReclaimableRoots was a global strong set. Its justifying comment — "everything in it dies with Groovy's own loader, which is the lifetime every ClassInfo has today" — is true for immortal platform keys but false for collectible keys: a script class's today-lifetime is its class's lifetime, because the default ClassValue association is an ephemeron (it dies with its key). The set therefore extended every EMC-dirty script class — root → ClassInfo → strongMetaClass → MetaClassImpl.theClass (strong final) → class → loader — to the runtime's lifetime. The clean-class and remove() rows isolate the cause exactly.
The fix: move the pin into the association. The store's per-Class slot (a bootstrap AtomicReference) holds either a bootstrap SoftReference (reclaimable) or the value itself (pinned); pin/unpin flip it (pin retries across a concurrent remove-and-recompute; unpin is a CAS so a foreign value cannot downgrade the slot). The strong hold is then reachable only from the key class — exactly a plain ClassValue association's lifetime, which is what §6's protection always meant. The bootstrap-loading constraint is preserved: an unpinned association on an immortal key still keeps nothing Groovy-loaded strongly reachable.
Test-construction findings (worth recording for future GC probes): the in-probe scenario initially failed for two reasons unrelated to the mode — the probe class's own invokedynamic call-site guards retain the first-linked argument class (receiver-side inline caching, present identically in every mode; the scenario is now @CompileStatic so no such sites exist), and the loop frame's stale local slots retain the last iteration's loader through the collection loop (locals now nulled). Both retainers were located by ablation against a driver harness; neither involves the association machinery. Collection is asserted after allocate-to-OOME pressure, since generic soft caches (lazy CachedClass/loader references) legitimately retain the island under mild GC in every mode — which is also precisely the "on memory pressure" phrasing of the review question.
| Comment | Position | Response (implemented) |
|---|---|---|
IndyInvalidation vs SwitchPointInvalidator: which is the abstraction boundary? Document, or hide the mechanism. | Agreed — layered, now explicit | They are one layered subsystem: SwitchPointInvalidator is the policy-free mechanism (one domain's SwitchPoint lifecycle + the live registry), IndyInvalidation the policy layer (width, reasons, anchoring, per-Class continuity), with ClassInfo the only other supported consumer (domain owner; local operations). Java visibility cannot enforce this across packages, so both classes now carry a "Layering" javadoc section naming the supported consumers and the guarantees each level provides alone, and both are annotated @Internal. (Most of the machinery is GROOVY-12191's; the question spans both changes.) |
ClassInfo.isSoftMode() is suspicious — why is ClassValue+SoftReference a "soft mode" but the ManagedMap version not? | Agreed — wrong abstraction | The property ClassInfo actually depends on is "can a value be collected while its key class is still alive?" — false for the strong ClassValue and for the map (whose values die with their class), true only for soft. GroovyClassValue now exposes it as a capability (valuesReclaimable(), default false) plus the default-no-op pin/unpin contract; ClassInfo queries its own store and the factory's isSoftMode() is gone. |
| "What happens if we always do that and not only in soft mode?" — either always (missing abstraction) or simplify | Agreed — abstraction was missing | With pin/unpin on the interface, ClassInfo.updateReclaimability() now runs unconditionally in every mode (no-ops elsewhere), and ClassInfo.remove() lost its store-specific unroot branch entirely. Always maintaining the v2 set in all modes would have been wrong: in =false mode it would add pinning that mode exists to avoid; in default mode, pure overhead. |
| Why not store soft mode as a boolean? | Agreed | The factory now stores parsed booleans; the per-call equalsIgnoreCase is gone, and the mode string never leaves the factory. |
| Boolean or provider-style selection for hybrid too | Noted — low stakes | Hybrid is a declined investigation artifact not intended to land (the PR lands only the soft commit), so the shipped factory has three arms; provider-style is fine if preferred on the landing commit. |
Does the ClassInfo case really need SoftReference, or would WeakReference do? Should strength be decided at this level? ("could stay like this for now") | Soft is deliberate | Weak values would be cleared at every minor GC whenever nothing else references the instance (the canonical side map is weak-valued too), so platform-receiver ClassInfos would churn through recreation between collections — losing the cache behaviour the mode exists to preserve. Soft approximates "collect only under pressure", the ticket's acceptance criterion. With the v3 slot design the strength choice is localized in GroovyClassValueSoft and could become a policy parameter later if ever needed. |
+ * java -Xmx256m Groovy12281LoaderSpike.java build/libs/groovy-6.0.0-SNAPSHOT.jar true # expect PINNED + * java -Xmx256m Groovy12281LoaderSpike.java build/libs/groovy-6.0.0-SNAPSHOT.jar soft # expect UNPINNED + * java -Xmx256m Groovy12281LoaderSpike.java build/libs/groovy-6.0.0-SNAPSHOT.jar false # expect UNPINNED (map control) + *+ * + * Soft references are only guaranteed cleared before OOME, so the spike + * applies allocation pressure to its own heap after dropping the loader; + * "collectedBeforePressure" records whether plain GCs sufficed. + */ +public final class Groovy12281LoaderSpike { + + private Groovy12281LoaderSpike() { + } + + public static void main(String[] args) throws Exception { + Path jar = Path.of(args[0]).toAbsolutePath(); + String mode = args.length > 1 ? args[1] : "true"; + // Set before any child class initializes; the child copy's + // GroovyClassValueFactory reads it during class initialization. + System.setProperty("groovy.use.classvalue", mode); + + WeakReference
+ * The map holds no classes alive (weak keys) and no foreign loaders
+ * (values reference nothing owner-ward); entries for dead classes are
+ * purged by the map, and their domains retired by the Class-keyed reclaim
+ * ({@link #anchorClassDomainToClass}) so the live-SwitchPoint registry
+ * cannot accumulate zombies.
+ */
+ private static final ManagedIdentityConcurrentMap
+ * Default mode: the instance-owned {@link #indySwitchPointDomain}, whose
+ * lifetime equals this ClassInfo's — sound because the strong ClassValue
+ * keeps the instance alive as long as its class.
+ *
+ * Reclaimable values (GROOVY-12281, soft mode): the per-Class domain from
+ * {@link IndyInvalidation#classDomainFor}, seeded with the local domain on
+ * first touch. Domain identity then survives ClassInfo recreation: a
+ * successor instance adopts its predecessor's domain, so a MetaClass
+ * change applied through the successor deterministically retires guards
+ * that were linked under the predecessor (POJO direct-dispatch guards
+ * capture only the SwitchPoint invoker, never this instance, so they can
+ * outlive it — the lazy {@code DomainReclaim} pump alone would leave an
+ * unbounded stale-guard window). When the class itself has been collected
+ * no receiver can reach any guard, so the local domain suffices.
+ */
+ private SwitchPointInvalidator indyDomain() {
+ if (RECLAIMABLE_CLASS_VALUES) {
+ Class> type = getTheClass();
+ if (type != null) {
+ return IndyInvalidation.classDomainFor(type, indySwitchPointDomain);
+ }
+ }
+ return indySwitchPointDomain;
+ }
+
/**
* Returns the SwitchPoint for monomorphic indy MOP guards on this class:
* the current generation of the class-level domain, which covers both the
@@ -199,11 +237,22 @@ private void bumpGenerationLocal() {
*/
@Internal
public SwitchPoint getIndySwitchPoint() {
+ SwitchPointInvalidator domain = indyDomain();
if (!indyDomainAnchored) {
- IndyInvalidation.anchorClassDomain(this, indySwitchPointDomain);
+ if (RECLAIMABLE_CLASS_VALUES) {
+ // Reclaimable values retire domains when the Class dies, not
+ // when a ClassInfo instance does: instances are replaceable
+ // (their successor adopts the same domain), classes are not.
+ Class> type = getTheClass();
+ if (type != null) {
+ IndyInvalidation.anchorClassDomainToClass(type, domain);
+ }
+ } else {
+ IndyInvalidation.anchorClassDomain(this, indySwitchPointDomain);
+ }
indyDomainAnchored = true;
}
- return indySwitchPointDomain.getSwitchPoint();
+ return domain.getSwitchPoint();
}
/**
@@ -215,7 +264,7 @@ public SwitchPoint getIndySwitchPoint() {
*/
@Internal
public void invalidateIndySwitchPoint() {
- indySwitchPointDomain.invalidate();
+ indyDomain().invalidate();
}
/**
@@ -227,7 +276,7 @@ public void invalidateIndySwitchPoint() {
*/
@Internal
public void collectLiveIndySwitchPoints(final List
+ * Correctness relies on resurrection: a weak-key/weak-value side map
+ * is the identity authority. {@code computeValue} consults it first, so when
+ * the slot's soft reference has been cleared but the value is still reachable
+ * anywhere — for example captured by a linked call site — the same instance
+ * is re-associated rather than a fresh one created. A fresh instance can only
+ * be created once the old one is weakly unreachable, at which point no guard,
+ * cache or call site can still observe the old instance, so "two live
+ * generations of one association" cannot arise.
+ *
+ * {@linkplain #pin Pinning} flips a slot to hold its value strongly, giving
+ * the value exactly a plain {@code ClassValue} association's lifetime: as
+ * long as its key class, and no longer. Because the strong hold lives inside
+ * the association (an ephemeron), a pinned value on a collectible key — for
+ * example an EMC-carrying script class — is released together with its class
+ * and loader; a global strong root would instead extend it to the runtime's
+ * lifetime (the "reverse" leak raised in review of PR #2820).
+ *
+ * {@link #remove(Class)} stays a hard detach (the identity entry and
+ * any pin go too), preserving the documented undeploy semantics of
+ * {@link ClassInfo#remove(Class)}: the next {@code get} creates a fresh value
+ * even if the old one is still reachable somewhere.
+ *
+ * @param Layering
+ * This is the mechanism half of the MOP invalidation subsystem and
+ * makes no policy decisions. It has exactly two supported consumers:
+ * {@link org.codehaus.groovy.reflection.ClassInfo}, which owns domain
+ * instances (allocation, local invalidate, detach), and
+ * {@link IndyInvalidation}, which owns invalidation width policy, reclaim
+ * anchoring and per-Class domain continuity. On its own this class guarantees
+ * only the single-domain lifecycle documented above (single-use SwitchPoints;
+ * registration precedes publication); guarantees about domain identity across
+ * ClassInfo recreation exist only under {@link IndyInvalidation}'s
+ * management. It is not an extension point and may change incompatibly.
+ *
* @since 6.0.0
*/
+@Internal
public final class SwitchPointInvalidator {
/**
diff --git a/src/main/java/org/codehaus/groovy/reflection/CachedClass.java b/src/main/java/org/codehaus/groovy/reflection/CachedClass.java
index d5d37f3297d..7c6fcb33948 100644
--- a/src/main/java/org/codehaus/groovy/reflection/CachedClass.java
+++ b/src/main/java/org/codehaus/groovy/reflection/CachedClass.java
@@ -572,6 +572,8 @@ private void updateSetNewMopMethods(List