From dadef013219b6843a0c96482cf2ead1ebc757254 Mon Sep 17 00:00:00 2001 From: Paul King Date: Wed, 19 Aug 2026 11:55:44 +1000 Subject: [PATCH 1/6] GROOVY-12281: hybrid GroovyClassValue investigation prototype (measured, declined) Routes platform-loader keys to the weak-key map and other keys to ClassValue, selected via -Dgroovy.use.classvalue=hybrid. Measured against pure ClassValue and pure map with a fresh-JVM-per-config harness: the hybrid tracks the map, not ClassValue, on macro dispatch, because dynamic code cannot avoid platform receivers (String, boxed numbers, DGM) and those are exactly the keys the hybrid maps. Kept on the investigation branch as the measured artifact; not proposed for merge. Numbers and method are recorded in the GROOVY-12281 assessment. Co-Authored-By: Claude Fable 5 --- .../reflection/GroovyClassValueFactory.java | 10 ++- .../reflection/GroovyClassValueHybrid.java | 61 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/codehaus/groovy/reflection/GroovyClassValueHybrid.java diff --git a/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java index 879c3773bb6..3b9b685bc00 100644 --- a/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java +++ b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java @@ -31,10 +31,16 @@ 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"); public static GroovyClassValue createGroovyClassValue(ComputeValue computeValue) { - return (USE_CLASSVALUE) + // GROOVY-12281 investigation prototype: "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. + if ("hybrid".equalsIgnoreCase(CLASSVALUE_MODE)) { + return new GroovyClassValueHybrid<>(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); + } + } +} From 09f38e278315d368faee091742984d5811c22cba Mon Sep 17 00:00:00 2001 From: Paul King Date: Wed, 19 Aug 2026 14:07:40 +1000 Subject: [PATCH 2/6] GROOVY-12281: quantify the cost of groovy.use.classvalue=false in the guide The escape hatch's trade-off was described as not measurable for most applications. Measured (57 classic-dispatch JMH benchmarks): +4.8% geomean with hot platform-receiver idioms up to ~1.4x, while compile time is within 1%. The guide now states those numbers so integrators can decide with their own workload in mind. Co-Authored-By: Claude Fable 5 --- GROOVY-12281-assessment.html | 230 ++++++++++++++++++++++++++++ src/spec/doc/guide-integrating.adoc | 6 +- 2 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 GROOVY-12281-assessment.html diff --git a/GROOVY-12281-assessment.html b/GROOVY-12281-assessment.html new file mode 100644 index 00000000000..6eae666a512 --- /dev/null +++ b/GROOVY-12281-assessment.html @@ -0,0 +1,230 @@ + + + + + + +GROOVY-12281 Assessment — ClassInfo.globalClassValue Loader Pinning + + + + +

GROOVY-12281 Assessment — ClassInfo.globalClassValue Loader Pinning

+

Investigation follow-up from GROOVY-12142 review · Assessment date: 19 August 2026 · Status: proposed pending team review

+ +
+

Verdict

+

The SoftReference-value strategy — in any gating, including "soft while pristine" — is not viable for globalClassValue. The investigation found two independent, code-grounded correctness failures for every drop-and-recompute scheme, one of which also nullifies the pristine-gated variant's entire benefit. The viable path is the per-key policy from the GROOVY-12142 review discussion, in a semantics-preserving form that never recomputes anything:

+
    +
  • Option B — hybrid store Decline after measurement: measured (§5), the hybrid tracks the map — not ClassValue — on macro dispatch, because dynamic code cannot avoid platform receivers (String, boxed numbers, DGM) and those are exactly the keys the hybrid maps. Its extra complexity buys a micro-only win that never surfaces.
  • +
  • Option D — flip the default to map-based Decline after measurement: the compiler gate passed (+1.0%, noise — §5.1) but the runtime JMH gate failed decisively (§5.2): geomean +4.8% across 57 classic dispatch benchmarks, with 12 statistically significant regressions including a 18–38% band on platform-receiver-heavy idioms (asToString 1.38×, elvis 1.32×, range iteration 1.32×, safe navigation 1.31×, map property access 1.23×, in operator 1.20×). This is far outside any "not measurable" claim and exactly the profile the synthetic micro results predicted.
  • +
+

