[
https://issues.apache.org/jira/browse/GROOVY-12281?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18106128#comment-18106128
]
Paul King commented on GROOVY-12281:
------------------------------------
Thanks Jochen — you were right that the "structurally illegal" conclusion
overreached, and re-grounding each claim in the code has been productive: the
recomputation avenue is not just open, it now has a working prototype (branch
groovy12281, assessment updated to v2). Taking your points in order:
*1 / 5 (ClassInfo not intrinsically immortal; caches deliberately recreated)* —
conceded. The runtime already recreates MetaClass/CachedClass state while
retaining the ClassInfo shell, and DomainReclaim already treats ClassInfo death
as an anticipated event. The identity-bearing residue is much smaller than v1
claimed: version (guard stamps), the strong/per-instance MetaClass slots, and
the MOP method arrays.
*4 / 6 ("written once" vs "non-reconstructible")* — conceded, and it resolves
better than reconstruction: the registry permanently retains every registered
MetaMethod ({{MetaClassRegistryImpl.instanceMethods/staticMethods}}, extension
modules included), and every registration path stores the _same CachedClass
instance_ the methods are written into ({{GeneratedMetaMethod.declaringClass}};
{{NewMetaMethod.bytecodeParameterTypes[0]}}; dgmimpl static fields). Since
CachedClass holds classInfo strongly, *any ClassInfo with non-empty DGM arrays
is permanently strongly rooted by the registry* — it can never be
soft-collected, so a recreated instance never needs to rebuild those arrays
(they are always empty for collectible classes, with hierarchy dispatch
supplying inherited DGM methods, which is the normal path). The prototype
enforces this invariant by construction rather than by audit: a MOP-array write
roots the ClassInfo ({{CachedClass.updateSetNewMopMethods}} →
{{ClassInfo.updateReclaimability}}), which also covers hypothetical third-party
MetaMethod implementations.
*2 (concrete reachable-object sequence for split-brain)* — here is one, and it
is sharper than v1's version: classic call sites are reachable only through the
caller's {{static SoftReference $callSiteArray}} (CallSiteWriter), so "strong
retention by a live call site" is itself only _soft_ reachability.
Soft-reference clearing is per-referent, and a linked classic site never
touches the ClassValue on its fast path (the guard reads version from the
captured instance), so the ClassValue's {{SoftReference<ClassInfo>}} goes
LRU-stale precisely while the site is hottest. A collector may therefore clear
the association while the site's chain survives: next getClassInfo creates B,
an EMC installed via B bumps B's version, and the surviving site guards A
forever. So naive soft values are indeed unsafe — but the fix is structural:
*resurrection*. A weak-key/weak-value side map is the identity authority;
computeValue consults it first, so a value still reachable _anywhere_ is
re-associated rather than replaced. A fresh instance can only be created once
the old one is weakly unreachable — at which point no guard can still reference
it, so two live generations of one association cannot arise, and version
continuity is automatic. remove(Class) stays a hard detach (undeploy semantics
unchanged).
*3 (indy analysed separately)* — done. Indy resolves ClassInfo fresh at link
time and invalidation routes through the canonical instance, but installed
guards keep only the SwitchPoint's internal invoker alive, and POJO
direct-dispatch chains capture no Groovy object at all (GroovyObject/MOP paths
do capture mc). So an indy guard _can_ outlive its ClassInfo, and under soft
values the lazy DomainReclaim pump would leave an unbounded stale-guard window.
*6 (indySwitchPointDomain ownership)* — your instinct was right: attaching the
domain to a stable per-Class identity is the correct fix for the above. In soft
mode the domain is adopted from a weak-Class-keyed map
({{IndyInvalidation.classDomainFor}}), so a successor ClassInfo shares its
predecessor's domain and a mutation applied through the successor
deterministically retires guards linked under the predecessor. This is
deliberately an indy-side improvement; classic sites need no equivalent because
resurrection preserves the instance their guards captured — relevant given the
deprecation path for the classic callsite code, which must keep working from
the classpath but shouldn't constrain indy design.
*Results* (prototype = {{-Dgroovy.use.classvalue=soft}}, all details and
file:line references in the v2 assessment):
* Correctness: unit tests + a child-JVM probe covering resurrection
identity/version continuity, DGM rooting, EMC and per-instance-MetaClass
survival under forced clearing, true collection + fresh-instance dispatch,
classic CallSiteArray behaviour across clear + EMC change, and
predecessor-SwitchPoint retirement across recreation — all pass;
reflection/indy/callsite/groovy.lang suites (1200+ tests) pass unchanged in
default mode, and the full core suite (16,800 tests) passes when run entirely
under soft mode. A real-GC concurrency stress probe (small heap,
SoftRefLRUPolicyMSPerMB=0, concurrent indy + classic dispatch racing the
collector against 129 EMC generations) sustained 66M dispatches with 33/36
platform-receiver ClassInfos genuinely collected and recreated mid-run and zero
invariant violations. A side-finding worth noting: generated Groovy classes
self-pin their ClassInfo while the class lives ({{$staticMetaClass}} + the POGO
metaClass field), so the collectible population under soft mode is
platform-class ClassInfos and dead-Class Groovy islands — which further shrinks
the recreate-risk surface.
* Acceptance (the test the ticket exists for): a dropped child-loader Groovy
copy is *PINNED forever under today's default ClassValue*, and *collected under
soft mode* once memory pressure clears soft references (map mode likewise;
spike kept on the branch).
* Cost: +≈0.6 ns on a raw getClassInfo lookup (1.2 → 1.7 ns; the map escape
hatch is ≈4.7 ns); dynamic dispatch macro loops and the compiler harness
(+0.0%) at parity. The JMH classic-bytecode gate (37 benchmarks, same
disjoint-CI rule that declined the map default with 12 significant
regressions): 0 of 37 significant, geomean +1.7%. Focused re-measurement of the
one directional outlier puts classic _polymorphic_ dispatch at a pooled +3.7% —
consistent with one SoftReference.get per classic inline-cache miss, irrelevant
to indy bytecode, and flagged for the idiom-suite sweep before any default-flip
discussion.
So v1's B/D verdicts stand, but its blanket recompute verdict is withdrawn:
soft-with-resurrection is the first option that demonstrably unpins the loader
while keeping the ClassValue fast path. The consolidated option landscape
(three modes × static/dynamic × indy/classic, with guidance) is assessment §9;
remaining questions for review are §10 (JDK ClassValue entry internals verified
for 17/21/23; soft values linger until pressure by design; adoption path —
opt-in first vs default).
https://github.com/apache/groovy/blob/groovy12281/GROOVY-12281-assessment.html
> Investigate mitigating class-loader pinning by ClassInfo.globalClassValue
> under the default ClassValue implementation
> ---------------------------------------------------------------------------------------------------------------------
>
> Key: GROOVY-12281
> URL: https://issues.apache.org/jira/browse/GROOVY-12281
> Project: Groovy
> Issue Type: Improvement
> Reporter: Paul King
> Priority: Major
> Attachments: GROOVY-12281-Assessment.pdf
>
>
> Follow-up from review discussion on [PR
> #2798|https://github.com/apache/groovy/pull/2798] (GROOVY-12142), preserving
> the analysis from that review. A {{ClassValue}} realizes the chain _key class
> → association → value → everything reachable from it_, and the association
> lives as long as the key class
> ([JDK-8136353|https://bugs.openjdk.org/browse/JDK-8136353], working as
> intended). The {{ClassValue}} implementation class itself prevents no
> unloading; what matters is the key's origin: a Groovy-loaded key class dies
> with Groovy's loader (fine), but a JDK/platform key class is effectively
> immortal, so any value reachable from it that was loaded by Groovy's loader
> pins that loader forever. Indirection counts — a JDK-typed value (e.g. an
> {{ArrayList}}) whose _elements_ are Groovy-loaded re-creates the pin one
> level down.
> {{ClassInfo.globalClassValue}} is static with unknown keys, including
> platform classes ({{String}} receives a {{ClassInfo}} in essentially every
> Groovy program), so the default {{ClassValue}} path pins the loader; today's
> only remedy is the global {{groovy.use.classvalue=false}} escape hatch, which
> trades away the per-class fast path for all keys.
> The general mitigation — {{SoftReference}}-wrapped values with a
> check-remove-recompute protocol — requires that recomputation be legal, and
> for {{ClassInfo}} it is not in general: a {{ClassInfo}} can carry
> non-recomputable state (modified metaclasses, category state), so a
> softly-collected value could silently discard user metaclass customizations.
> Approaches to investigate:
> * soft values while a {{ClassInfo}} is pristine, hardening the reference on
> first mutation — only classes with customized metaclasses would then pin, a
> far smaller set;
> * per-key policy: platform-loader keys get soft/map treatment, Groovy-loader
> keys stay strong (the key-origin rule applied mechanically);
> * splitting {{ClassInfo}} into recomputable and stateful parts;
> * revisiting whether the map-based implementation should become the default,
> with {{ClassValue}} as the opt-in fast path.
> Recompute cost and dispatch-path performance need measurement for any
> candidate.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)