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 @@ + + + + + + +GROOVY-12281 Assessment v2 — ClassInfo.globalClassValue Loader Pinning + + + + +

GROOVY-12281 Assessment v2 — ClassInfo.globalClassValue Loader Pinning

+

Investigation 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

+ +
+

Verdict (v2)

+

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.

+
+ +

1. What the review changed

+

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 pointv2 findingWhere
1. ClassInfo is not intrinsically immortalConceded. 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 sequenceProvided — 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 sitesDone. 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-reconstructibleConceded, 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 alreadyConceded. 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
+ +

2. Reference-graph census — who holds a ClassInfo, and how hard

+ + + + + + + + + + + +
#Holder → path to ClassInfoStrengthLifetime / consequence
1globalClassValue 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.
2Classic 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 softGuard 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.
3Registry method inventory: GroovySystemMetaClassRegistryImpl.instanceMethods/staticMethods (never pruned, MetaClassRegistryImpl.java:83-84) → each MetaMethod's declaring CachedClassCachedClass.classInfo (strong, CachedClass.java:231)strongGroovy-loader lifetime. Roots every DGM/extension declaring target (String, Object, Collection, …): §3. Contributes no foreign pinning — it all dies with Groovy itself.
4Any strongly-held MetaClass: MetaClassImpltheCachedClassclassInfostrong from the MCWhoever holds a MetaClass holds its ClassInfo — includes indy guards that bind mc (§5).
5User-state registries: modifiedExpandos (ClassInfo.java, weak bundle), MetaClassRegistryImpl.metaClassInfo (weak bundle)weakNeither 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.
6Indy 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-dependentA POJO direct-dispatch guard can outlive its ClassInfo — the indy-specific hazard §5 addresses.
7GlobalClassSet (ClassInfo.java), DomainReclaim owner refweakEnumeration and post-mortem cleanup only.
8(soft mode) resurrection side map in GroovyClassValueSoft: weak identity keys, weak valuesweak/weakIdentity 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.

+ +

3. Review Q1/Q4/Q6 — the registry-rooting invariant kills Failure 1

+

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:

+
Any ClassInfo with non-empty 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()):

+ +

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.

+ +

4. Review Q2/Q3 — the split-brain sequence, and why resurrection ends it

+

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:

