Skip to content

GROOVY-12281: opt-in soft GroovyClassValue mode — unpins class loaders while keeping the ClassValue fast path - #2820

Open
paulk-asert wants to merge 2 commits into
apache:groovy12281from
paulk-asert:groovy12281
Open

GROOVY-12281: opt-in soft GroovyClassValue mode — unpins class loaders while keeping the ClassValue fast path#2820
paulk-asert wants to merge 2 commits into
apache:groovy12281from
paulk-asert:groovy12281

Conversation

@paulk-asert

@paulk-asert paulk-asert commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What this PR is

The master-bound payload of the GROOVY-12281 investigation, now exactly two commits:

  • soft GroovyClassValue mode — soft values with resurrection (opt-in): the reviewed
    change, rebuilt without the declined hybrid prototype (the factory has only
    true/soft/false arms);
  • review response — ephemeron pinning + store capability: the delta responding to the
    review below, kept as a separate commit so it can be re-reviewed against the earlier
    state. Intended to be squashed into the first commit at landing — the earlier
    global-root-set design should not exist in master history (it has the reverse-scenario
    leak described below).

The investigation artifacts (hybrid prototype, escape-hatch cost quantification for the
guide, assessment document, loader/perf spike harnesses) now live on the companion branch
groovy12281-investigation:
GROOVY-12281-assessment.html
(v3; §12 records the review round and response).

Problem

A java.lang.ClassValue association lives as long as its key class, so
ClassInfo.globalClassValue entries on immortal platform classes (String, Integer, …) hold a
strong chain to the ClassInfo and through it to Groovy's class loader — leaking every Groovy
copy a container deploys and undeploys (JDK-8136353 / GROOVY-12142). The existing remedy,
-Dgroovy.use.classvalue=false, gives up the per-Class fast path for every key
(~4× on the raw lookup, +4.8% geomean on classic dispatch, hot platform idioms to 1.38×).

Change

-Dgroovy.use.classvalue=soft keeps ClassValue for all keys but stores each value in a
per-Class slot (a bootstrap AtomicReference) holding a bootstrap SoftReference, cutting
the only strong chain from immortal keys to the Groovy island. Three cooperating pieces make
that safe (details and file references in the assessment):

  • Resurrection — a weak-key/weak-value side map is the identity authority: a value still
    reachable anywhere is re-associated, never replaced, so a fresh instance can only exist once
    no guard can still observe the old one. Classic call-site version guards therefore stay
    sound: legacy-compiled jars using groovy-callsite from the classpath are unaffected.
  • Ephemeron pinning — ClassInfos carrying non-reconstructible state (installed MetaClass,
    per-instance MetaClasses, registry-written DGM/extension arrays) are pinned inside their
    own association
    : the slot flips from soft to strong, so a pinned instance lives exactly as
    long as its class — an immortal platform key retains it, a dropped script class releases it
    together with its loader (the "reverse" scenario from review). The store advertises this
    via a GroovyClassValue capability (valuesReclaimable() + default-no-op pin/unpin),
    so ClassInfo never consults the mode; ClassInfo.remove() keeps its hard-detach undeploy
    semantics with no store-specific handling — the pin travels with the association.
  • Per-Class indy domain continuity — SwitchPoint domains are keyed by Class in soft
    mode, so a successor ClassInfo adopts its predecessor's domain and mutations
    deterministically retire guards that captured only the SwitchPoint (POJO direct dispatch).
    Indy-only; the default mode's behaviour is unchanged. The IndyInvalidation /
    SwitchPointInvalidator layering (policy vs mechanism, with ClassInfo the only other
    supported consumer) is now documented on both classes, which are @Internal.

Default-mode behaviour is byte-identical apart from no-op hook calls; all soft-mode structures
are unallocated unless the flag is set.

