Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,21 @@ public MethodHandle getFallbackTarget() {
public void setFallbackTarget(MethodHandle fallbackTarget) {
this.fallbackTarget = fallbackTarget;
}

/**
* Clear the cache entirely. Called when metaclass changes to ensure
* stale method handles are discarded.
*/
public void clearCache() {
// Clear the latest hit reference
latestHitMethodHandleWrapperSoftReference = null;

// Clear the LRU cache
synchronized (lruCache) {
Comment thread
jamesfredley marked this conversation as resolved.
Outdated
lruCache.clear();
}

// Reset fallback count
fallbackCount.set(0);
}
}
41 changes: 39 additions & 2 deletions src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
import java.lang.invoke.MethodType;
import java.lang.invoke.MutableCallSite;
import java.lang.invoke.SwitchPoint;
import java.lang.ref.WeakReference;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.logging.Level;
Expand Down Expand Up @@ -168,24 +171,55 @@ public static CallType fromCallSiteName(String callSiteName) {
}

protected static SwitchPoint switchPoint = new SwitchPoint();

/**
* Weak set of all CacheableCallSites. Used to invalidate caches when metaclass changes.
* Uses WeakReferences so call sites can be garbage collected when no longer referenced.
*/
private static final Set<WeakReference<CacheableCallSite>> ALL_CALL_SITES = ConcurrentHashMap.newKeySet();
Comment thread
jamesfredley marked this conversation as resolved.
Outdated

static {
GroovySystem.getMetaClassRegistry().addMetaClassRegistryChangeEventListener(cmcu -> invalidateSwitchPoints());
}

/**
* Register a call site for cache invalidation when metaclass changes.
*/
static void registerCallSite(CacheableCallSite callSite) {
ALL_CALL_SITES.add(new WeakReference<>(callSite));
Comment thread
jamesfredley marked this conversation as resolved.
}

/**
* Callback for constant metaclass update change
* Callback for constant metaclass update change.
* Invalidates all call site caches to ensure metaclass changes are visible.
*/
protected static void invalidateSwitchPoints() {
if (LOG_ENABLED) {
LOG.info("invalidating switch point");
LOG.info("invalidating switch point and call site caches");
}

synchronized (IndyInterface.class) {
SwitchPoint old = switchPoint;
switchPoint = new SwitchPoint();
SwitchPoint.invalidateAll(new SwitchPoint[]{old});
}

// Invalidate all call site caches and reset targets to default (cache lookup)
// This ensures metaclass changes are visible without using expensive switchpoint guards
ALL_CALL_SITES.removeIf(ref -> {
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 size of ALL_CALL_SITES could be very big, so it's better to use parallel stream for better performance.

Copy link
Copy Markdown
Contributor

@daniellansun daniellansun Feb 1, 2026

Choose a reason for hiding this comment

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

When metaclass changes, all registered call sites have their caches cleared

Current implementation will clear all cache no matter whether the changed metaclasses are releated.
Groovy 5 has lookup instance in the CacheableCallSite, it can help us determine which class is using the cache site, maybe we can backport it to Groovy 4:

private final MethodHandles.Lookup lookup;

Copy link
Copy Markdown
Contributor Author

@jamesfredley jamesfredley Feb 1, 2026

Choose a reason for hiding this comment

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

I tested two versions of the parallel stream change with the two test application and saw a reduction in performance.

Metric Baseline Approach 1 Approach 2
Grails App (steady state) ~141 ms ~167 ms (18% SLOWER) ~158 ms (12% SLOWER)

Approach 1: Parallel Collect + Atomic Swap - 18% SLOWER

// Invalidate all call site caches and reset targets to default (cache lookup)
// This ensures metaclass changes are visible without using expensive switchpoint guards
Set<WeakReference<CacheableCallSite>> liveReferences = ALL_CALL_SITES.parallelStream()
    .filter(ref -> {
        CacheableCallSite cs = ref.get();
        if (cs == null) {
            return false; // Don't keep garbage collected references
        }
        // Reset target to default (fromCache) so next call goes through cache lookup
        MethodHandle defaultTarget = cs.getDefaultTarget();
        if (defaultTarget != null && cs.getTarget() != defaultTarget) {
            cs.setTarget(defaultTarget);
        }
        // Clear the cache so stale method handles are discarded
        cs.clearCache();
        return true; // Keep live references
    })
    .collect(Collectors.toSet());
// Atomic swap - clear and replace with live references only
ALL_CALL_SITES.clear();
ALL_CALL_SITES.addAll(liveReferences);

Approach 2: Parallel ForEach + Sequential RemoveIf - 12% SLOWER
// Invalidate all call site caches in parallel

ALL_CALL_SITES.parallelStream().forEach(ref -> {
    CacheableCallSite cs = ref.get();
    if (cs != null) {
        // Reset target to default (fromCache) so next call goes through cache lookup
        MethodHandle defaultTarget = cs.getDefaultTarget();
        if (defaultTarget != null && cs.getTarget() != defaultTarget) {
            cs.setTarget(defaultTarget);
        }
        // Clear the cache so stale method handles are discarded
        cs.clearCache();
    }
});
// Remove garbage collected references (must be sequential for ConcurrentHashMap.KeySetView)
ALL_CALL_SITES.removeIf(ref -> ref.get() == null);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Testing with the Groovy 5 caller-specific Lookup pattern is generally positive on the micro benchmarks but 11% slower in the grails test application:

More details: jamesfredley#1

CacheableCallSite cs = ref.get();
if (cs == null) {
return true; // Remove garbage collected references
}
// Reset target to default (fromCache) so next call goes through cache lookup
MethodHandle defaultTarget = cs.getDefaultTarget();
if (defaultTarget != null && cs.getTarget() != defaultTarget) {
cs.setTarget(defaultTarget);
}
// Clear the cache so stale method handles are discarded
cs.clearCache();
Comment thread
jamesfredley marked this conversation as resolved.
return false;
});
}