+
    +
  1. A caller links a classic site capturing ClassInfo A and its version (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.
  2. +
  3. Steady-state calls run through the linked site, whose guard reads 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.
  4. +
  5. Soft-reference clearing is per-referent: the collector may clear the stale SoftReference<A> while keeping the fresh SoftReference<CallSiteArray> (touched every caller invocation).
  6. +
  7. Any other code path calls getClassInfo(String) → fresh B is created.
  8. +
  9. A user installs an EMC — the registry resolves B, bumps B's version, retires B's domain.
  10. +
  11. The surviving site still guards A, whose version never changes again: it accepts forever, silently dispatching pre-EMC behaviour.
  12. +
+

So naive soft values are indeed unsafe — v1's conclusion, now with the demonstration it lacked. The structural fix:

+
Resurrection. 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.

+ +

5. Review Q3/Q5 — indy: capture audit and per-Class domain continuity

+

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.

+ +

6. Review Q1 residue — the dirty-root set for genuinely non-reconstructible state

+

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.

+ +

7. Option E design summary

+ + + + + + +
PieceWhereRole
GroovyClassValueSoftorg.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 + capabilityGroovyClassValueFactory, GroovyClassValuegroovy.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 continuityClassInfo.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.

+ +

8. Results

+

8.1 Correctness

+ +

8.2 Acceptance — container-undeploy loader collection (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.

+ + + + + +
ModeCollected after plain GCCollected after soft-clearing pressure
true — default ClassValue (today)nono — pinned forever (reproduces GROOVY-12142)
soft — Option Enoyes — unpinned
false — map escape hatchnoyes — unpinned (control)
+

8.3 Cost (JDK 17, fixed 512 MB heap, idle machine; fresh JVM per run; medians)

+

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 loop1.181.74 (+0.56)4.70
micro: getClassInfo, 5 mixed keys44.549.4 (+11%, loop-dominated)49.3
macro: String-heavy dynamic loop90.785.5 (parity/noise)102.9 (+13%)
macro: POGO dynamic loop8.958.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 evidencepure-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 pairspoly 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.

+ +

9. The resulting option landscape (if adopted as opt-in)

+

What Groovy 6 would offer in this area, consolidated for the adoption discussion:

+ + + + + + + +
true — defaultsoft — new opt-in (Option E)false — map escape hatch
Storejava.lang.ClassValue, strong valuesClassValue fast path; SoftReference values + resurrection + dirty roots + per-Class indy domainsweak-key identity map, strong values
Groovy loader unpinned on undeployno — leaks per redeploy (JDK-8136353)yes — collected under memory pressureyes — structurally (no platform-key association)
Release promptnessn/a (never)pressure-driven (soft-reference contract; -XX:SoftRefLRUPolicyMSPerMB tunes it)at loader death
Raw getClassInfo1.2 ns1.7 ns4.7 ns
ClassInfo ever collected/recreated?neveronly 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.

+

9.1 Cost by code style

+ +

9.2 Guidance sketch (for the integration guide, once the mode's fate is decided)

+ +

The guide itself is deliberately not touched on this branch; this section is the draft content for it.

+ +

10. Residual risks and review questions

+ + +

11. Relationship to companion work

+

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.

+ +

12. Review round 2 — PR #2820 (Jochen, 20 Aug 2026) and response (v3)

+

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.

+ +

12.1 The reverse scenario — a real leak, found and fixed

+

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):

+ + + + + + + + + +
ConfigurationLoaders 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-220/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 → strongMetaClassMetaClassImpl.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.

+ +

12.2 Inline comments

+ + + + + + + + +
CommentPositionResponse (implemented)
IndyInvalidation vs SwitchPointInvalidator: which is the abstraction boundary? Document, or hide the mechanism.Agreed — layered, now explicitThey 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 abstractionThe 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 simplifyAgreed — abstraction was missingWith 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?AgreedThe 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 tooNoted — low stakesHybrid 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 deliberateWeak 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.
+ + + + + diff --git a/Groovy12281LoaderSpike.java b/Groovy12281LoaderSpike.java new file mode 100644 index 00000000000..c021ac985e9 --- /dev/null +++ b/Groovy12281LoaderSpike.java @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import java.lang.ref.WeakReference; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * GROOVY-12281 acceptance spike (manual, not CI): does a dropped Groovy + * runtime's class loader become collectable under each global ClassValue mode? + * This reproduces the container topology from GROOVY-12142: a Groovy copy is + * loaded in a child loader, runs a script whose dynamic dispatch creates + * ClassValue associations on immortal platform classes (String, Integer, + * ArrayList, ...), and is then dropped. + * + *
+ *   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 loaderRef = loadRunAndDrop(jar); + + gc(10); + boolean collectedBeforePressure = loaderRef.get() == null; + + applySoftClearingPressure(); + gc(20); + boolean collected = loaderRef.get() == null; + + System.out.println("mode=" + mode + + " collectedBeforePressure=" + collectedBeforePressure + + " collectedAfterPressure=" + collected); + System.out.println(collected ? "UNPINNED" : "PINNED"); + } + + private static WeakReference loadRunAndDrop(Path jar) throws Exception { + URLClassLoader child = new URLClassLoader("groovy-under-test", + new URL[]{jar.toUri().toURL()}, ClassLoader.getPlatformClassLoader()); + Class shellClass = Class.forName("groovy.lang.GroovyShell", true, child); + Object shell = shellClass.getConstructor().newInstance(); + Object result = shellClass.getMethod("evaluate", String.class).invoke(shell, + // platform-receiver-heavy dispatch: String, Integer, Range, ArrayList, GString + "def s = 'abc'.reverse()\n" + + "def total = (1..5).collect { it * 2 }.sum()\n" + + "def m = [a: 1, b: 2]\n" + + "\"${s}:${total}:${m.a + m.b}\".toString()"); + if (!"cba:30:3".equals(result)) { + throw new IllegalStateException("unexpected script result: " + result); + } + System.out.println("script result: " + result + " (child Groovy active)"); + child.close(); + return new WeakReference<>(child); + } + + private static void gc(int rounds) throws InterruptedException { + for (int i = 0; i < rounds; i++) { + System.gc(); + Thread.sleep(50); + } + } + + /** + * Allocates until OutOfMemoryError, forcing the collector to clear all + * soft references first (JLS guarantee), then releases everything. + */ + private static void applySoftClearingPressure() { + List hog = new ArrayList<>(); + try { + while (true) { + hog.add(new byte[1 << 20]); + } + } catch (OutOfMemoryError expected) { + hog.clear(); + } + System.out.println("soft-clearing pressure applied"); + } +} diff --git a/Groovy12281PerfSpike.groovy b/Groovy12281PerfSpike.groovy new file mode 100644 index 00000000000..0ae031f6c2d --- /dev/null +++ b/Groovy12281PerfSpike.groovy @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* + * GROOVY-12281 indicative perf spike (manual, not CI; treat as indicative — + * the JMH suite remains the heavyweight gate). Measures the global + * ClassValue mode's cost on the paths soft mode touches: + * - micro: ClassInfo.getClassInfo hot loop (the extra SoftReference deref) + * - macro: dynamic dispatch loops (String-heavy and POGO) + * + * Run per mode, fresh JVM each, idle machine, e.g.: + * for m in true soft false; do + * java -Xms512m -Xmx512m -cp build/libs/groovy-6.0.0-SNAPSHOT.jar \ + * -Dgroovy.use.classvalue=$m groovy.ui.GroovyMain Groovy12281PerfSpike.groovy + * done + */ + +import groovy.transform.CompileStatic +import org.codehaus.groovy.reflection.ClassInfo + +@CompileStatic +class Micro { + static long hotLoop(int iters) { + long acc = 0 + for (int i = 0; i < iters; i++) { + acc += System.identityHashCode(ClassInfo.getClassInfo(String)) + } + acc + } + + static long mixedLoop(int iters) { + long acc = 0 + Class[] keys = [String, Integer, ArrayList, LinkedHashMap, Micro] as Class[] + for (int i = 0; i < iters; i++) { + acc += System.identityHashCode(ClassInfo.getClassInfo(keys[i % keys.length])) + } + acc + } +} + +class Pogo { + int value + def bump(int n) { value += n; value } +} + +def stringLoop = { int iters -> + def s = 'abcdef' + def acc = 0 + for (int i = 0; i < iters; i++) { + acc += s.reverse().size() + "x${i & 7}".size() + } + acc +} + +def pogoLoop = { int iters -> + def p = new Pogo() + def acc = 0 + for (int i = 0; i < iters; i++) { + acc += p.bump(1) - p.value + i + } + acc +} + +static List medianTimes(int rounds, int iters, Closure work) { + def times = [] + for (int r = 0; r < rounds; r++) { + def t0 = System.nanoTime() + work(iters) + times << (System.nanoTime() - t0) / (double) iters + } + times.sort() + times +} + +def mode = System.getProperty('groovy.use.classvalue', 'true') +int microIters = 20_000_000 +int macroIters = 200_000 +int rounds = 7 + +// warmup +Micro.hotLoop(microIters); Micro.mixedLoop(microIters) +stringLoop(macroIters); pogoLoop(macroIters) + +def report = { String label, List t -> + printf('%s mode=%s median=%.2f ns/op (min=%.2f max=%.2f)%n', + label, mode, t[t.size().intdiv(2)], t.first(), t.last()) +} + +report('micro.getClassInfo(String) ', medianTimes(rounds, microIters, Micro.&hotLoop)) +report('micro.getClassInfo(mixed) ', medianTimes(rounds, microIters, Micro.&mixedLoop)) +report('macro.dispatch(String-heavy)', medianTimes(rounds, macroIters, stringLoop)) +report('macro.dispatch(POGO) ', medianTimes(rounds, macroIters, pogoLoop)) diff --git a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle index 08a6db6c947..2606bdbd760 100644 --- a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle +++ b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle @@ -92,6 +92,13 @@ tasks.withType(Test).configureEach { if (closurePack) { systemProperty 'groovy.target.closure.pack', closurePack } + // Forward the global ClassValue mode (GROOVY-12142/GROOVY-12281: false = weak-key map, + // soft = soft values with resurrection) to the test JVM so whole suites can be run + // against a non-default mode, e.g. -Pgroovy.use.classvalue=soft. Set only when present. + def classValueMode = findProperty('groovy.use.classvalue') ?: System.getProperty('groovy.use.classvalue') + if (classValueMode) { + systemProperty 'groovy.use.classvalue', classValueMode + } def testdb = System.properties['groovy.testdb.props'] if (testdb) { systemProperty 'groovy.testdb.props', testdb diff --git a/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java index 6879789c724..39b8059d395 100644 --- a/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java +++ b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java @@ -23,7 +23,9 @@ import groovy.lang.MetaClass; import groovy.lang.MetaClassImpl; import groovy.lang.MetaClassRegistryChangeEvent; +import groovy.transform.Internal; import org.apache.groovy.util.SystemUtil; +import org.apache.groovy.util.concurrent.ManagedIdentityConcurrentMap; import org.codehaus.groovy.reflection.ClassInfo; import org.codehaus.groovy.runtime.NullObject; import org.codehaus.groovy.util.ManagedReference; @@ -72,8 +74,22 @@ * Production guards: {@code IndyInterface.applyMopSwitchPoints}; tests may use * {@link #guardWithMopSwitchPoints}. * + *

Layering

+ * This class is the policy half of a two-level subsystem: + * {@link SwitchPointInvalidator} is the mechanism (one domain's SwitchPoint + * lifecycle plus the process-wide live registry) and makes no policy + * decisions; this class decides invalidation width, labels the reasons, owns + * reclaim anchoring and — when the global ClassValue store reclaims values + * (GROOVY-12281 soft mode) — per-Class domain continuity. The only other + * supported consumer of the mechanism is {@link ClassInfo}, which owns domain + * instances and performs the local operations (allocate, invalidate, detach) + * directly. No other code should construct or invalidate + * {@link SwitchPointInvalidator} instances; both classes are internal and may + * change incompatibly. + * * @since 6.0.0 */ +@Internal public final class IndyInvalidation { private static final Logger LOG = Logger.getLogger(IndyInvalidation.class.getName()); @@ -135,6 +151,76 @@ public void finalizeReference() { } } + // ------------------------------------------------------------------------- + // Soft ClassValue mode: per-Class domain continuity (GROOVY-12281) + // ------------------------------------------------------------------------- + + /** + * Per-Class domain map used only when {@code groovy.use.classvalue=soft}. + * In soft mode a ClassInfo instance is replaceable — it can be soft-collected + * and a successor created for the same class — while POJO direct-dispatch + * guards capture only the SwitchPoint's internal invoker, never the + * ClassInfo. Keying the domain by the {@code Class} (weak identity keys, + * strong domain values) makes domain identity survive instance turnover: + * the successor adopts its predecessor's domain, so an invalidation applied + * through the successor deterministically retires guards linked under the + * predecessor, with no dependency on lazy reference-queue processing. + *

+ * 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, SwitchPointInvalidator> CLASS_DOMAINS = + new ManagedIdentityConcurrentMap<>(); + + /** + * Returns the canonical domain for {@code type}, seeding the per-Class map + * with {@code candidate} on first touch. Soft-mode only (callers gate). + * + * @param type the class whose domain is requested (must not be {@code null}) + * @param candidate the caller-owned domain to install if none is mapped yet + * @return the canonical per-Class domain + */ + public static SwitchPointInvalidator classDomainFor(final Class type, final SwitchPointInvalidator candidate) { + return CLASS_DOMAINS.getOrPut(type, candidate); + } + + /** + * Anchors a reclaim reference keyed to the {@code Class} rather than a + * ClassInfo instance: soft mode retires a domain only when the class dies + * (no receiver can reach an installed guard afterwards), because ClassInfo + * instances are replaceable and their successors adopt the same domain. + * + * @param owner the class owning the domain (must not be {@code null}) + * @param domain the class-level domain (must not be {@code null}) + */ + public static void anchorClassDomainToClass(final Class owner, final SwitchPointInvalidator domain) { + domain.setReclaimAnchor(new ClassDomainReclaim(owner, domain)); + } + + /** + * Weak reference to a domain's owning {@code Class} whose collection + * retires the domain (soft ClassValue mode). Mirrors {@link DomainReclaim} + * with the owner lifetime moved from the replaceable ClassInfo instance to + * the stable per-Class identity. + */ + private static final class ClassDomainReclaim extends ManagedReference> { + private final SwitchPointInvalidator domain; + + ClassDomainReclaim(final Class owner, final SwitchPointInvalidator domain) { + super(ReferenceBundle.getWeakBundle(), owner); + this.domain = domain; + } + + @Override + public void finalizeReference() { + domain.invalidate(); + super.finalizeReference(); + } + } + // ------------------------------------------------------------------------- // Width: exact class // ------------------------------------------------------------------------- diff --git a/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java index 9639aafbc04..912c477acd7 100644 --- a/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java +++ b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java @@ -18,6 +18,8 @@ */ package org.apache.groovy.runtime.indy; +import groovy.transform.Internal; + import java.lang.invoke.SwitchPoint; import java.util.List; import java.util.concurrent.ConcurrentHashMap; @@ -53,8 +55,21 @@ * not the SwitchPoint object, so the registry entry is what keeps a * still-installed guard retirable until its domain is explicitly retired. * + *

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 arr) { } else classInfo.newMetaMethods = classInfo.dgmMetaMethods; + // GROOVY-12281 soft mode: MOP-array writes make the ClassInfo non-reclaimable + classInfo.updateReclaimability(); } /** @@ -622,6 +624,8 @@ private void updateAddNewMopMethods(List arr) { res.addAll(Arrays.asList(classInfo.newMetaMethods)); res.addAll(arr); classInfo.newMetaMethods = res.toArray(MetaMethod.EMPTY_ARRAY); + // GROOVY-12281 soft mode: MOP-array writes make the ClassInfo non-reclaimable + classInfo.updateReclaimability(); var theClass = classInfo.getCachedClass().getTheClass(); if (theClass == Closure.class || theClass == Class.class) { ClosureMetaClass.resetCachedMetaClasses(); diff --git a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java index 39a802bacab..c2923aceb3c 100644 --- a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java +++ b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java @@ -126,6 +126,16 @@ public ClassInfo computeValue(Class type) { } }); + /** + * Whether {@link #globalClassValue} can collect a ClassInfo while its + * class is still alive ({@code -Dgroovy.use.classvalue=soft}, GROOVY-12281 + * investigation prototype). Reclaimable values need two cooperating pieces + * here: ephemeron pinning of instances carrying non-reconstructible state + * ({@link #updateReclaimability()}) and per-Class indy domain continuity + * (see {@link #indyDomain()}). + */ + private static final boolean RECLAIMABLE_CLASS_VALUES = globalClassValue.valuesReclaimable(); + private static final GlobalClassSet globalClassSet = new GlobalClassSet(); ClassInfo(Class klazz) { @@ -189,6 +199,34 @@ private void bumpGenerationLocal() { invalidateIndySwitchPoint(); } + /** + * Resolves the SwitchPoint domain all indy guard operations act on. + *

+ * 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 out) { - SwitchPoint live = indySwitchPointDomain.detachLive(); + SwitchPoint live = indyDomain().detachLive(); if (live != null) { out.add(live); } @@ -242,7 +291,7 @@ public void collectLiveIndySwitchPoints(final List out) { */ @Internal public SwitchPoint detachLiveIndySwitchPoint() { - return indySwitchPointDomain.detachLive(); + return indyDomain().detachLive(); } /** @@ -326,6 +375,9 @@ public static ClassInfo getClassInfo (Class cls) { * from cache */ public static void remove(Class cls) { + // A hard detach drops the whole association — including any pin the + // detached instance held there — so undeploy semantics need no + // store-specific handling. globalClassValue.remove(cls); } @@ -400,6 +452,39 @@ public void setStrongMetaClass(MetaClass answer) { } replaceWeakMetaClassRef(null); + updateReclaimability(); + } + + /** + * Reclaimability bookkeeping (GROOVY-12281): while this ClassInfo carries + * state that could not be reconstructed after collection — an installed + * class-level MetaClass, per-instance MetaClasses, or registry-written + * DGM/extension method arrays — it is pinned inside its own + * association ({@link GroovyClassValue#pin}), so it lives exactly as + * long as its class: an immortal platform key retains it (it must — the + * state is not reconstructible), while a dropped script class releases it + * together with its loader. A global strong root would get the second half + * wrong, extending dirty script classes (and their loaders) to the + * runtime's lifetime — the "reverse" leak raised in review of PR #2820. + * The DGM condition also enforces the registry-rooting invariant by + * construction rather than by audit: any instance holding non-empty MOP + * arrays is pinned, so a recreated instance never needs to rebuild them. + * Removal is conservative: lingering weak entries in the per-instance map + * merely delay unpinning, which is the safe direction (today's default + * retains every ClassInfo for its class's lifetime). No-op unless the + * value store reclaims values ({@link GroovyClassValue#valuesReclaimable}). + */ + void updateReclaimability() { + Class type = getTheClass(); + if (type == null) return; + if (strongMetaClass != null + || (perInstanceMetaClassMap != null && !perInstanceMetaClassMap.isEmpty()) + || dgmMetaMethods.length != 0 + || newMetaMethods.length != 0) { + globalClassValue.pin(type, this); + } else { + globalClassValue.unpin(type, this); + } } /** @@ -431,6 +516,7 @@ public void setWeakMetaClass(MetaClass answer) { newRef = new ManagedReference (softBundle,answer); } replaceWeakMetaClassRef(newRef); + updateReclaimability(); } private void replaceWeakMetaClassRef(ManagedReference newRef) { @@ -688,6 +774,7 @@ public void setPerInstanceMetaClass(Object obj, MetaClass metaClass) { perInstanceMetaClassMap.remove(obj); } } + updateReclaimability(); } /** diff --git a/src/main/java/org/codehaus/groovy/reflection/GroovyClassValue.java b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValue.java index 909f9b189e6..cab34765bbd 100644 --- a/src/main/java/org/codehaus/groovy/reflection/GroovyClassValue.java +++ b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValue.java @@ -30,7 +30,46 @@ interface ComputeValue{ } T get(Class type); - + void remove(Class type); - + + /** + * Whether an association's value can be collected while its key class is + * still alive (GROOVY-12281, {@code -Dgroovy.use.classvalue=soft}). The + * default — a value lives exactly as long as its key class — answers + * {@code false}. Implementations answering {@code true} must honor + * {@link #pin} so callers can exempt values whose state cannot be rebuilt + * by recomputation. + * + * @return {@code true} if values may be collected before their key class + */ + default boolean valuesReclaimable() { + return false; + } + + /** + * Keeps {@code value} strongly reachable from its own key's + * association until {@link #unpin} or {@link #remove}. The value then + * lives exactly as long as {@code type} — like a plain + * {@code java.lang.ClassValue} association: an immortal key retains it, + * a collectible key releases it together with its loader. Implementations + * must not root the value globally, which would extend a collectible key's + * lifetime to the runtime's. No-op unless {@link #valuesReclaimable()}. + * + * @param type the key class + * @param value the current value for {@code type} + */ + default void pin(Class type, T value) { + } + + /** + * Reverts {@link #pin}: the association holds {@code value} reclaimably + * again. No-op when {@code value} is not the currently pinned value, and + * unless {@link #valuesReclaimable()}. + * + * @param type the key class + * @param value the value to release + */ + default void unpin(Class type, T value) { + } } diff --git a/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java index 879c3773bb6..9f6aa8fe38b 100644 --- a/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java +++ b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java @@ -31,10 +31,30 @@ class GroovyClassValueFactory { * {@code -Dgroovy.use.classvalue=false} at JVM startup to use a weak-key * map instead; the default remains ClassValue for its per-Class fast path. */ - private static final boolean USE_CLASSVALUE = Boolean.parseBoolean(SystemUtil.getSystemPropertySafe("groovy.use.classvalue", "true")); + private static final String CLASSVALUE_MODE = SystemUtil.getSystemPropertySafe("groovy.use.classvalue", "true"); + + /** + * GROOVY-12281 investigation prototypes; see {@link #createGroovyClassValue}. + * Callers needing behavior differences never consult the mode: they ask the + * created store for its capabilities ({@link GroovyClassValue#valuesReclaimable}). + */ + private static final boolean SOFT_MODE = "soft".equalsIgnoreCase(CLASSVALUE_MODE); + private static final boolean HYBRID_MODE = "hybrid".equalsIgnoreCase(CLASSVALUE_MODE); public static GroovyClassValue createGroovyClassValue(ComputeValue computeValue) { - return (USE_CLASSVALUE) + // GROOVY-12281 investigation prototypes: "hybrid" routes platform-loader keys to the + // weak-key map and everything else to ClassValue, so immortal platform keys never + // pin the value's loader while user classes keep the per-class fast path (measured, + // declined); "soft" keeps ClassValue for all keys but holds values softly with + // resurrection and ephemeron pinning, so immortal keys hold no strong chain to the + // value's loader. + if (HYBRID_MODE) { + return new GroovyClassValueHybrid<>(computeValue); + } + if (SOFT_MODE) { + return new GroovyClassValueSoft<>(computeValue); + } + return Boolean.parseBoolean(CLASSVALUE_MODE) ? new GroovyClassValueJava7<>(computeValue) : new GroovyClassValueMapBased<>(computeValue); } diff --git a/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueHybrid.java b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueHybrid.java new file mode 100644 index 00000000000..8dd1f01690f --- /dev/null +++ b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueHybrid.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.reflection; + +import org.codehaus.groovy.reflection.v7.GroovyClassValueJava7; + +/** + * GROOVY-12281 investigation prototype: routes by key origin. A {@code ClassValue} + * association lives as long as its key class, so an association on an immortal + * platform class pins the value's loader forever; platform-loader keys therefore + * use the weak-key map (owned by Groovy's own loader), while every other key + * keeps the {@code ClassValue} per-class fast path. Values are strong in both + * stores — lifetimes are unchanged and nothing is ever recomputed. + * + * @param the value type + */ +class GroovyClassValueHybrid implements GroovyClassValue { + + private final GroovyClassValueJava7 fastPath; + private final GroovyClassValueMapBased platformStore; + + GroovyClassValueHybrid(final ComputeValue computeValue) { + this.fastPath = new GroovyClassValueJava7<>(computeValue); + this.platformStore = new GroovyClassValueMapBased<>(computeValue); + } + + private static boolean isPlatformKey(final Class type) { + ClassLoader loader = type.getClassLoader(); + return loader == null || loader == ClassLoader.getPlatformClassLoader(); + } + + @Override + public T get(final Class type) { + return isPlatformKey(type) ? platformStore.get(type) : fastPath.get(type); + } + + @Override + public void remove(final Class type) { + if (isPlatformKey(type)) { + platformStore.remove(type); + } else { + fastPath.remove(type); + } + } +} diff --git a/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueSoft.java b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueSoft.java new file mode 100644 index 00000000000..7a821ca3d6a --- /dev/null +++ b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueSoft.java @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.reflection; + +import org.apache.groovy.util.concurrent.ConcurrentReferenceHashMap; +import org.apache.groovy.util.concurrent.ConcurrentReferenceHashMap.Option; +import org.apache.groovy.util.concurrent.ConcurrentReferenceHashMap.ReferenceType; + +import java.lang.ref.SoftReference; +import java.util.EnumSet; +import java.util.concurrent.atomic.AtomicReference; + +/** + * GROOVY-12281 investigation prototype ({@code -Dgroovy.use.classvalue=soft}): + * keeps {@code java.lang.ClassValue}'s per-{@code Class} fast path but stores + * each value behind a mutable slot holding either a + * {@link SoftReference} to the value (the reclaimable state) or the value + * itself ({@linkplain #pin pinned}), so an association on an immortal + * platform class (for example {@code String}) no longer holds a strong chain + * to the value's class loader unless the value was deliberately pinned + * (JDK-8136353 / GROOVY-12142). The slot is a bootstrap-loaded + * {@link AtomicReference} and the wrapper a bootstrap-loaded + * {@code java.lang.ref.SoftReference}; the per-Class entry references the + * defining {@code ClassValue} only weakly (via its {@code Version}; the map + * key is a bootstrap {@code Identity} object). So the only path from an + * immortal key to the Groovy-loaded value is softly reachable — cleared under + * memory pressure once nothing else keeps the Groovy island alive. + *

+ * 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 the value type + */ +class GroovyClassValueSoft implements GroovyClassValue { + + private final ComputeValue computeValue; + + /** + * Identity authority: weak identity keys (no key class is pinned by this + * map) and weak values (the map keeps nothing alive by itself; it only + * remembers a value for as long as something else does). + */ + private final ConcurrentReferenceHashMap, T> canonical = + new ConcurrentReferenceHashMap<>(ReferenceType.WEAK, ReferenceType.WEAK, + EnumSet.of(Option.IDENTITY_COMPARISONS)); + + /** + * Striped locks for the canonical-miss path. Creation must be mutually + * exclusive per key: without it two racing threads could each create and + * leak a distinct instance through the uncached fallback in + * {@link #get(Class)}, breaking the identity guarantee the side map + * exists to provide. (The map's own {@code putIfAbsent} cannot be used + * for this: on an entry whose collected value has not yet been purged it + * reports success without storing the replacement.) + */ + private final Object[] creationLocks = new Object[64]; + + /** + * Per-Class slot: contains either a {@code SoftReference} (reclaimable) + * or the value itself (pinned). Both the slot and the wrapper are + * bootstrap-loaded, so an unpinned association on an immortal key keeps + * nothing Groovy-loaded strongly reachable. + */ + private final ClassValue> store = new ClassValue>() { + @Override + protected AtomicReference computeValue(final Class type) { + return new AtomicReference<>(new SoftReference<>(canonical(type))); + } + }; + + GroovyClassValueSoft(final ComputeValue computeValue) { + this.computeValue = computeValue; + for (int i = 0; i < creationLocks.length; i++) { + creationLocks[i] = new Object(); + } + } + + @Override + public T get(final Class type) { + T value = deref(store.get(type).get()); + if (value != null) return value; + // The soft reference was cleared: drop the memoized slot and + // recompute once — resurrection returns the canonical instance when + // it is still alive (GROOVY-12280 remove-and-recompute pattern). + store.remove(type); + value = deref(store.get(type).get()); + if (value != null) return value; + // Pathological memory pressure cleared the fresh wrapper before we + // could dereference it: serve the canonical instance uncached; a + // later get() re-tries the cache. Bounded, and identity-safe because + // the side map, not the ClassValue, is the identity authority. + return canonical(type); + } + + /** The value a slot's content designates: through the soft wrapper, or the pinned value itself. */ + @SuppressWarnings("unchecked") + private T deref(final Object slotContent) { + return slotContent instanceof SoftReference ? ((SoftReference) slotContent).get() : (T) slotContent; + } + + @Override + public void remove(final Class type) { + canonical.remove(type); + store.remove(type); + } + + @Override + public boolean valuesReclaimable() { + return true; + } + + /** + * {@inheritDoc} + *

+ * The retry handles the race with {@link #get}'s remove-and-recompute: if + * the slot we wrote was concurrently discarded (its soft reference had + * been cleared), the write is repeated on the successor slot — whose + * recompute returned the same canonical instance, because {@code value} + * is strongly reachable in our hands throughout. + */ + @Override + public void pin(final Class type, final T value) { + while (true) { + AtomicReference slot = store.get(type); + slot.set(value); + if (store.get(type) == slot) return; // still current: the strong hold is visible + } + } + + @Override + public void unpin(final Class type, final T value) { + // CAS: only downgrade the exact pinned value; a slot already holding a + // soft wrapper (or a successor value) is left untouched. + store.get(type).compareAndSet(value, new SoftReference<>(value)); + } + + /** + * Returns the current canonical value without creating one — used by + * detach paths that must act on the existing instance (for example + * un-rooting it) but must not resurrect or create anything as a side + * effect. + * + * @param type the key class + * @return the live canonical value, or {@code null} if none + */ + T getIfPresent(final Class type) { + return canonical.get(type); + } + + /** + * Returns the canonical value for {@code type}: the still-live existing + * instance when there is one, otherwise a freshly computed instance + * published under the key's creation lock. + */ + private T canonical(final Class type) { + T existing = canonical.get(type); + if (existing != null) return existing; + Object lock = creationLocks[System.identityHashCode(type) & (creationLocks.length - 1)]; + synchronized (lock) { + existing = canonical.get(type); + if (existing != null) return existing; + T fresh = computeValue.computeValue(type); + // Unconditional put: replaces a stale (value-collected) entry. + canonical.put(type, fresh); + return fresh; + } + } +} diff --git a/src/spec/doc/guide-integrating.adoc b/src/spec/doc/guide-integrating.adoc index a7f930091fc..695e7f53d6a 100644 --- a/src/spec/doc/guide-integrating.adoc +++ b/src/spec/doc/guide-integrating.adoc @@ -397,7 +397,11 @@ Two supported measures release the class loader; either one suffices: * Start the JVM with `-Dgroovy.use.classvalue=false`. Groovy then stores the associations in a weak-key map instead of `ClassValue`. The trade-off is a hash lookup where `ClassValue` has a - per-class fast path; for most applications the difference is not measurable. + per-class fast path. Compilation speed is unaffected (within 1% in our measurements), and for + applications that are not dispatch-heavy the difference is unlikely to be noticed; heavily + dynamic code, however, can see a few percent overall, with hot dynamic idioms on JDK-class + receivers (string coercions, ranges, safe navigation, map property access) up to roughly + 1.4 times slower. Measure with your own workload if dispatch performance matters. * Clean up explicitly when the application is discarded (for example from `ServletContextListener#contextDestroyed`): + diff --git a/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeProbe.groovy b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeProbe.groovy new file mode 100644 index 00000000000..744858ecb5a --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeProbe.groovy @@ -0,0 +1,301 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.reflection + +import groovy.lang.GroovySystem +import groovy.transform.CompileStatic +import org.codehaus.groovy.runtime.InvokerHelper + +import java.lang.invoke.SwitchPoint +import java.lang.ref.SoftReference +import java.lang.ref.WeakReference +import java.util.zip.Adler32 +import java.util.zip.CRC32 +import java.util.zip.Deflater +import java.util.zip.Inflater + +/** + * GROOVY-12281 child-process probe for {@code -Dgroovy.use.classvalue=soft}: + * exercises the global {@link ClassInfo#globalClassValue} in soft mode. + * Invoked from {@link ClassInfoSoftModeTest}; prints {@code OK} on success. + *

+ * Forced clears simulate GC clearing of the memoized SoftReference. A manual + * {@code clear()} is a conservative superset of GC behavior (a collector never + * clears a reference to a strongly reachable object), so passing here implies + * correctness under any GC schedule. + */ +final class ClassInfoSoftModeProbe { + + static class IndyHost { + def ping() { 'pong' } + } + + /** + * The global store's slot content for {@code type}: a SoftReference for a + * reclaimable value, or the ClassInfo itself when pinned (dirty state). + */ + private static Object slotContent(Class type) { + def gcvField = ClassInfo.getDeclaredField('globalClassValue') + gcvField.accessible = true + def gcv = gcvField.get(null) + assert gcv instanceof GroovyClassValueSoft : "probe requires -Dgroovy.use.classvalue=soft, found ${gcv.getClass().simpleName}" + def storeField = GroovyClassValueSoft.getDeclaredField('store') + storeField.accessible = true + ClassValue store = storeField.get(gcv) + store.get(type).get() + } + + /** Clears the global store's memoized SoftReference for {@code type}. A pinned slot has none. */ + private static void clearSoft(Class type) { + def content = slotContent(type) + assert content instanceof SoftReference : "a pinned slot cannot be cleared by GC (${type.name})" + ((SoftReference) content).clear() + } + + /** + * Asserts the strongest statement available about non-reclaimable state: + * the slot holds the ClassInfo itself, so no GC schedule can clear it — + * while the class lives, exactly like the default strong ClassValue. + */ + private static void assertPinned(Class type) { + assert slotContent(type) instanceof ClassInfo : "ClassInfo for ${type.name} should be pinned in its association" + } + + private static boolean awaitCollected(WeakReference ref) { + for (int i = 0; i < 100 && ref.get() != null; i++) { + System.gc() + byte[][] pressure = new byte[64][] + for (int j = 0; j < pressure.length; j++) { + pressure[j] = new byte[1 << 16] + } + Thread.sleep(10) + } + return ref.get() == null + } + + /** + * Applies real memory pressure: the collector clears soft references + * before throwing {@link OutOfMemoryError}, so generic soft caches + * (for example {@code ClassInfo}'s lazy CachedClass/loader references) + * release their referents — the "on memory pressure" premise of the + * reverse scenario. Mild GC alone retains them in every mode. + */ + private static void applySevereMemoryPressure() { + def hold = [] + try { + while (true) { hold << new byte[1 << 20] } + } catch (OutOfMemoryError expected) { + hold = null + } + } + + static void main(String[] args) { + resurrectionPreservesIdentityAndVersion() + dgmTargetClassInfoIsPinned() + strongMetaClassPinsAndUnpinsWithItsState() + perInstanceMetaClassPinsClassInfo() + pristineClassInfoIsCollectedAndRecreatedWorking() + classicCallSiteStaysSoundAcrossClearAndRelinksOnChange() + indyDomainContinuityAcrossRecreation() + dirtyScriptClassDiesWithItsLoader() + println 'OK' + } + + /** + * The split-brain check: a ClassInfo captured by any holder must be + * returned as-is after the slot's soft reference clears, with its + * version untouched, so captured version guards stay sound. Uses a class + * with no registry-written DGM arrays, whose slot is therefore soft. + */ + private static void resurrectionPreservesIdentityAndVersion() { + ClassInfo before = ClassInfo.getClassInfo(Deflater) // strong local ref: "captured by a call site" + int version = before.version + clearSoft(Deflater) + ClassInfo after = ClassInfo.getClassInfo(Deflater) + assert after.is(before) : 'live ClassInfo must be resurrected, not replaced' + assert after.version == version : 'resurrection must not disturb the version guard stamp' + } + + /** + * The enforced E3 invariant: a ClassInfo holding registry-written DGM/extension + * method arrays is pinned inside its own association — the slot holds the + * instance itself, so no GC schedule can clear it while the class lives and + * a recreated instance never needs to rebuild those arrays. + */ + private static void dgmTargetClassInfoIsPinned() { + def weak = new WeakReference(ClassInfo.getClassInfo(String)) + assertPinned(String) + System.gc() + assert weak.get() != null : 'DGM-target ClassInfo must be pinned (non-reclaimable)' + assert ClassInfo.getClassInfo(String).is(weak.get()) + assert 'abc'.reverse() == 'cba' : 'String DGM dispatch intact' + } + + /** + * User metaclass customizations are non-reconstructible: installing one + * pins the ClassInfo in its association; removing it unpins, restoring + * reclaimability — the pin follows the state, not the class. + */ + private static void strongMetaClassPinsAndUnpinsWithItsState() { + CRC32.metaClass.twiddle = { -> 42 } + try { + def weak = new WeakReference(ClassInfo.getClassInfo(CRC32)) + assertPinned(CRC32) + System.gc() + assert weak.get() != null : 'ClassInfo with installed MetaClass must be pinned' + assert new CRC32().twiddle() == 42 : 'EMC customization must survive GC' + } finally { + GroovySystem.metaClassRegistry.removeMetaClass(CRC32) + } + assert slotContent(CRC32) instanceof SoftReference : 'removing the MetaClass must unpin the ClassInfo' + } + + /** Per-instance metaclasses are equally non-reconstructible state. */ + private static void perInstanceMetaClassPinsClassInfo() { + def receiver = new Adler32() + receiver.metaClass.spin = { -> 7 } + try { + def weak = new WeakReference(ClassInfo.getClassInfo(Adler32)) + assertPinned(Adler32) + System.gc() + assert weak.get() != null : 'ClassInfo with per-instance MetaClass must be pinned' + assert receiver.spin() == 7 : 'per-instance customization must survive GC' + } finally { + receiver.metaClass = null + } + } + + /** + * The unpinning payoff: a pristine ClassInfo (no user MetaClass state, no + * DGM arrays) really is collected once cleared, and dispatch afterwards + * works against a fresh instance — hierarchy DGM methods included. + */ + private static void pristineClassInfoIsCollectedAndRecreatedWorking() { + def weak = new WeakReference(ClassInfo.getClassInfo(Inflater)) + clearSoft(Inflater) + assert awaitCollected(weak) : 'pristine ClassInfo should be collectable once cleared' + def inflater = new Inflater() + try { + assert inflater.with { 'fresh' } == 'fresh' : 'Object-hierarchy DGM dispatch on the fresh ClassInfo' + } finally { + inflater.end() + } + } + + /** + * Legacy classic call sites (groovy-callsite on the classpath, e.g. from + * jars compiled by older Groovy) capture the ClassInfo instance and its + * version at link time. Resurrection keeps that capture sound across a + * clear, and a later MetaClass change must still be observed via the + * version guard on the same instance. Without resurrection the stale site + * would answer the original result forever. The receiver is a POJO with no + * registry-written DGM arrays, so its slot is soft (clearable); a pinned + * receiver like {@code String} can never be cleared in the first place. + */ + private static void classicCallSiteStaysSoundAcrossClearAndRelinksOnChange() { + // groovy-callsite is runtime-only for core (as for legacy-compiled jars + // in the wild), so drive it reflectively + def csaClass = Class.forName('org.codehaus.groovy.runtime.callsite.CallSiteArray') + def csa = csaClass.getConstructor(Class, String[]).newInstance(ClassInfoSoftModeProbe, ['toString'] as String[]) + def noparam = csaClass.NOPARAM + def joiner = new StringJoiner('-') + assert csa.array[0].call(joiner, noparam) == '' + // the linked site (csa.array[0] after the first call) now captures ClassInfo(StringJoiner)+version + clearSoft(StringJoiner) + System.gc() + assert csa.array[0].call(joiner, noparam) == '' : 'linked site stays correct across the clear' + StringJoiner.metaClass.toString = { -> 'emc' } + try { + assert csa.array[0].call(joiner, noparam) == 'emc' : 'version guard on the resurrected instance must observe the change' + } finally { + GroovySystem.metaClassRegistry.removeMetaClass(StringJoiner) + } + assert csa.array[0].call(joiner, noparam) == '' + } + + /** + * The E4 re-homing check: indy guards capture only the SwitchPoint, so a + * guard can outlive its ClassInfo. Domain identity is keyed by Class, so a + * mutation applied through the successor ClassInfo must deterministically + * retire a SwitchPoint handed out by the collected predecessor. Without + * re-homing, the successor would invalidate a fresh domain and this + * SwitchPoint would survive, leaving the stale guard installed until the + * lazy reference-queue pump. + */ + private static void indyDomainContinuityAcrossRecreation() { + ClassInfo before = ClassInfo.getClassInfo(IndyHost) + SwitchPoint sp = before.indySwitchPoint + def weak = new WeakReference(before) + before = null + clearSoft(IndyHost) + assert awaitCollected(weak) : 'unrooted ClassInfo with a linked domain should be collectable' + ClassInfo successor = ClassInfo.getClassInfo(IndyHost) + successor.incVersion() + assert sp.hasBeenInvalidated() : "predecessor's SwitchPoint must be retired by a mutation through the successor" + } + + /** Installs an EMC method statically, replicating {@code cls.metaClass.extra = { -> 42 }}. */ + @CompileStatic + private static void installExtra(Class cls) { + def emc = new ExpandoMetaClass(cls, true, true) + emc.initialize() + emc.setProperty('extra', { -> 42 }) + GroovySystem.metaClassRegistry.setMetaClass(cls, emc) + } + + /** + * The "reverse" scenario (Jochen, PR #2820 review): the Groovy runtime + * stays alive while script loaders come and go, and a script installs an + * EMC on a class it created. The pin lives inside the class's own + * association, so on memory pressure dropping the loader must release the + * class, its ClassInfo, the EMC and the loader itself — exactly as the + * default strong ClassValue does. A global strong root would fail this: + * it would extend every EMC-dirty script class to the runtime's lifetime. + *

+ * Compiled statically on purpose: a dynamic call in this long-lived probe + * class would link its invokedynamic call-site guards against the script + * classes, retaining them from the call site — a receiver-side inline-cache + * effect present in every mode, not the association lifetime this scenario + * isolates. Locals are nulled for the same reason: the last iteration's + * frame slots stay reachable through the collection loop below. + */ + @CompileStatic + private static void dirtyScriptClassDiesWithItsLoader() { + List> loaderRefs = [] + List> classRefs = [] + for (int i = 0; i < 3; i++) { + def gcl = new GroovyClassLoader() + Class cls = gcl.parseClass("class ReverseScripted${i} { def hi() { 'hi' } }") + installExtra(cls) + def obj = cls.getDeclaredConstructor().newInstance() + assert InvokerHelper.invokeMethod(obj, 'extra', null) == 42 : 'EMC on the script-created class must dispatch' + assertPinned(cls) + loaderRefs << new WeakReference(gcl) + classRefs << new WeakReference(cls) + obj = null; cls = null; gcl = null + } + applySevereMemoryPressure() + loaderRefs.each { WeakReference ref -> + assert awaitCollected(ref) : 'dropped script loader with an EMC-dirty class must be collectable (reverse scenario)' + } + classRefs.each { WeakReference ref -> + assert ref.get() == null : 'the script class must go with its loader' + } + } +} diff --git a/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeStressProbe.groovy b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeStressProbe.groovy new file mode 100644 index 00000000000..d9f67aed0c9 --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeStressProbe.groovy @@ -0,0 +1,309 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.reflection + +import groovy.lang.GroovySystem + +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +/** + * GROOVY-12281 stress probe for {@code -Dgroovy.use.classvalue=soft} under + * real GC clearing (unlike {@link ClassInfoSoftModeProbe}, which + * clears deterministically): run with a small heap and + * {@code -XX:SoftRefLRUPolicyMSPerMB=0} so every collection clears whatever + * soft references are not strongly protected, while dispatch, MetaClass + * mutation and class churn race the collector. Invoked from + * {@link ClassInfoSoftModeStressTest}; prints {@code OK} on success. + * + * Invariants asserted: + *

    + *
  • no exception anywhere (a MissingMethodException on the mutated class + * means a dirty-rooted customization was lost to collection);
  • + *
  • observed EMC generations stay within what was installed and are + * non-decreasing per dispatcher thread;
  • + *
  • the mutated (dirty-rooted) ClassInfo keeps its identity for the whole + * run — it must never be collected/recreated;
  • + *
  • churn-class ClassInfos really do get collected (the stress is live) and + * dispatch on them stays correct across recreation;
  • + *
  • after the final generation is installed, every dispatcher — indy and + * classic — observes it while pressure continues (no straggler guard + * survives a mutation on a resurrected/recreated ClassInfo).
  • + *
+ */ +final class ClassInfoSoftModeStressProbe { + + private static final int CHURN_CLASSES = 100 + private static final long STRESS_MILLIS = 20_000 + private static final long QUIESCE_TIMEOUT_MILLIS = 10_000 + + private static final ConcurrentLinkedQueue errors = new ConcurrentLinkedQueue<>() + private static final AtomicBoolean mutating = new AtomicBoolean(true) + private static final AtomicInteger installedGen = new AtomicInteger() + private static volatile int finalGen = -1 + private static final AtomicLong dispatches = new AtomicLong() + + static void main(String[] args) { + def gcv = ClassInfo.getDeclaredField('globalClassValue').tap { accessible = true }.get(null) + assert gcv instanceof GroovyClassValueSoft : + "probe requires -Dgroovy.use.classvalue=soft, found ${gcv.getClass().simpleName}" + + // POGO group: Groovy-defined classes. Their ClassInfos are self-pinned + // while the class lives (POGO instances hold their MetaClass and the + // generated $staticMetaClass field holds it from the Class), so they + // stress link/relink correctness, not collection. + def gcl = new GroovyClassLoader(ClassInfoSoftModeStressProbe.classLoader) + def churnReceivers = new Object[CHURN_CLASSES] + for (int i = 0; i < CHURN_CLASSES; i++) { + def cls = gcl.parseClass("class Churn$i { int id() { $i } }", "Churn${i}.groovy") + churnReceivers[i] = cls.getDeclaredConstructor().newInstance() + } + + // collection group: JAVA receivers — no MetaClass instance field, no + // $staticMetaClass, and POJO indy guards capture only Class objects — + // so their pristine ClassInfos are exactly the population soft mode + // is allowed to reap and recreate under pressure (the ticket's + // platform-receiver scenario). + Object[] javaReceivers = [ + new ArrayList(), new LinkedList(), new HashMap(), new TreeMap(), + new HashSet(), new TreeSet(), new ArrayDeque(), new PriorityQueue(), + new Stack(), new Vector(), new Hashtable(), new StringBuilder('x'), + new StringBuffer('y'), new Random(42), new StringJoiner(','), + new IdentityHashMap(), new WeakHashMap(), new LinkedHashMap(), + new LinkedHashSet(), new java.util.concurrent.ConcurrentHashMap(), + new java.util.concurrent.ConcurrentLinkedDeque(), new java.util.concurrent.CopyOnWriteArrayList(), + new java.util.concurrent.atomic.AtomicInteger(7), new java.util.concurrent.atomic.AtomicLong(9), + new java.util.zip.CRC32(), new java.util.zip.Adler32(), + new java.text.StringCharacterIterator('z'), new java.awt.Point(1, 2), + new java.awt.Dimension(3, 4), new java.awt.Rectangle(5, 6), + new java.io.ByteArrayOutputStream(), new java.io.StringWriter(), + new java.net.InetSocketAddress(80), new java.util.Formatter(), + new java.util.EventObject('e'), new java.util.SimpleTimeZone(0, 'UTC'), + ] as Object[] + long[] javaExpected = new long[javaReceivers.length] + def javaInfos = new WeakReference[javaReceivers.length] + for (int i = 0; i < javaReceivers.length; i++) { + javaExpected[i] = javaReceivers[i].hashCode() // receivers are never mutated + javaInfos[i] = new WeakReference(ClassInfo.getClassInfo(javaReceivers[i].getClass())) + } + + // mutated group: an EMC on a platform class — the ticket's exact + // shape — whose ClassInfo the dirty root must keep alive throughout + installGen(1) + ClassInfo mutatedInfoAtStart = ClassInfo.getClassInfo(BitSet) + def mutatedInfoRef = new WeakReference(mutatedInfoAtStart) + + List workers = [] + def startGate = new CountDownLatch(1) + def done = new CountDownLatch(6) + + // 2 indy dispatchers over the POGO classes (one megamorphic site) + 2.times { t -> + workers << worker(startGate, done, "pogo-$t") { + for (int i = 0; ; i++) { + int k = (i * 31 + t) % CHURN_CLASSES + def r = churnReceivers[k] + int got = r.id() + if (got != k) { + throw new IllegalStateException("POGO dispatch returned $got for Churn$k") + } + dispatches.incrementAndGet() + if (shouldStop()) return + } + } + } + + // 1 indy dispatcher over the Java receivers, racing collection and + // recreation of their ClassInfos + workers << worker(startGate, done, 'java-churn') { + for (int i = 0; ; i++) { + int k = (i * 17 + 3) % javaReceivers.length + long got = javaReceivers[k].hashCode() + if (got != javaExpected[k]) { + throw new IllegalStateException("Java dispatch returned $got for ${javaReceivers[k].getClass().name}, expected ${javaExpected[k]}") + } + dispatches.incrementAndGet() + if (shouldStop()) return + } + } + + // 2 indy dispatchers on the mutated platform class + 2.times { t -> + workers << worker(startGate, done, "mutated-$t") { + def receiver = new BitSet() + int lastSeen = 0 + while (true) { + int gen = receiver.probe() + checkGen(gen, lastSeen, "mutated-$t") + lastSeen = Math.max(lastSeen, gen) + dispatches.incrementAndGet() + if (shouldStop() && lastSeen == finalGen) return + if (shouldStop() && quiesceExpired()) { + throw new IllegalStateException("mutated-$t stuck at gen $lastSeen, final is $finalGen") + } + } + } + } + + // 1 classic dispatcher on the same class, via CallSiteArray as a + // legacy-compiled jar would dispatch (groovy-callsite is runtime-only) + workers << worker(startGate, done, 'classic') { + def csaClass = Class.forName('org.codehaus.groovy.runtime.callsite.CallSiteArray') + def csa = csaClass.getConstructor(Class, String[]).newInstance(ClassInfoSoftModeStressProbe, ['probe'] as String[]) + def noparam = csaClass.NOPARAM + def receiver = new BitSet() + int lastSeen = 0 + while (true) { + int gen = (int) csa.array[0].call(receiver, noparam) + checkGen(gen, lastSeen, 'classic') + lastSeen = Math.max(lastSeen, gen) + dispatches.incrementAndGet() + if (shouldStop() && lastSeen == finalGen) return + if (shouldStop() && quiesceExpired()) { + throw new IllegalStateException("classic stuck at gen $lastSeen, final is $finalGen") + } + } + } + + // mutator: replace the EMC method with an increasing generation + def mutator = new Thread({ + startGate.await() + long end = System.currentTimeMillis() + STRESS_MILLIS + while (System.currentTimeMillis() < end) { + installGen(installedGen.get() + 1) + Thread.sleep(150) + } + int last = installedGen.get() + 1 + installGen(last) + finalGen = last // publish, then let dispatchers converge + quiesceStart = System.currentTimeMillis() + mutating.set(false) + }, 'mutator') + mutator.daemon = true + + // pressure: keep the collector busy; with SoftRefLRUPolicyMSPerMB=0 + // every GC clears all unprotected soft references + def pressure = new Thread({ + startGate.await() + def hog = new byte[24][] + int i = 0, gcTick = 0 + while (!done.await(0, java.util.concurrent.TimeUnit.MILLISECONDS)) { + try { + hog[i++ % hog.length] = new byte[256 << 10] + } catch (OutOfMemoryError e) { + hog = new byte[24][] // release and back off + } + if (++gcTick % 64 == 0) { + System.gc() + Thread.sleep(20) + } + } + }, 'pressure') + pressure.daemon = true + + workers*.start(); mutator.start(); pressure.start() + startGate.countDown() + + long deadline = System.currentTimeMillis() + STRESS_MILLIS + QUIESCE_TIMEOUT_MILLIS + 10_000 + for (t in workers) { + long left = deadline - System.currentTimeMillis() + t.join(Math.max(1, left)) + if (t.alive) errors.add("worker ${t.name} did not finish") + } + + // dirty root must have preserved the mutated class's identity + ClassInfo mutatedInfoAtEnd = ClassInfo.getClassInfo(BitSet) + if (!mutatedInfoAtEnd.is(mutatedInfoAtStart)) { + errors.add('dirty-rooted ClassInfo(BitSet) was replaced during the run') + } + if (mutatedInfoRef.get() == null) { + errors.add('dirty-rooted ClassInfo(BitSet) was collected during the run') + } + + // the stress must have been live: Java-receiver ClassInfos really collected + int collected = javaInfos.count { it.get() == null } + // and dispatch still works on every class afterwards + for (int k = 0; k < CHURN_CLASSES; k++) { + if (churnReceivers[k].id() != k) { + errors.add("post-run dispatch broken for Churn$k") + } + } + for (int k = 0; k < javaReceivers.length; k++) { + if (javaReceivers[k].hashCode() != javaExpected[k]) { + errors.add("post-run dispatch broken for ${javaReceivers[k].getClass().name}") + } + } + + GroovySystem.metaClassRegistry.removeMetaClass(BitSet) + + println "dispatches=${dispatches.get()} generations=${installedGen.get()} javaInfosCollected=$collected/${javaInfos.length}" + if (collected == 0) { + errors.add('no Java-receiver ClassInfo was ever collected — the stress did not exercise real GC clearing') + } + if (errors.isEmpty()) { + println 'OK' + } else { + errors.each { println "ERROR: $it" } + System.exit(1) + } + } + + private static volatile long quiesceStart = Long.MAX_VALUE + + private static boolean shouldStop() { + !mutating.get() + } + + private static boolean quiesceExpired() { + System.currentTimeMillis() - quiesceStart > QUIESCE_TIMEOUT_MILLIS + } + + private static void checkGen(int gen, int lastSeen, String who) { + if (gen < 1 || gen > installedGen.get()) { + throw new IllegalStateException("$who observed gen $gen outside installed range 1..${installedGen.get()}") + } + if (gen < lastSeen) { + throw new IllegalStateException("$who observed gen $gen after already seeing $lastSeen") + } + } + + private static void installGen(int gen) { + installedGen.set(gen) + BitSet.metaClass.probe = { -> gen } + } + + private static Thread worker(CountDownLatch startGate, CountDownLatch done, String name, Closure body) { + def t = new Thread({ + startGate.await() + try { + body() + } catch (Throwable e) { + errors.add("$name: ${e.getClass().simpleName}: ${e.message}") + } finally { + done.countDown() + } + }, name) + t.daemon = true + t + } +} diff --git a/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeStressTest.groovy b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeStressTest.groovy new file mode 100644 index 00000000000..3d9918834f4 --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeStressTest.groovy @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.reflection + +import org.junit.jupiter.api.Test + +import java.util.concurrent.TimeUnit + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertTrue + +/** + * GROOVY-12281: races soft-mode ClassInfo management against real GC + * clearing in a child JVM (small heap, {@code SoftRefLRUPolicyMSPerMB=0}) — + * see {@link ClassInfoSoftModeStressProbe} for the invariants. Runtime is + * ~30s by design; the deterministic-clearing companion is + * {@link ClassInfoSoftModeTest}. + */ +final class ClassInfoSoftModeStressTest { + + @Test + void softModeSurvivesRealGcClearingUnderConcurrency() { + def javaBin = System.getProperty('java.home') + '/bin/java' + def cp = System.getProperty('java.class.path') + def pb = new ProcessBuilder( + javaBin, + '-Dgroovy.use.classvalue=soft', + '-Xmx128m', + '-XX:SoftRefLRUPolicyMSPerMB=0', + '-cp', cp, + 'org.codehaus.groovy.reflection.ClassInfoSoftModeStressProbe') + pb.redirectErrorStream(true) + def proc = pb.start() + def out = new StringBuilder() + proc.inputStream.eachLine { out.append(it).append('\n') } + assertTrue(proc.waitFor(120, TimeUnit.SECONDS), "stress probe timed out: $out") + assertEquals(0, proc.exitValue(), "stress probe failed: $out") + assertTrue(out.contains('OK'), out.toString()) + } +} diff --git a/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeTest.groovy b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeTest.groovy new file mode 100644 index 00000000000..e981d2b1858 --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeTest.groovy @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.reflection + +import org.junit.jupiter.api.Test + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertTrue + +/** + * GROOVY-12281: soft-value global ClassValue mode + * ({@code -Dgroovy.use.classvalue=soft}). The mode is chosen once at startup, + * so the semantics are asserted in a child JVM running + * {@link ClassInfoSoftModeProbe}. + */ +final class ClassInfoSoftModeTest { + + @Test + void softModeSemantics_inChildProcess() { + def javaBin = System.getProperty('java.home') + '/bin/java' + def cp = System.getProperty('java.class.path') + def pb = new ProcessBuilder( + javaBin, + '-Xmx256m', // bounded heap: the reverse scenario fills it to apply real memory pressure + '-Dgroovy.use.classvalue=soft', + '-cp', cp, + 'org.codehaus.groovy.reflection.ClassInfoSoftModeProbe') + pb.redirectErrorStream(true) + def proc = pb.start() + def out = proc.inputStream.text + assertEquals(0, proc.waitFor(), "soft-mode probe failed: $out") + assertTrue(out.contains('OK'), out) + } +} diff --git a/src/test/groovy/org/codehaus/groovy/reflection/GroovyClassValueSoftTest.groovy b/src/test/groovy/org/codehaus/groovy/reflection/GroovyClassValueSoftTest.groovy new file mode 100644 index 00000000000..16738b2e62f --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/reflection/GroovyClassValueSoftTest.groovy @@ -0,0 +1,202 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.reflection + +import org.junit.jupiter.api.Test + +import java.lang.ref.SoftReference +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicInteger + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertNotSame +import static org.junit.jupiter.api.Assertions.assertNull +import static org.junit.jupiter.api.Assertions.assertSame +import static org.junit.jupiter.api.Assertions.assertTrue + +/** + * GROOVY-12281: soft-value {@code GroovyClassValue} with resurrection. + * The forced-clear tests simulate GC clearing of the memoized SoftReference; + * a manual {@code clear()} is a conservative superset of what a collector can + * do (a collector never clears a reference to a strongly reachable object), + * so correctness under these tests implies correctness under any GC schedule. + */ +final class GroovyClassValueSoftTest { + + private static final class Holder { + final Class type + Holder(Class type) { this.type = type } + } + + private static GroovyClassValueSoft newSubject(AtomicInteger counter) { + new GroovyClassValueSoft({ Class type -> + counter.incrementAndGet() + new Holder(type) + } as GroovyClassValue.ComputeValue) + } + + /** The current slot content for {@code type}: a SoftReference (reclaimable) or the value itself (pinned). */ + private static Object slotContent(GroovyClassValueSoft subject, Class type) { + def storeField = GroovyClassValueSoft.getDeclaredField('store') + storeField.accessible = true + ClassValue store = storeField.get(subject) + store.get(type).get() + } + + /** Clears the memoized SoftReference for {@code type}, as a GC would. A pinned slot has none. */ + private static void forceClear(GroovyClassValueSoft subject, Class type) { + def content = slotContent(subject, type) + assert content instanceof SoftReference : 'a pinned (strong) slot cannot be cleared by GC' + ((SoftReference) content).clear() + } + + private static boolean survivesGc(WeakReference ref) { + for (int i = 0; i < 5 && ref.get() != null; i++) { + System.gc() + Thread.sleep(10) + } + return ref.get() != null + } + + private static boolean awaitCollected(WeakReference ref) { + for (int i = 0; i < 100 && ref.get() != null; i++) { + System.gc() + byte[][] pressure = new byte[64][] + for (int j = 0; j < pressure.length; j++) { + pressure[j] = new byte[1 << 16] + } + Thread.sleep(10) + } + return ref.get() == null + } + + @Test + void memoizesLikeAnyClassValue() { + def counter = new AtomicInteger() + def subject = newSubject(counter) + def first = subject.get(String) + assertSame(first, subject.get(String)) + assertEquals(1, counter.get()) + subject.get(Integer) + assertEquals(2, counter.get()) + } + + @Test + void clearedValueIsResurrectedWhileAlive_sameIdentity_noRecompute() { + def counter = new AtomicInteger() + def subject = newSubject(counter) + def survivor = subject.get(String) // strong local ref: "captured by a call site" + assertEquals(1, counter.get()) + + forceClear(subject, String) + def resurrected = subject.get(String) + + assertSame(survivor, resurrected, 'a live value must be resurrected, never replaced') + assertEquals(1, counter.get(), 'resurrection must not invoke computeValue') + } + + @Test + void deadValueIsRecreatedFresh() { + def counter = new AtomicInteger() + def subject = newSubject(counter) + def weak = new WeakReference(subject.get(String)) + assertEquals(1, counter.get()) + + forceClear(subject, String) + assertTrue(awaitCollected(weak), 'unreferenced value should be collectable once cleared') + + def fresh = subject.get(String) + assertEquals(2, counter.get(), 'a truly dead value is recomputed') + assertSame(fresh, subject.get(String)) + } + + @Test + void removeIsAHardDetach_evenWhileOldValueAlive() { + def counter = new AtomicInteger() + def subject = newSubject(counter) + def old = subject.get(String) + subject.remove(String) + def fresh = subject.get(String) + assertNotSame(old, fresh, 'remove() must forget identity (undeploy semantics)') + assertEquals(2, counter.get()) + } + + @Test + void valuesAreReclaimableByContract() { + assertTrue(newSubject(new AtomicInteger()).valuesReclaimable()) + } + + @Test + void pinHoldsTheValueInItsOwnSlotUntilUnpin() { + def counter = new AtomicInteger() + def subject = newSubject(counter) + def value = subject.get(String) + subject.pin(String, value) + assertSame(value, slotContent(subject, String), 'a pinned slot holds the value itself, not a wrapper') + + def weak = new WeakReference(value) + value = null + assertTrue(survivesGc(weak), 'a pinned value must not be collected') + def pinned = weak.get() + assertSame(pinned, subject.get(String)) + assertEquals(1, counter.get(), 'pin must not disturb identity or recompute') + + subject.unpin(String, pinned) + assertTrue(slotContent(subject, String) instanceof SoftReference, 'unpin restores the reclaimable wrapper') + assertSame(pinned, subject.get(String), 'unpin keeps the same live instance') + pinned = null + forceClear(subject, String) + assertTrue(awaitCollected(weak), 'an unpinned value is reclaimable again') + } + + @Test + void removeReleasesThePin() { + def counter = new AtomicInteger() + def subject = newSubject(counter) + def value = subject.get(String) + subject.pin(String, value) + def weak = new WeakReference(value) + value = null + subject.remove(String) + assertTrue(awaitCollected(weak), 'remove() must drop the pin with the association') + subject.get(String) + assertEquals(2, counter.get()) + } + + @Test + void unpinOfAForeignValueIsANoop() { + def counter = new AtomicInteger() + def subject = newSubject(counter) + def value = subject.get(String) + subject.pin(String, value) + subject.unpin(String, new Holder(String)) // not the pinned value + assertSame(value, slotContent(subject, String), 'foreign unpin must not downgrade the slot') + } + + @Test + void getIfPresentNeverCreates() { + def counter = new AtomicInteger() + def subject = newSubject(counter) + assertNull(subject.getIfPresent(String)) + assertEquals(0, counter.get()) + def value = subject.get(String) + assertSame(value, subject.getIfPresent(String)) + assertEquals(1, counter.get()) + } +}