Final recommendation: status quo, deliberately. Recompute-based options are illegal on correctness; B and D are declined on measurement. java.lang.ClassValue stays the default for its measured dispatch advantage, and the container pin remains addressed by the two remedies GROOVY-12142 already ships and documents — -Dgroovy.use.classvalue=false (whose cost is now quantified here: ~5% geomean on classic dispatch idioms, +1% compile) and explicit ClassInfo.remove() cleanup on undeploy. GROOVY-12281 closes as "investigated: no default change; escape-hatch cost quantified", with this document as the record. The integration guide's "for most applications the difference is not measurable" sentence should be tempered to reflect §5.2 (dispatch-heavy dynamic code can see mid-single-digit percent, with hot idioms up to ~1.4×).

+
+ +

1. Scope and the chain rule

+

From the GROOVY-12142 review analysis: a ClassValue realizes key class → association → value → everything reachable, and the association lives as long as the key class (JDK-8136353, working as intended). A Groovy-loaded key dies with its loader — fine; an immortal platform key (e.g. String) holds a Groovy-loaded value, and with it the whole Groovy class loader, forever. ClassInfo.globalClassValue (ClassInfo.java:113) is static with unknown keys including platform classes, so the default path pins; the only current remedy is the global groovy.use.classvalue=false escape hatch, which gives up the fast path for every key. GROOVY-12280 fixed the analogous problem in AwaitableAdapterRegistry with soft values — legitimately, because that cache's recomputation is a pure, cheap scan. This assessment answers whether globalClassValue can do the same. It cannot.

+ +

2. What a ClassInfo actually holds

+

Recompute-legality is a property of the value's state. The inventory:

+ + + + + + + +
FieldRecomputable?Written by / consumed by
classRef, cachedClassRef, artifactClassLoaderyesLazy caches (ClassInfo.java:78-79,131-135); pure derivations of the class.
versionnoGuard stamp. Classic call sites capture the ClassInfo/MetaClassImpl instance at link time and validate version == classInfo.getVersion() against that captured instance (groovy-callsite: StaticMetaClassSite.java:39-44, MetaClassConstructorSite.java:39-46, ConstructorMetaMethodSite.java:38,53; core: MetaClassImpl.java:3832).
indySwitchPointDomainpartlyPer-class indy guard domain (ClassInfo.java:92). Collection of a ClassInfo retires its domain via DomainReclaim (IndyInvalidation.java:114-137) — but only after actual GC plus reference processing, leaving a two-live-instances window.
strongMetaClass, perInstanceMetaClassMapnoUser state: EMC modifications, per-instance metaclasses (ClassInfo.java:101,105).
dgmMetaMethods, newMetaMethodsnoWritten once, externally: DGM and extension-module method arrays are pushed into the ClassInfo by CachedClass.updateSetNewMopMethods (CachedClass.java:566-575) during registry initialization and extension-module registration. There is no re-registration hook a recomputed instance could invoke.
+ +

3. Why every recompute scheme fails Decline Option A (soft values, any gating)

+

Failure 1 — irreversible DGM/extension-method loss

+

A recomputed ClassInfo starts with empty dgmMetaMethods/newMetaMethods. For String that means a subsequently created MetaClassImpl without DGM methods — core dispatch breaks — and there is no path by which recomputation could re-run the one-time registry/extension-module registration. This failure also destroys the pristine-gated variant's purpose: the platform classes are exactly the ones that are never pristine, because the registry writes DGM state into their ClassInfos at startup. Gating softness on pristineness would exclude String, Integer, Object… — precisely the immortal keys the mitigation exists for — so the scheme would carry all its complexity while unpinning nothing.

+

Failure 2 — split-brain guard stamps

+

