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..f32336d0da5 100644
--- a/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java
+++ b/src/main/java/org/codehaus/groovy/reflection/GroovyClassValueFactory.java
@@ -31,10 +31,23 @@ 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; 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);
public static GroovyClassValue createGroovyClassValue(ComputeValue computeValue) {
- return (USE_CLASSVALUE)
+ // GROOVY-12281: "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 (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/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