Skip to content

Commit 3010843

Browse files
committed
GROOVY-12259: Reap orphaned SwitchPoint registry entries when their domain is collected
The live-SwitchPoint registry held its owning invalidator strongly, so a domain discarded without ever being invalidated (a dead script class's MetaClass/ClassInfo in a long-lived, high-churn process that never uses categories) left its entry behind forever: no per-domain event fires for a dead class and no bulk drain runs without category/custom-MetaClass activity. Registry values are now weak references to the owner, delivered to a ReferenceQueue and reaped opportunistically from getSwitchPoint() and drainLive() — the operations churn-heavy processes keep performing — while bulk drains also claim not-yet-reaped orphans directly. Orphans are always invalidated, never silently dropped: installed guard chains embed only the SwitchPoint's internal invoker, which does not keep the SwitchPoint object reachable, so the strong registry key is what keeps a possibly-still-installed orphan guard retirable, and invalidating it just forces straggler sites to re-link. This preserves the invariant that an empty registry proves there was nothing to retire, and also closes a latent staleness window where sites guarded on a collected MetaClass's domain could no longer be reached by class-level invalidation. Detach paths clear the owner reference so explicitly handled entries never surface on the queue. Stable processes pay one weak reference per domain and an empty queue poll on the link path.
1 parent f549626 commit 3010843

2 files changed

Lines changed: 159 additions & 14 deletions

File tree

src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java

Lines changed: 118 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
package org.apache.groovy.runtime.indy;
2020

2121
import java.lang.invoke.SwitchPoint;
22+
import java.lang.ref.Reference;
23+
import java.lang.ref.ReferenceQueue;
24+
import java.lang.ref.WeakReference;
2225
import java.util.List;
2326
import java.util.concurrent.ConcurrentHashMap;
2427
import java.util.concurrent.atomic.AtomicInteger;
@@ -47,6 +50,17 @@
4750
* guard (GROOVY-12258, GROOVY-12259). Registration precedes publication, so
4851
* an empty (or entry-less) observation proves no guard chain holds a live
4952
* SwitchPoint that the observer could have needed to retire.
53+
* <p>
54+
* Registry entries hold their owning invalidator only weakly. When an owner
55+
* is collected without ever detaching (a discarded MetaClass or ClassInfo
56+
* domain in a long-lived, high-script-churn process that never bulk
57+
* invalidates), the orphaned SwitchPoint is <em>invalidated</em> — never
58+
* silently forgotten — either by the reaper pumped from allocation and drain,
59+
* or by the next bulk drain claiming it directly. Installed guards embed only
60+
* the SwitchPoint's internal invoker, which does not keep the SwitchPoint
61+
* object reachable, so the strong registry key is what keeps a
62+
* possibly-still-installed orphan guard retirable; invalidating it merely
63+
* forces straggler sites to re-link.
5064
*
5165
* @since 6.0.0
5266
*/
@@ -61,20 +75,49 @@ public final class SwitchPointInvalidator {
6175
private static final SwitchPoint[] SINGLE_INVALIDATE_BUF = new SwitchPoint[1];
6276

6377
/**
64-
* Registry of all live SwitchPoints, each mapped to its owning invalidator.
65-
* Keyed by SwitchPoint — each has a single-use lifecycle (allocated once,
66-
* detached once), so a removal can never clobber a successor's entry the
67-
* way an invalidator-keyed registry could (ABA on re-allocation).
78+
* Registry of all live SwitchPoints, each mapped to a weak reference to
79+
* its owning invalidator. Keyed by SwitchPoint — each has a single-use
80+
* lifecycle (allocated once, detached once), so a removal can never
81+
* clobber a successor's entry the way an invalidator-keyed registry could
82+
* (ABA on re-allocation). Keys are strong on purpose: a guard chain keeps
83+
* only the SwitchPoint's internal invoker alive, so this entry is what
84+
* keeps an orphaned-but-installed guard retirable. Values are weak so a
85+
* dead domain's invalidator is collectable; {@link #reapOrphans()} then
86+
* invalidates (never just drops) the orphaned SwitchPoint.
6887
* <p>
6988
* Invariant: any SwitchPoint published in {@link #current} has an entry
7089
* here, established by registering <em>before</em> the publishing CAS.
7190
* Entries are removed only by the party that detaches the SwitchPoint
72-
* (or by the allocator when its publishing CAS loses), so an entry whose
73-
* SwitchPoint is not (yet) current is transient and self-resolving.
91+
* (or by the allocator when its publishing CAS loses, or by the party
92+
* that invalidates an orphan), so an entry whose SwitchPoint is not
93+
* (yet) current is transient and self-resolving.
7494
*/
75-
private static final ConcurrentHashMap<SwitchPoint, SwitchPointInvalidator> LIVE =
95+
private static final ConcurrentHashMap<SwitchPoint, OwnerRef> LIVE =
7696
new ConcurrentHashMap<>();
7797

98+
/**
99+
* Delivery queue for {@link OwnerRef}s whose invalidator was collected
100+
* while its SwitchPoint was still registered. Drained opportunistically
101+
* by {@link #reapOrphans()}; empty (and cost-free) unless domains die
102+
* undetached.
103+
*/
104+
private static final ReferenceQueue<SwitchPointInvalidator> ORPHANS = new ReferenceQueue<>();
105+
106+
/**
107+
* Weak reference from a registered SwitchPoint to its owning invalidator.
108+
* Detach paths {@link Reference#clear() clear} the reference, which keeps
109+
* an explicitly handled entry from ever being enqueued; only owners that
110+
* die undetached reach {@link #ORPHANS}.
111+
*/
112+
private static final class OwnerRef extends WeakReference<SwitchPointInvalidator> {
113+
final SwitchPoint sp;
114+
115+
OwnerRef(final SwitchPoint sp, final SwitchPointInvalidator owner) {
116+
super(owner, ORPHANS);
117+
this.sp = sp;
118+
}
119+
}
120+
78121
/** {@code null} means no live switch point (lazy allocation on next get). */
79122
private final AtomicReference<SwitchPoint> current = new AtomicReference<>();
80123
private final AtomicInteger retirementCount = new AtomicInteger();
@@ -94,20 +137,25 @@ public SwitchPointInvalidator() {
94137
* @return a live switch point (never {@code null})
95138
*/
96139
public SwitchPoint getSwitchPoint() {
140+
// Allocation is the operation churn-heavy processes keep performing,
141+
// so it doubles as the reaper pump; a no-op while the queue is empty.
142+
reapOrphans();
97143
for (;;) {
98144
SwitchPoint sp = current.get();
99145
if (sp != null) {
100146
return sp;
101147
}
102148
SwitchPoint created = new SwitchPoint();
149+
OwnerRef ref = new OwnerRef(created, this);
103150
// Register before publish: a bulk path that finds no entry is then
104151
// guaranteed the SwitchPoint was not visible to any guard, so
105152
// skipping it is safe.
106-
LIVE.put(created, this);
153+
LIVE.put(created, ref);
107154
if (current.compareAndSet(null, created)) {
108155
return created;
109156
}
110-
LIVE.remove(created); // lost the publishing race; never live
157+
ref.clear(); // lost the publishing race; never live
158+
LIVE.remove(created);
111159
}
112160
}
113161

@@ -130,7 +178,7 @@ public void invalidate() {
130178
public SwitchPoint detachLive() {
131179
SwitchPoint sp = current.getAndSet(null);
132180
if (sp != null) {
133-
LIVE.remove(sp);
181+
deregister(sp);
134182
retirementCount.incrementAndGet();
135183
}
136184
return sp;
@@ -148,13 +196,24 @@ public SwitchPoint detachLive() {
148196
*/
149197
boolean detachIfCurrent(final SwitchPoint sp) {
150198
if (current.compareAndSet(sp, null)) {
151-
LIVE.remove(sp);
199+
deregister(sp);
152200
retirementCount.incrementAndGet();
153201
return true;
154202
}
155203
return false;
156204
}
157205

206+
/**
207+
* Removes {@code sp}'s registry entry and clears its owner reference so an
208+
* explicitly detached SwitchPoint can never surface on {@link #ORPHANS}.
209+
*/
210+
private static void deregister(final SwitchPoint sp) {
211+
OwnerRef ref = LIVE.remove(sp);
212+
if (ref != null) {
213+
ref.clear();
214+
}
215+
}
216+
158217
/**
159218
* Returns how many live switch points have been retired.
160219
*
@@ -186,21 +245,50 @@ static boolean hasLiveSwitchPoints() {
186245
* Entries whose SwitchPoint is no longer (or not yet) current are left in
187246
* place: they are either about to be published (removing them would strand
188247
* a live SwitchPoint unregistered, invisible to all future drains) or are
189-
* being removed by their detaching owner. The weakly consistent iteration
248+
* being removed by their detaching owner. Entries whose owner has been
249+
* collected are claimed here directly (the reaper may not have run yet)
250+
* and retired with the rest of the batch. The weakly consistent iteration
190251
* may miss a SwitchPoint published mid-drain — same window as the previous
191252
* all-classes walk; sites linking concurrently read the current category
192253
* state at link time.
193254
*
194255
* @param out destination list (must not be {@code null})
195256
*/
196257
static void drainLive(final List<SwitchPoint> out) {
197-
LIVE.forEach((sp, inv) -> {
198-
if (inv.detachIfCurrent(sp)) {
258+
reapOrphans();
259+
LIVE.forEach((sp, ref) -> {
260+
SwitchPointInvalidator inv = ref.get();
261+
if (inv == null) {
262+
// Owner died undetached; exactly one claimant retires the orphan.
263+
if (LIVE.remove(sp, ref)) {
264+
out.add(sp);
265+
}
266+
} else if (inv.detachIfCurrent(sp)) {
199267
out.add(sp);
200268
}
201269
});
202270
}
203271

272+
/**
273+
* Retires SwitchPoints whose owning invalidator was collected while they
274+
* were still registered (e.g. discarded script-class domains in processes
275+
* that never bulk invalidate). Orphans are removed from the registry and
276+
* <em>invalidated</em>: an installed guard chain does not keep the
277+
* SwitchPoint object reachable, so a straggler site may still be guarding
278+
* on it — invalidation forces such sites to re-link, which is always safe.
279+
* No-op (a queue poll) unless owners have died undetached.
280+
*/
281+
private static void reapOrphans() {
282+
for (Reference<? extends SwitchPointInvalidator> r; (r = ORPHANS.poll()) != null; ) {
283+
OwnerRef ref = (OwnerRef) r;
284+
// Two-arg remove: lose gracefully to a concurrent drain claiming
285+
// the same orphan; exactly one party invalidates.
286+
if (LIVE.remove(ref.sp, ref)) {
287+
invalidateIfLive(ref.sp);
288+
}
289+
}
290+
}
291+
204292
/**
205293
* Number of currently registered live SwitchPoints (tests).
206294
*
@@ -220,6 +308,22 @@ static boolean isRegistered(final SwitchPoint sp) {
220308
return LIVE.containsKey(sp);
221309
}
222310

311+
/**
312+
* Clears {@code sp}'s owner reference while leaving its entry registered,
313+
* simulating an owner collected before queue delivery (tests).
314+
*
315+
* @param sp registered SwitchPoint
316+
* @return {@code true} if an entry was found and its reference cleared
317+
*/
318+
static boolean clearOwnerRefForTesting(final SwitchPoint sp) {
319+
OwnerRef ref = LIVE.get(sp);
320+
if (ref != null) {
321+
ref.clear();
322+
return true;
323+
}
324+
return false;
325+
}
326+
223327
/**
224328
* Invalidates {@code sp} when non-null and still valid.
225329
*

src/test/groovy/org/apache/groovy/runtime/indy/SwitchPointInvalidatorTest.groovy

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,47 @@ final class SwitchPointInvalidatorTest {
289289
}
290290
}
291291

292+
@Test
293+
void drain_claimsOrphanWhoseOwnerWasCollected() {
294+
def inv = new SwitchPointInvalidator()
295+
SwitchPoint orphan = inv.switchPoint
296+
// simulate the owner dying undetached, before queue delivery
297+
assertTrue(SwitchPointInvalidator.clearOwnerRefForTesting(orphan))
298+
def batch = []
299+
SwitchPointInvalidator.drainLive(batch)
300+
assertTrue(batch.contains(orphan), 'drain must claim an orphaned SwitchPoint')
301+
assertFalse(SwitchPointInvalidator.isRegistered(orphan))
302+
SwitchPoint.invalidateAll(batch as SwitchPoint[])
303+
def batch2 = []
304+
SwitchPointInvalidator.drainLive(batch2)
305+
assertFalse(batch2.contains(orphan), 'a claimed orphan must not be re-drained')
306+
if (batch2) SwitchPoint.invalidateAll(batch2 as SwitchPoint[])
307+
inv.detachLive() // test-only owner is actually alive; clear its stale current
308+
}
309+
310+
@Test
311+
void reaper_invalidatesOrphanedSwitchPointAfterOwnerGc() {
312+
SwitchPoint orphan = createOrphan()
313+
assertTrue(SwitchPointInvalidator.isRegistered(orphan))
314+
def pump = new SwitchPointInvalidator()
315+
boolean reaped = false
316+
for (int i = 0; i < 200 && !reaped; i++) {
317+
System.gc()
318+
pump.invalidate() // detach so the next get re-enters allocation
319+
pump.switchPoint // allocation pumps the reaper
320+
reaped = !SwitchPointInvalidator.isRegistered(orphan)
321+
if (!reaped) Thread.sleep(10)
322+
}
323+
pump.invalidate()
324+
assertTrue(reaped, 'orphaned SwitchPoint was never reaped from the registry')
325+
assertTrue(orphan.hasBeenInvalidated(), 'reaper must invalidate, not merely forget')
326+
}
327+
328+
/** The invalidator allocated here is unreachable once this returns. */
329+
private static SwitchPoint createOrphan() {
330+
new SwitchPointInvalidator().switchPoint
331+
}
332+
292333
@Test
293334
void concurrentGet_retryOnCasLoss() {
294335
// Hammer getSwitchPoint from many threads so some lose the CAS and retry.

0 commit comments

Comments
 (0)