Between a value being dropped and its old instance being collected, two ClassInfos exist for one class. Classic call sites hold the old instance and its version, which will never change again, so their guards accept forever — blind to any metaclass installed on the fresh instance: silently wrong dispatch, not a performance blip. The indy side is only partially protected: DomainReclaim retires an orphaned domain at reference-processing time, not at drop time, so the same window exists there until GC catches up. (Whether IndyInvalidation's live-registry reaches both domains during the window is a verification item, §6 — but the classic-path failure alone is disqualifying while groovy-callsite is supported.)

+
Net: the GROOVY-12280 pattern generalizes only where the value is stateless and externally reproducible. ClassInfo is neither — it is an identity-bearing registry node that other subsystems write into and guard against. This sharpens the ticket's premise: recompute is not merely "risky under memory pressure", it is structurally illegal here.
+ +

4. The viable options — never recompute; move the association instead

+

Option B — hybrid per-key store Decline (measured, §5)

+

Split by the key-origin rule, mechanically: keys whose loader is the bootstrap (null) or platform loader are stored in a weak-identity-key, strong-value map (the exact mechanics of GroovyClassValueMapBased, GroovyClassValueMapBased.java:39-56); every other key uses java.lang.ClassValue. Chain analysis: a platform key's entry now lives in a map owned by Groovy's loader — the platform class holds nothing, and map-entry-→-ClassInfo dies with Groovy itself; a user key's ClassValue association dies with the user class's loader, which is the webapp loader in the container topology. Both halves preserve today's semantics exactly: values are strong, live as long as their class, and are never recomputed — failures 1 and 2 cannot arise.

+
    +
  • Implementation: a third GroovyClassValue implementation (~40 lines) selected by GroovyClassValueFactory as the new default; groovy.use.classvalue=false keeps forcing the full map, and a true-style opt-out could force pure ClassValue for anyone who wants today's exact behavior.
  • +
  • Cost: one loader check per get (cacheable per call site is overkill; getClassLoader() on a hot path is cheap but must be measured), and map lookups for platform-class receivers — which include the hottest dispatch targets (String). This is the measurement gate.
  • +
  • Memory: platform-key entries live until Groovy's loader dies — identical to today's ClassValue lifetime, bounded by loaded platform classes.
  • +
+

Option D — default flip to map-based Decline (measured, §5.2)

+

Make GroovyClassValueMapBased the default and ClassValue the opt-in. One-line change, uniform semantics, kills the pin for all keys. Both gates were run: the compiler harness passed (+1.0%, within noise — §5.1), but the runtime JMH classic-dispatch gate failed (+4.8% geomean, 12 significant regressions up to 1.38× — §5.2). The decision rule set in advance was "within noise → flip; otherwise status quo" — so D is declined and the escape hatch remains the remedy, now with its cost quantified for the documentation.

+

Option C — split ClassInfo into recomputable/stateful parts Decline

+

A deep refactor of an identity-bearing class that half the MOP writes into, to enable a recompute scheme that B and D make unnecessary. All of the risk, none of the unique benefit.

+ +

5. Measurement (executed) — B loses to D

+

A hybrid prototype (GroovyClassValueHybrid, selected via -Dgroovy.use.classvalue=hybrid) was implemented on the investigation branch and measured against pure ClassValue and pure map. Harness: hand-rolled fresh-JVM-per-config driver (not JMH — treat as indicative), JDK 17, fixed 512 MB heap, medians of 7 in-JVM rounds × 3 JVMs per config. Micro = getClassInfo hot loop; macro = dynamic Groovy loops (200k iterations each: a String-DGM loop and a POGO method-call loop).

+ + + + + + + + +
ns/op (median)ClassValue (today)Map (Option D)Hybrid (Option B)
lookup, platform key (String)14–54
lookup, user key13–42
lookup, mixed keys154–5
loader predicate (per check)~0.5–0.7 — negligible
dispatch/iter, String-heavy loop58–92 (noisy)67–8168–82
dispatch/iter, POGO loop (tight)28–3033–3833–38
+

The finding that settles B-vs-D: hybrid tracks the map, not ClassValue, on both macro benchmarks — including the POGO loop built to favor it. The reason is structural, not an artifact: dynamic Groovy code cannot avoid platform receivers — every string concatenation, boxed-integer operation and DGM call dispatches on a platform key — and those are exactly the keys the hybrid routes to the map. B preserves the fast path only for user-class keys, whose lookups are the minority of hot getClassInfo traffic even in POGO-centric code. B's micro win on user keys (2 ns vs 3–4 ns) never surfaces at macro level.

+

Cost of D vs today: roughly +15% on the tight synthetic POGO loop (~29→~34 ns/iter over several dynamic ops), indistinguishable from noise on the wider String loop. Synthetic tight loops overstate the relative cost for real applications, consistent with the integration guide's "not measurable for most applications" — but a default flip should still be gated on the heavyweight macro suites, which this indicative harness does not replace.

+

5.1 Compiler-performance harness (executed) — gate passes

+

The standard harness (org.apache.groovy.perf.CompilerPerformanceTest, 50 warmup + 300 measured rounds compiling the 4-file corpus, run directly per the established repro method: fresh JVM, -Xms512m -Xmx512m, JDK 17, idle machine, modes alternated per round to cancel drift):

+ + + + + +
ModeRun means (ms)Overallvs ClassValue
ClassValue (today)304.3 / 307.9 / 313.6≈ 308.6
Map (Option D)302.7 / 316.0 / 316.8≈ 311.8+1.0%
Hybrid (Option B)304.3 (1 run)≈ 304≈ parity
+

Paired per-round deltas (−1.6 / +8.1 / +3.2 ms against per-run σ of 13–21 ms) put the map's central estimate at +1%, inside the ~2% gate and statistically indistinguishable from noise at this sample size. This is the expected shape: the compiler is Java code whose ClassInfo traffic is modest, unlike the dynamic-dispatch runtime path.

+ +

5.2 Runtime JMH classic-dispatch gate (executed) — gate fails

+

Method: the repo's JMH suite (subprojects/performance/src/jmh) built as the classic (-Pindy=false) fat jar; 57 benchmarks across MethodInvocationBench, OperatorBench, PropertyAccessBench, GroovyIdiomBench, and grails DynamicDispatchBench; annotation defaults (2 forks × 3 warmup + 5 measurement × 2s); one full sweep per mode with -jvmArgsAppend -Dgroovy.use.classvalue=… so the property reaches forked JVMs; significance = disjoint 99.9% CIs.

+ + + + + + +
Map vs ClassValueResult
Geomean over 57 benchmarks+4.8%
Significant regressions (>2%, CIs disjoint)12
Worst band (platform-receiver idioms)1.20×–1.38×
Significant improvements11, all ≤ 9% (mostly ~3%)
+

The regression tail is exactly the predicted profile — platform-receiver-heavy idioms: asToString 1.38×, elvisEmptyString 1.32×, rangeIteration 1.32×, safeNavNonNull 1.31×, mapDotPropertyAccess 1.23×, propertyMissingReadWrite 1.23×, inOperator 1.20×, dynamicTypedPropertyAccess 1.11×. Caveat: one sweep per mode (though 2 JMH forks each); the 20–38% band dwarfs run-to-run noise and matches both the synthetic micro results and the mechanism, so the verdict is robust. Gate failed → Option D declined per the pre-set decision rule. Raw results: jmh-cv-final.json (session scratchpad).

+ +

6. Risks and verification items

+
    +
  • Platform-key predicate: loader == null || loader == ClassLoader.getPlatformClassLoader() — confirm this classifies JDK-internal loaders acceptably and costs nothing measurable per lookup.
  • +
  • ManagedIdentityConcurrentMap contention on the hottest keys (String) under B/D — covered by the JMH plan; if contended, a striped or read-optimized map is a contained fix.
  • +
  • ClassInfo.remove(Class) (ClassInfo.java:328-330) works identically against both stores; the hybrid must route removes by the same predicate.
  • +
  • Verification item from §3: whether IndyInvalidation's live-domain registry reaches both domains while two instances coexist — moot for the chosen options (no recompute), but worth confirming while in the area, since it bounds the blast radius of any future recompute proposal.
  • +
  • Relationship to GROOVY-12280: related, not dependent — shared strategy vocabulary, no shared code; a common helper is a non-goal (the groovy-concurrent-java extraction constraint vs layering inversion, as recorded on both tickets).
  • +
+ +
+

Sources. GROOVY-12142 review discussion (chain model, key-origin rule, per-key policy suggestion); ClassInfo.java:78-135 (state, globalClassValue, GlobalClassSet weak membership :741-761), :566-575 of CachedClass.java (one-time DGM/extension writes), IndyInvalidation.java:114-167 (DomainReclaim, invalidateClass), groovy-callsite version stamps (StaticMetaClassSite, MetaClassConstructorSite, ConstructorMetaMethodSite), MetaClassImpl.java:3832, GroovyClassValueMapBased.java, GroovyClassValueFactory.java:27-40. Companion work: GROOVY-12280 (PR #2818), GROOVY-12282-class documentation ticket. All recommendations proposed pending team review.

+
+ + + 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`): + From 68fd09469b5bbeca90bc23ef98e926055fb62530 Mon Sep 17 00:00:00 2001 From: Paul King Date: Thu, 20 Aug 2026 14:33:43 +1000 Subject: [PATCH 3/6] =?UTF-8?q?GROOVY-12281:=20soft=20GroovyClassValue=20m?= =?UTF-8?q?ode=20=E2=80=94=20soft=20values=20with=20resurrection=20(opt-in?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds -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 (JDK-8136353 / GROOVY-12142). Correctness pieces, addressing the review findings on the v1 assessment: - Resurrection: a weak-key/weak-value side map is the identity authority; computeValue re-associates a still-reachable value instead of replacing it, so a fresh instance can only exist once no guard can observe the old one (kills the split-brain sequence; classic call-site version guards stay sound, so legacy groovy-callsite jars on the classpath are unaffected). - Dirty roots: ClassInfos carrying non-reconstructible state (installed MetaClass, per-instance MetaClasses, registry-written MOP arrays) are strong-rooted Groovy-side; the MOP-write hook also enforces the registry-rooting invariant (non-empty DGM arrays => never collectible) by construction. ClassInfo.remove() unroots, keeping hard-detach undeploy semantics. - Per-Class indy domain continuity: in soft mode SwitchPoint domains are adopted from a weak-Class-keyed map, so a successor ClassInfo shares its predecessor's domain and mutations deterministically retire guards that captured only the SwitchPoint (POJO direct dispatch); domain reclaim moves from ClassInfo-death to Class-death. Indy-only; default mode unchanged. Tests: GroovyClassValueSoftTest (unit); ClassInfoSoftModeTest/Probe (child JVM, deterministic forced clearing: resurrection identity+version, DGM rooting, EMC and per-instance survival, true collection + fresh dispatch, classic CallSiteArray soundness across clear + EMC change, predecessor SwitchPoint retirement); ClassInfoSoftModeStressTest/Probe (child JVM, real GC clearing: 128MB heap + SoftRefLRUPolicyMSPerMB=0, concurrent indy + classic dispatch racing the collector through ~130 EMC generations — 66M dispatches, 33/36 platform-receiver ClassInfos collected and recreated mid-run, zero invariant violations). build-logic forwards -Pgroovy.use.classvalue to test JVMs so whole suites can soak a non-default mode; the full core suite passes under soft mode (16,800 tests). --- .../groovy/org.apache.groovy-tested.gradle | 7 + .../groovy/runtime/indy/IndyInvalidation.java | 71 ++++ .../groovy/reflection/CachedClass.java | 4 + .../codehaus/groovy/reflection/ClassInfo.java | 110 ++++++- .../reflection/GroovyClassValueFactory.java | 21 +- .../reflection/GroovyClassValueSoft.java | 149 +++++++++ .../reflection/ClassInfoSoftModeProbe.groovy | 203 ++++++++++++ .../ClassInfoSoftModeStressProbe.groovy | 309 ++++++++++++++++++ .../ClassInfoSoftModeStressTest.groovy | 56 ++++ .../reflection/ClassInfoSoftModeTest.groovy | 49 +++ .../GroovyClassValueSoftTest.groovy | 135 ++++++++ 11 files changed, 1107 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/codehaus/groovy/reflection/GroovyClassValueSoft.java create mode 100644 src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeProbe.groovy create mode 100644 src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeStressProbe.groovy create mode 100644 src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeStressTest.groovy create mode 100644 src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeTest.groovy create mode 100644 src/test/groovy/org/codehaus/groovy/reflection/GroovyClassValueSoftTest.groovy 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..d77c7332ee8 100644 --- a/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java +++ b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java @@ -24,6 +24,7 @@ import groovy.lang.MetaClassImpl; import groovy.lang.MetaClassRegistryChangeEvent; 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; @@ -135,6 +136,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/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..0d40ff51b5b 100644 --- a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java +++ b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java @@ -61,6 +61,8 @@ import java.util.Collection; import java.util.Iterator; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; /** @@ -110,6 +112,29 @@ public class ClassInfo implements Finalizable { private static final ManagedConcurrentLinkedQueue modifiedExpandos = new ManagedConcurrentLinkedQueue(weakBundle); + /** + * Whether {@link #globalClassValue} stores its values softly with + * resurrection ({@code -Dgroovy.use.classvalue=soft}, GROOVY-12281 + * investigation prototype). Soft mode needs two cooperating pieces here: + * strong roots for non-reconstructible state ({@link #nonReclaimableRoots}) + * and per-Class indy domain continuity (see {@link #indyDomain()}). + */ + private static final boolean SOFT_CLASS_VALUES = GroovyClassValueFactory.isSoftMode(); + + /** + * Soft-mode strong roots for ClassInfos whose state could not be + * reconstructed if the instance were soft-collected and later recreated: + * an installed class-level MetaClass ({@link #setStrongMetaClass}), + * per-instance MetaClasses, or registry-written DGM/extension method + * arrays ({@link CachedClass#setNewMopMethods}/{@code addNewMopMethods}). + * The set itself is Groovy-loaded, so it contributes no foreign-loader + * pinning: everything in it dies with Groovy's own loader, which is the + * lifetime every ClassInfo has today under the default strong ClassValue. + * {@code null} unless soft mode is active. + */ + private static final Set nonReclaimableRoots = + SOFT_CLASS_VALUES ? ConcurrentHashMap.newKeySet() : null; + private static final GroovyClassValue globalClassValue = GroovyClassValueFactory.createGroovyClassValue(new ComputeValue(){ /** * Creates a new {@code ClassInfo} for the given class type. @@ -189,6 +214,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. + *

+ * Soft mode (GROOVY-12281): 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 (SOFT_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 +252,22 @@ private void bumpGenerationLocal() { */ @Internal public SwitchPoint getIndySwitchPoint() { + SwitchPointInvalidator domain = indyDomain(); if (!indyDomainAnchored) { - IndyInvalidation.anchorClassDomain(this, indySwitchPointDomain); + if (SOFT_CLASS_VALUES) { + // Soft mode retires 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 +279,7 @@ public SwitchPoint getIndySwitchPoint() { */ @Internal public void invalidateIndySwitchPoint() { - indySwitchPointDomain.invalidate(); + indyDomain().invalidate(); } /** @@ -227,7 +291,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 +306,7 @@ public void collectLiveIndySwitchPoints(final List out) { */ @Internal public SwitchPoint detachLiveIndySwitchPoint() { - return indySwitchPointDomain.detachLive(); + return indyDomain().detachLive(); } /** @@ -326,6 +390,15 @@ public static ClassInfo getClassInfo (Class cls) { * from cache */ public static void remove(Class cls) { + if (SOFT_CLASS_VALUES && globalClassValue instanceof GroovyClassValueSoft) { + // A hard detach must also unroot the detached instance, or an + // undeployed class's ClassInfo would stay pinned by its own + // non-reclaimable root — the exact leak remove() exists to break. + ClassInfo current = ((GroovyClassValueSoft) globalClassValue).getIfPresent(cls); + if (current != null) { + nonReclaimableRoots.remove(current); + } + } globalClassValue.remove(cls); } @@ -400,6 +473,31 @@ public void setStrongMetaClass(MetaClass answer) { } replaceWeakMetaClassRef(null); + updateReclaimability(); + } + + /** + * Soft-mode bookkeeping (GROOVY-12281): keeps this ClassInfo strongly + * rooted while it carries state that could not be reconstructed after + * soft collection — an installed class-level MetaClass, per-instance + * MetaClasses, or registry-written DGM/extension method arrays. The DGM + * condition also enforces the registry-rooting invariant by construction + * rather than by audit: any instance holding non-empty MOP arrays is + * non-collectible, so a recreated instance never needs to rebuild them. + * Removal is conservative: lingering weak entries in the per-instance map + * merely delay unrooting, which is the safe direction (today's default + * roots every ClassInfo forever). No-op unless soft mode is active. + */ + void updateReclaimability() { + if (!SOFT_CLASS_VALUES) return; + if (strongMetaClass != null + || (perInstanceMetaClassMap != null && !perInstanceMetaClassMap.isEmpty()) + || dgmMetaMethods.length != 0 + || newMetaMethods.length != 0) { + nonReclaimableRoots.add(this); + } else { + nonReclaimableRoots.remove(this); + } } /** @@ -431,6 +529,7 @@ public void setWeakMetaClass(MetaClass answer) { newRef = new ManagedReference (softBundle,answer); } replaceWeakMetaClassRef(newRef); + updateReclaimability(); } private void replaceWeakMetaClassRef(ManagedReference newRef) { @@ -688,6 +787,7 @@ public void setPerInstanceMetaClass(Object obj, MetaClass metaClass) { perInstanceMetaClassMap.remove(obj); } } + updateReclaimability(); } /** diff --git a/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java index 3b9b685bc00..9b0e987266e 100644 --- a/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java +++ b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java @@ -33,13 +33,30 @@ class GroovyClassValueFactory { */ private static final String CLASSVALUE_MODE = SystemUtil.getSystemPropertySafe("groovy.use.classvalue", "true"); + /** + * GROOVY-12281 investigation prototype: whether values are stored behind + * {@link java.lang.ref.SoftReference}s with resurrection semantics + * ({@code -Dgroovy.use.classvalue=soft}). Soft mode needs cooperation from + * {@link ClassInfo} (strong roots for non-reconstructible state, per-Class + * indy domain continuity), which is why it is exposed package-wide rather + * than kept local to {@link #createGroovyClassValue}. + */ + static boolean isSoftMode() { + return "soft".equalsIgnoreCase(CLASSVALUE_MODE); + } + public static GroovyClassValue createGroovyClassValue(ComputeValue computeValue) { - // GROOVY-12281 investigation prototype: "hybrid" routes platform-loader keys to the + // 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. + // 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, so immortal keys hold no strong chain to the value's loader. if ("hybrid".equalsIgnoreCase(CLASSVALUE_MODE)) { return new GroovyClassValueHybrid<>(computeValue); } + if (isSoftMode()) { + 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/GroovyClassValueSoft.java b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueSoft.java new file mode 100644 index 00000000000..a179924ec20 --- /dev/null +++ b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueSoft.java @@ -0,0 +1,149 @@ +/* + * 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; + +/** + * 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 {@link SoftReference}, so an association on an immortal + * platform class (for example {@code String}) no longer holds a strong chain + * to the value's class loader (JDK-8136353 / GROOVY-12142). The stored wrapper + * is a bootstrap-loaded {@code java.lang.ref.SoftReference}, and 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 and is 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 {@code ClassValue}'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. + *

+ * {@link #remove(Class)} stays a hard detach (the identity entry is + * purged 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]; + + private final ClassValue> store = new ClassValue>() { + @Override + protected SoftReference computeValue(final Class type) { + return 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 = store.get(type).get(); + if (value != null) return value; + // The soft reference was cleared: drop the memoized wrapper and + // recompute once — resurrection returns the canonical instance when + // it is still alive (GROOVY-12280 remove-and-recompute pattern). + store.remove(type); + value = 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); + } + + @Override + public void remove(final Class type) { + canonical.remove(type); + store.remove(type); + } + + /** + * 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/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeProbe.groovy b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeProbe.groovy new file mode 100644 index 00000000000..7b9b41abe06 --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeProbe.groovy @@ -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 groovy.lang.GroovySystem + +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.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' } + } + + /** Clears the global store's memoized SoftReference for {@code type}. */ + private static void clearSoft(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) + ((SoftReference) store.get(type)).clear() + } + + 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 + } + + static void main(String[] args) { + resurrectionPreservesIdentityAndVersion() + dgmTargetClassInfoIsRooted() + strongMetaClassSurvivesClear() + perInstanceMetaClassSurvivesClear() + pristineClassInfoIsCollectedAndRecreatedWorking() + classicCallSiteStaysSoundAcrossClearAndRelinksOnChange() + indyDomainContinuityAcrossRecreation() + println 'OK' + } + + /** + * The split-brain check: a ClassInfo captured by any holder must be + * returned as-is after the ClassValue's soft reference clears, with its + * version untouched, so captured version guards stay sound. + */ + private static void resurrectionPreservesIdentityAndVersion() { + ClassInfo before = ClassInfo.getClassInfo(String) // strong local ref: "captured by a call site" + int version = before.version + clearSoft(String) + ClassInfo after = ClassInfo.getClassInfo(String) + 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 strongly rooted, so it can never be soft-collected and a + * recreated instance never needs to rebuild those arrays. + */ + private static void dgmTargetClassInfoIsRooted() { + def weak = new WeakReference(ClassInfo.getClassInfo(String)) + clearSoft(String) + System.gc() + assert weak.get() != null : 'DGM-target ClassInfo must be rooted (non-reclaimable)' + assert ClassInfo.getClassInfo(String).is(weak.get()) + assert 'abc'.reverse() == 'cba' : 'String DGM dispatch intact' + } + + /** User metaclass customizations must survive value clearing (dirty root). */ + private static void strongMetaClassSurvivesClear() { + CRC32.metaClass.twiddle = { -> 42 } + try { + def weak = new WeakReference(ClassInfo.getClassInfo(CRC32)) + clearSoft(CRC32) + System.gc() + assert weak.get() != null : 'ClassInfo with installed MetaClass must be rooted' + assert new CRC32().twiddle() == 42 : 'EMC customization must survive the clear' + } finally { + GroovySystem.metaClassRegistry.removeMetaClass(CRC32) + } + } + + /** Per-instance metaclasses are equally non-reconstructible state. */ + private static void perInstanceMetaClassSurvivesClear() { + def receiver = new Adler32() + receiver.metaClass.spin = { -> 7 } + try { + def weak = new WeakReference(ClassInfo.getClassInfo(Adler32)) + clearSoft(Adler32) + System.gc() + assert weak.get() != null : 'ClassInfo with per-instance MetaClass must be rooted' + assert receiver.spin() == 7 : 'per-instance customization must survive the clear' + } 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 'cba' forever. + */ + 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, ['reverse'] as String[]) + def noparam = csaClass.NOPARAM + assert csa.array[0].call('abc', noparam) == 'cba' + // the linked site (csa.array[0] after the first call) now captures ClassInfo(String)+version + clearSoft(String) + System.gc() + assert csa.array[0].call('abc', noparam) == 'cba' : 'linked site stays correct across the clear' + String.metaClass.reverse = { -> 'emc' } + try { + assert csa.array[0].call('abc', noparam) == 'emc' : 'version guard on the resurrected instance must observe the change' + } finally { + GroovySystem.metaClassRegistry.removeMetaClass(String) + } + assert csa.array[0].call('abc', noparam) == 'cba' + } + + /** + * 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" + } +} 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..f8ca44177bc --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoSoftModeTest.groovy @@ -0,0 +1,49 @@ +/* + * 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, + '-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..eccf864ed1c --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/reflection/GroovyClassValueSoftTest.groovy @@ -0,0 +1,135 @@ +/* + * 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) + } + + /** Clears the memoized SoftReference for {@code type}, as a GC would. */ + private static void forceClear(GroovyClassValueSoft subject, Class type) { + def storeField = GroovyClassValueSoft.getDeclaredField('store') + storeField.accessible = true + ClassValue store = storeField.get(subject) + ((SoftReference) store.get(type)).clear() + } + + 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 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()) + } +} From e30e8dad0dc6bb8d32af9745232a03a5346d6f35 Mon Sep 17 00:00:00 2001 From: Paul King Date: Thu, 20 Aug 2026 14:33:43 +1000 Subject: [PATCH 4/6] =?UTF-8?q?GROOVY-12281:=20assessment=20v2=20=E2=80=94?= =?UTF-8?q?=20recomputation=20avenue=20reopened,=20prototyped,=20measured?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes v1 in place after review: withdraws the blanket "structurally illegal" verdict (Failure 1 cannot occur for collectible ClassInfos — the registry-rooting invariant; Failure 2 is real, demonstrated via the $callSiteArray per-referent-clearing sequence, and prevented by construction via resurrection). Records the reference-graph census (including POGO self-pinning), the indy capture audit, the Option E design, the consolidated option landscape for the adoption discussion (three modes x static/dynamic x indy/classic, with guidance), and the measurements: - Acceptance (Groovy12281LoaderSpike, JDK 17/21/23): dropped child-loader Groovy is pinned forever under the default ClassValue, collected under soft mode once pressure clears soft references — the ticket's first demonstrated unpinning that keeps the ClassValue fast path. - Cost (Groovy12281PerfSpike + compiler harness + classic JMH sweep): +0.6ns on a raw getClassInfo lookup, macro dispatch and compilation at parity, 0 of 37 JMH classic benchmarks significant (geomean +1.7%; Option D failed the same rule with 12); focused re-measurement puts classic polymorphic miss traffic at a pooled +3.7%, flagged for the idiom-suite sweep before any default-flip discussion. v1's measured declines of Options B (hybrid) and D (map default) stand. Recommendation: opt-in mode now; default question deferred to team review. --- GROOVY-12281-assessment.html | 212 +++++++++++++++++++++++------------ Groovy12281LoaderSpike.java | 113 +++++++++++++++++++ Groovy12281PerfSpike.groovy | 108 ++++++++++++++++++ 3 files changed, 364 insertions(+), 69 deletions(-) create mode 100644 Groovy12281LoaderSpike.java create mode 100644 Groovy12281PerfSpike.groovy diff --git a/GROOVY-12281-assessment.html b/GROOVY-12281-assessment.html index 6eae666a512..f473d157a89 100644 --- a/GROOVY-12281-assessment.html +++ b/GROOVY-12281-assessment.html @@ -21,7 +21,7 @@ -GROOVY-12281 Assessment — ClassInfo.globalClassValue Loader Pinning +GROOVY-12281 Assessment v2 — ClassInfo.globalClassValue Loader Pinning