Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions build-logic/src/main/groovy/org.apache.groovy-tested.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,8 +74,22 @@
* Production guards: {@code IndyInterface.applyMopSwitchPoints}; tests may use
* {@link #guardWithMopSwitchPoints}.
*
* <h2>Layering</h2>
* This class is the <em>policy</em> 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());
Expand Down Expand Up @@ -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.
* <p>
* 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<Class<?>, 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<Class<?>> {
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
// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
* <h2>Layering</h2>
* This is the <em>mechanism</em> 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 {

/**
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/org/codehaus/groovy/reflection/CachedClass.java
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,8 @@ private void updateSetNewMopMethods(List<MetaMethod> arr) {
}
else
classInfo.newMetaMethods = classInfo.dgmMetaMethods;
// GROOVY-12281 soft mode: MOP-array writes make the ClassInfo non-reclaimable
classInfo.updateReclaimability();
}

/**
Expand Down Expand Up @@ -622,6 +624,8 @@ private void updateAddNewMopMethods(List<MetaMethod> 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();
Expand Down
97 changes: 92 additions & 5 deletions src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -189,6 +199,34 @@ private void bumpGenerationLocal() {
invalidateIndySwitchPoint();
}

/**
* Resolves the SwitchPoint domain all indy guard operations act on.
* <p>
* 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.
* <p>
* 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
Expand All @@ -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();
}

/**
Expand All @@ -215,7 +264,7 @@ public SwitchPoint getIndySwitchPoint() {
*/
@Internal
public void invalidateIndySwitchPoint() {
indySwitchPointDomain.invalidate();
indyDomain().invalidate();
}

/**
Expand All @@ -227,7 +276,7 @@ public void invalidateIndySwitchPoint() {
*/
@Internal
public void collectLiveIndySwitchPoints(final List<SwitchPoint> out) {
SwitchPoint live = indySwitchPointDomain.detachLive();
SwitchPoint live = indyDomain().detachLive();
if (live != null) {
out.add(live);
}
Expand All @@ -242,7 +291,7 @@ public void collectLiveIndySwitchPoints(final List<SwitchPoint> out) {
*/
@Internal
public SwitchPoint detachLiveIndySwitchPoint() {
return indySwitchPointDomain.detachLive();
return indyDomain().detachLive();
}

/**
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 <em>inside its own
* association</em> ({@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);
}
}

/**
Expand Down Expand Up @@ -431,6 +516,7 @@ public void setWeakMetaClass(MetaClass answer) {
newRef = new ManagedReference<MetaClass> (softBundle,answer);
}
replaceWeakMetaClassRef(newRef);
updateReclaimability();
}

private void replaceWeakMetaClassRef(ManagedReference<MetaClass> newRef) {
Expand Down Expand Up @@ -688,6 +774,7 @@ public void setPerInstanceMetaClass(Object obj, MetaClass metaClass) {
perInstanceMetaClassMap.remove(obj);
}
}
updateReclaimability();
}

/**
Expand Down
43 changes: 41 additions & 2 deletions src/main/java/org/codehaus/groovy/reflection/GroovyClassValue.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,46 @@ interface ComputeValue<T>{
}

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 <em>from its own key's
* association</em> 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) {
}
}
Loading
Loading