/**
Expand Down Expand Up @@ -230,6 +264,9 @@ private static CallSite realBootstrap(Lookup caller, String name, int callID, Me
mc.setTarget(mh);
mc.setDefaultTarget(mh);
mc.setFallbackTarget(makeFallBack(mc, sender, name, callID, type, safe, thisCall, spreadCall));

// Register for cache invalidation on metaclass changes
registerCallSite(mc);

return mc;
}
Expand Down
16 changes: 13 additions & 3 deletions src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import groovy.lang.MissingMethodException;
import groovy.transform.Internal;
import org.apache.groovy.runtime.ObjectUtil;
import org.apache.groovy.util.SystemUtil;
import org.codehaus.groovy.GroovyBugError;
import org.codehaus.groovy.reflection.CachedField;
import org.codehaus.groovy.reflection.CachedMethod;
Expand Down Expand Up @@ -940,9 +941,18 @@ public void setGuards(Object receiver) {
}
}

// handle constant metaclass and category changes
handle = switchPoint.guardWithTest(handle, fallback);
if (LOG_ENABLED) LOG.info("added switch point guard");
// Skip the global switchpoint guard by default.
// The switchpoint causes ALL call sites to fail when ANY metaclass changes.
// In Grails and similar frameworks with frequent metaclass changes, this causes
// massive guard failures and performance degradation.
// The other guards (metaclass identity, class receiver, category) should be
// sufficient, combined with cache invalidation on metaclass changes.
//
// If you need strict metaclass change detection, set groovy.indy.switchpoint.guard=true
Comment thread
jamesfredley marked this conversation as resolved.
Outdated
if (SystemUtil.getBooleanSafe("groovy.indy.switchpoint.guard")) {
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 one question I have about this is whether this check ought to be inverted. That is, should the default behaviour be the old way, which we know is less performant but more battle-tested, or this way? Could Grails simply turn this flag on by default, thus solving their problem, without impacting other users?

Also, as a nitpicky thing, I reckon this string ought to be a public static final field so that we could add some tests around it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If this PRs approach is viable, but does not prove to be generally valuable for all/most code run with Indy on, than I think the inverse would be fine for Grails and we could set that flag in default generated applications.

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 is part of why I'd like to run this branch against the current benchmarks on CI. That would likely provide some confidence as to whether this was "safe" to merge without impacting anyone's expectations about how the language behaves.

Comment thread
jamesfredley marked this conversation as resolved.
Outdated
handle = switchPoint.guardWithTest(handle, fallback);
if (LOG_ENABLED) LOG.info("added switch point guard");
}

java.util.function.Predicate<Class<?>> nonFinalOrNullUnsafe = (t) -> {
return !Modifier.isFinal(t.getModifiers())
Expand Down
Loading