Evidence

  • Acceptance (the ticket's point; JDK 17/21/23): a dropped child-loader Groovy copy is
    pinned forever under the default and collected under soft mode once pressure clears
    soft references.
  • Reverse acceptance (from review): the runtime stays alive while script loaders come and
    go, each installing an EMC on a class it created — under soft mode the dropped loaders are
    collected on memory pressure exactly as under the default (20/20; the earlier root-set
    design leaked 0/20). Now a probe scenario.
  • Correctness: 9 unit tests + a deterministic child-JVM probe with 8 scenarios
    (resurrection identity/version, pinned-slot assertions for DGM/EMC/per-instance state,
    EMC removal unpins, collection + recreation dispatch, classic CallSiteArray soundness,
    predecessor-SwitchPoint retirement, the reverse scenario); a real-GC concurrency
    stress probe (128 MB heap, SoftRefLRUPolicyMSPerMB=0): 66M dispatches, ~130 EMC
    generations, 33/36 platform-receiver ClassInfos collected and recreated mid-run, zero
    invariant violations; full core suite passes under soft mode (16,800 tests).
  • Cost: raw getClassInfo 1.2 → 1.7 ns; compiler harness +0.0%; classic-bytecode JMH gate
    0 of 37 significant (geomean +1.7%; the declined map default failed the same rule with 12,
    worst 1.38×); classic polymorphic miss traffic ≈ +3.7% pooled, flagged for the idiom-suite
    sweep before any default-flip discussion.

Open questions for review

Assessment §9 consolidates the resulting option landscape (true/soft/false ×
static/dynamic × indy/classic, with guidance); §10 lists the open items: adoption path
(opt-in now — recommended — vs eventual default), whether domain re-homing should become
unconditional rather than soft-gated, and container soak experience. §12 records the PR
review round. One separate finding will get its own JIRA: =false on current master fails
the reverse scenario (0/20 collected vs 20/20 on released 5.0.6/6.0.0-beta-2) — the
escape-hatch rework's weak-key/strong-value map is not an ephemeron.

JIRA: https://issues.apache.org/jira/browse/GROOVY-12281

@codecov-commenter

codecov-commenter commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.85714% with 27 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (groovy12281@e801df3). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...java/org/codehaus/groovy/reflection/ClassInfo.java 62.9630% 7 Missing and 3 partials ⚠️
...g/apache/groovy/runtime/indy/IndyInvalidation.java 10.0000% 9 Missing ⚠️
...dehaus/groovy/reflection/GroovyClassValueSoft.java 86.4865% 2 Missing and 3 partials ⚠️
...aus/groovy/reflection/GroovyClassValueFactory.java 40.0000% 1 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@               Coverage Diff                @@
##             groovy12281      #2820   +/-   ##
================================================
  Coverage               ?   70.2671%           
  Complexity             ?      36198           
================================================
  Files                  ?       1571           
  Lines                  ?     133613           
  Branches               ?      24592           
================================================
  Hits                   ?      93886           
  Misses                 ?      31236           
  Partials               ?       8491           
Files with missing lines Coverage Δ
...he/groovy/runtime/indy/SwitchPointInvalidator.java 100.0000% <ø> (ø)
...va/org/codehaus/groovy/reflection/CachedClass.java 71.2500% <100.0000%> (ø)
...g/codehaus/groovy/reflection/GroovyClassValue.java 100.0000% <100.0000%> (ø)
...aus/groovy/reflection/GroovyClassValueFactory.java 37.5000% <40.0000%> (ø)
...dehaus/groovy/reflection/GroovyClassValueSoft.java 86.4865% <86.4865%> (ø)
...g/apache/groovy/runtime/indy/IndyInvalidation.java 81.9820% <10.0000%> (ø)
...java/org/codehaus/groovy/reflection/ClassInfo.java 85.8369% <62.9630%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@paulk-asert
paulk-asert requested a review from blackdrag August 20, 2026 11:50

@blackdrag blackdrag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think besides the architecture question I have one more... What happens if you do the reverse memory pressure test? I mean spinning up many GroovyShells and execute some scripts in there, that for example set EMC on classes they create. The expectation would be that on memory pressure the old script instances and their meta classes can be collected. I called it reverse, because in this case the Groovy runtime stays alive.

*
* @since 6.0.0
*/
public final class IndyInvalidation {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The architectural relationship between IndyInvalidation and SwitchPointInvalidator makes me wonder. The issue is not only that IndyInvalidation is tightly coupled to SwitchPointInvalidator, but also which of the two is supposed to be the abstraction boundary?

  • If IndyInvalidation is a wrapper/facade around SwitchPointInvalidator, then SwitchPointInvalidator is effectively an implementation detail of the higher-level invalidation mechanism. In that case, it would be surprising for SwitchPointInvalidator to remain independently exposed and used. Consumers should normally go through IndyInvalidation, otherwise the wrapper does not really define the abstraction boundary.

  • If SwitchPointInvalidator is intended to be an independently usable abstraction, then the two classes should be considered independent components which happen to cooperate. In that case, their respective responsibilities, lifecycle guarantees and invariants need to be clearly defined. In particular, it should be clear which guarantees SwitchPointInvalidator provides on its own and which only exist when it is managed through IndyInvalidation.

At the moment, the implementation seems somewhat in between these two models. IndyInvalidation directly manages SwitchPointInvalidator instances and depends on its lifecycle and global state, which makes the relationship look more like a layered abstraction than two independent components. At the same time, SwitchPointInvalidator is still exposed and can be used independently.

This should be document, otherwise it is difficult to determine if the current coupling is intentional or simply an implementation dependency that has become externally visible.

If SwitchPointInvalidator can be used independently as well as through IndyInvalidation, then the correctness of the overall invalidation machinery cannot simply rely on invariants established by IndyInvalidation. The independent use cases need to obey the same assumptions, or the boundary between the two needs to enforce those assumptions.

If SwitchPointInvalidator is really only the low-level mechanism behind IndyInvalidation, hiding it behind that abstraction would make the ownership and lifecycle model much easier to reason about and would also leave room to change the underlying invalidation mechanism later.

I think the important point is not simply whether the classes are coupled, but whether the current API and usage actually match the intended abstraction boundary. If they are one layered subsystem, that relationship should be explicit; if they are independent abstractions, their independence and invariants need to be justified.

* 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isSoftMode() is a bit suspicious. Again it produces a tight coupling to a certain implementation. Why is a ClassValue with SoftReferences a soft mode, but a ManagedMap based version not?

* {@code null} unless soft mode is active.
*/
private static final Set<ClassInfo> nonReclaimableRoots =
SOFT_CLASS_VALUES ? ConcurrentHashMap.newKeySet() : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if we always do that and not only in soft mode? This is also not the only place. Either we have to do it, then this is missing an abstraction in my eyes. Or we do not, well, then we should simplify the code

* than kept local to {@link #createGroovyClassValue}.
*/
static boolean isSoftMode() {
return "soft".equalsIgnoreCase(CLASSVALUE_MODE);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not store soft mode as boolean?

// 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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not part of the PR really, but if you do a boolean for soft mode, you could also do one for hybrid then. Or you switch to a model where you do not use the boolean, but a provider GroovyClassValueHybrid::new and so on. Just a thought

private final ClassValue<SoftReference<T>> store = new ClassValue<SoftReference<T>>() {
@Override
protected SoftReference<T> computeValue(final Class<?> type) {
return new SoftReference<>(canonical(type));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually no wonder... does the ClassInfo case really need a SoftReference or could it be a WeakReference? The SoftReference is maybe the more "general", "cache friendly" variant, but I have now second thoughts of if it should be defined at this level, also considering the factory. But it could stay like this for now

paulk-asert and others added 2 commits August 21, 2026 15:31
…ion (opt-in)

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).
association; expose value reclaimability as a store capability

Jochen's reverse memory-pressure question on PR apache#2820 found a real leak:
the global strong root set extended EMC-dirty script-created classes
(and their loaders) to the runtime's lifetime, where the default mode
releases them with their class (probe: 0/20 loaders collected vs 20/20).
The root set's justifying comment was true for immortal platform keys
but false for collectible ones.

- GroovyClassValue gains valuesReclaimable() plus default-no-op
  pin/unpin: callers never consult the mode; they ask the store for its
  capabilities.
- GroovyClassValueSoft stores a per-Class slot (bootstrap
  AtomicReference) holding either a bootstrap SoftReference
  (reclaimable) or the value itself (pinned). The strong hold lives
  inside the association — an ephemeron — so a pinned ClassInfo lives
  exactly as long as its class: an immortal platform key retains it, a
  dropped script class releases it together with its loader. pin()
  retries across a concurrent remove-and-recompute; unpin() is a CAS so
  a foreign value cannot downgrade the slot.
- ClassInfo drops nonReclaimableRoots and the isSoftMode() coupling;
  updateReclaimability() pins/unpins unconditionally (no-ops outside
  soft mode) and remove() needs no store-specific unroot: the pin
  travels with the association.
- GroovyClassValueFactory stores the parsed mode as booleans; the mode
  string never leaves the factory.
- IndyInvalidation / SwitchPointInvalidator document their layering
  (mechanism vs policy, with ClassInfo the only other supported
  consumer) and are annotated @internal.

Tests: GroovyClassValueSoftTest adds the capability, pin-until-unpin,
remove-releases-pin and foreign-unpin-noop cases; ClassInfoSoftModeProbe
gains the reverse scenario (an EMC-dirty script class dies with its
loader under memory pressure) and asserts pinned slots directly — the
stronger claim, since a pinned slot has no clearable reference. The
reverse scenario is @CompileStatic and nulls its loop locals on purpose:
indy call-site guards in the long-lived probe class and stale frame
slots retain script classes in every mode — receiver-side effects
distinct from the association lifetime under test. The classic call-site
scenario moves to a clean POJO receiver, since a pinned receiver like
String no longer has a clearable reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@testlens-app

testlens-app Bot commented Aug 21, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

⚠️ TestLens detected flakiness ⚠️

Test Summary

Build and test / lts (17, ubuntu-latest) > :test

Test Runs Flakiness
ClassInfoSoftModeStressTest > softModeSurvivesRealGcClearingUnderConcurrency() ❌ ✅ 20% 🔴

Build and test / lts (17, windows-latest, 1) > :test

Test Runs Flakiness
ClassInfoSoftModeStressTest > softModeSurvivesRealGcClearingUnderConcurrency() ❌ ✅ 20% 🔴

🏷️ Commit: f242c84
▶️ Tests: 44600 executed
⚪️ Checks: 31/31 completed


Learn more about TestLens at testlens.app/docs.

@paulk-asert

Copy link
Copy Markdown
Contributor Author

@blackdrag Response to your questions in the issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants