You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/04/24 20:28:54 UTC

(groovy) branch master updated: GROOVY-11935: Set invokedynamic call site target immediately to enable earlier JIT inlining (#2473)

This is an automated email from the ASF dual-hosted git repository.

daniellansun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new 815e7566be GROOVY-11935: Set invokedynamic call site target immediately to enable earlier JIT inlining (#2473)
815e7566be is described below

commit 815e7566be4658a44956db4e303c61cf77421bf0
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Apr 25 04:28:48 2026 +0800

    GROOVY-11935: Set invokedynamic call site target immediately to enable earlier JIT inlining (#2473)
---
 .../groovy/vmplugin/v8/CacheableCallSite.java      |   3 +-
 .../codehaus/groovy/vmplugin/v8/IndyInterface.java |  79 ++--
 .../groovy/vmplugin/v8/MethodHandleWrapper.java    |  16 +-
 .../v8/IndyInterfaceCallSiteTargetTest.groovy      | 499 +++++++++++++++++++++
 .../groovy/bench/StaticMethodCallIndy.groovy       | 124 +++++
 .../groovy/bench/StaticMethodCallIndyBench.java    | 180 ++++++++
 6 files changed, 864 insertions(+), 37 deletions(-)

diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java
index 73527d5227..53ac15ae63 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java
@@ -22,6 +22,7 @@ import org.apache.groovy.util.SystemUtil;
 import org.codehaus.groovy.runtime.DefaultGroovyMethods;
 import org.codehaus.groovy.runtime.memoize.MemoizeCache;
 
+import java.io.Serial;
 import java.lang.invoke.MethodHandle;
 import java.lang.invoke.MethodHandles;
 import java.lang.invoke.MethodType;
@@ -52,7 +53,7 @@ public class CacheableCallSite extends MutableCallSite {
     private MethodHandle fallbackTarget;
     private final Map<String, SoftReference<MethodHandleWrapper>> lruCache =
             new LinkedHashMap<String, SoftReference<MethodHandleWrapper>>(INITIAL_CAPACITY, LOAD_FACTOR, true) {
-                private static final long serialVersionUID = 7785958879964294463L;
+                @Serial private static final long serialVersionUID = 7785958879964294463L;
 
                 @Override
                 protected boolean removeEldestEntry(Map.Entry eldest) {
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
index 91682cc722..77cef73e4a 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
@@ -21,7 +21,6 @@ package org.codehaus.groovy.vmplugin.v8;
 import groovy.lang.GroovySystem;
 import org.apache.groovy.util.SystemUtil;
 import org.codehaus.groovy.GroovyBugError;
-import org.codehaus.groovy.reflection.ClassInfo;
 import org.codehaus.groovy.runtime.GeneratedClosure;
 
 import java.lang.invoke.CallSite;
@@ -31,6 +30,7 @@ import java.lang.invoke.MethodHandles;
 import java.lang.invoke.MethodType;
 import java.lang.invoke.MutableCallSite;
 import java.lang.invoke.SwitchPoint;
+import java.lang.reflect.Modifier;
 import java.util.Map;
 import java.util.function.Function;
 import java.util.logging.Level;
@@ -320,48 +320,49 @@ public class IndyInterface {
      */
     private static MethodHandle fromCacheHandle(CacheableCallSite callSite, Class<?> sender, String methodName, int callID, Boolean safeNavigation, Boolean thisCall, Boolean spreadCall, Object dummyReceiver, Object[] arguments) throws Throwable {
         FallbackSupplier fallbackSupplier = new FallbackSupplier(callSite, sender, methodName, callID, safeNavigation, thisCall, spreadCall, dummyReceiver, arguments);
-
-        MethodHandleWrapper mhw;
-        if (bypassCache(spreadCall, arguments)) {
-            mhw = NULL_METHOD_HANDLE_WRAPPER;
-        } else {
-            Object receiver = arguments[0];
-            String receiverClassName = receiver != null ? receiver.getClass().getName() : NULL_OBJECT_CLASS_NAME;
-
-            mhw = callSite.getAndPut(receiverClassName, (theName) -> {
-                MethodHandleWrapper fallback = fallbackSupplier.get();
-                if (fallback.isCanSetTarget()) return fallback;
-                return NULL_METHOD_HANDLE_WRAPPER;
-            });
-        }
+        Object receiver = arguments[0];
+        String receiverClassName = receiverCacheKey(receiver);
+        MethodHandleWrapper mhw = callSite.getAndPut(receiverClassName, (theName) -> {
+            MethodHandleWrapper fallback = fallbackSupplier.get();
+            if (fallback.isCanSetTarget()) return fallback;
+            return NULL_METHOD_HANDLE_WRAPPER;
+        });
 
         if (mhw == NULL_METHOD_HANDLE_WRAPPER) {
+            // The PIC stores a sentinel to remember "do not relink this receiver shape";
+            // execution still needs a real handle for the current invocation.
             mhw = fallbackSupplier.get();
         }
 
-        if (mhw.isCanSetTarget() && (callSite.getTarget() != mhw.getTargetMethodHandle()) && (mhw.getLatestHitCount() > INDY_OPTIMIZE_THRESHOLD)) {
-            if (callSite.getFallbackRound().get() > INDY_FALLBACK_CUTOFF) {
-                if (callSite.getTarget() != callSite.getDefaultTarget()) {
-                    // reset the call site target to default forever to avoid JIT deoptimization storm further
-                    callSite.setTarget(callSite.getDefaultTarget());
+        if (mhw.isCanSetTarget() && (callSite.getTarget() != mhw.getTargetMethodHandle())) {
+            // GROOVY-11935: Set invokedynamic call site target immediately to enable earlier JIT inlining.
+            if (callSite.type().parameterType(0) == Class.class) {
+                var method = mhw.getMethod();
+                if (method != null && Modifier.isStatic(method.getModifiers())) {
+                    callSite.setTarget(mhw.getTargetMethodHandle());
                 }
-            } else {
-                callSite.setTarget(mhw.getTargetMethodHandle());
-                if (LOG_ENABLED) LOG.info("call site target set, preparing outside invocation");
             }
 
-            mhw.resetLatestHitCount();
+            if (mhw.getLatestHitCount() > INDY_OPTIMIZE_THRESHOLD) {
+                if (callSite.getFallbackRound().get() > INDY_FALLBACK_CUTOFF) {
+                    if (callSite.getTarget() != callSite.getDefaultTarget()) {
+                        // reset the call site target to default forever to avoid JIT deoptimization storm further
+                        callSite.setTarget(callSite.getDefaultTarget());
+                    }
+                } else {
+                    if (callSite.getTarget() != mhw.getTargetMethodHandle()) {
+                        callSite.setTarget(mhw.getTargetMethodHandle());
+                        if (LOG_ENABLED) LOG.info("call site target set, preparing outside invocation");
+                    }
+                }
+
+                mhw.resetLatestHitCount();
+            }
         }
 
         return mhw.getCachedMethodHandle();
     }
 
-    private static boolean bypassCache(Boolean spreadCall, Object[] arguments) {
-        if (spreadCall) return true;
-        Object receiver = arguments[0];
-        return receiver != null && ClassInfo.getClassInfo(receiver.getClass()).hasPerInstanceMetaClasses();
-    }
-
     /**
      * Core method for indy method selection using runtime types.
      * @deprecated Use the new bootHandle-based approach instead.
@@ -390,13 +391,26 @@ public class IndyInterface {
             // correct the stale methodHandle in the inline cache of callsite
             // it is important but impacts the performance somehow when cache misses frequently
             Object receiver = arguments[0];
-            String key = receiver != null ? receiver.getClass().getName() : NULL_OBJECT_CLASS_NAME;
-            callSite.put(key, mhw);
+
+            // Avoid PIC pollution: don't write back uncached wrappers, e.g. for instance-level metaClass dispatches.
+            callSite.put(receiverCacheKey(receiver), mhw.isCanSetTarget() ? mhw : NULL_METHOD_HANDLE_WRAPPER);
         }
 
         return mhw.getCachedMethodHandle();
     }
 
+    /**
+     * Computes the PIC cache key for the given receiver.
+     * Different {@code Class} objects (e.g. {@code A} vs {@code B}) share the same runtime class
+     * ({@code java.lang.Class}) but dispatch to different methods. Including the represented class
+     * name avoids PIC cache collisions for static-method call sites.
+     */
+    private static String receiverCacheKey(Object receiver) {
+        if (receiver == null) return NULL_OBJECT_CLASS_NAME;
+        if (receiver instanceof Class<?> c) return "java.lang.Class:" + c.getName();
+        return receiver.getClass().getName();
+    }
+
     private static MethodHandleWrapper fallback(CacheableCallSite callSite, Class<?> sender, String methodName, int callID, Boolean safeNavigation, Boolean thisCall, Boolean spreadCall, Object dummyReceiver, Object[] arguments) {
         Selector selector = Selector.getSelector(callSite, sender, methodName, callID, safeNavigation, thisCall, spreadCall, arguments);
         selector.setCallSiteTarget();
@@ -404,6 +418,7 @@ public class IndyInterface {
         return new MethodHandleWrapper(
                 selector.handle.asSpreader(Object[].class, arguments.length).asType(MethodType.methodType(Object.class, Object[].class)),
                 selector.handle,
+                selector.method,
                 selector.cache
         );
     }
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java
index b858464352..765c3d74ed 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java
@@ -18,6 +18,8 @@
  */
 package org.codehaus.groovy.vmplugin.v8;
 
+import groovy.lang.MetaMethod;
+
 import java.lang.invoke.MethodHandle;
 import java.util.concurrent.atomic.AtomicLong;
 
@@ -29,12 +31,14 @@ import java.util.concurrent.atomic.AtomicLong;
 class MethodHandleWrapper {
     private final MethodHandle cachedMethodHandle;
     private final MethodHandle targetMethodHandle;
+    private final MetaMethod method;
     private final boolean canSetTarget;
     private final AtomicLong latestHitCount = new AtomicLong(0);
 
-    public MethodHandleWrapper(MethodHandle cachedMethodHandle, MethodHandle targetMethodHandle, boolean canSetTarget) {
+    public MethodHandleWrapper(MethodHandle cachedMethodHandle, MethodHandle targetMethodHandle, MetaMethod method, boolean canSetTarget) {
         this.cachedMethodHandle = cachedMethodHandle;
         this.targetMethodHandle = targetMethodHandle;
+        this.method = method;
         this.canSetTarget = canSetTarget;
     }
 
@@ -46,6 +50,10 @@ class MethodHandleWrapper {
         return targetMethodHandle;
     }
 
+    public MetaMethod getMethod() {
+        return method;
+    }
+
     public boolean isCanSetTarget() {
         return canSetTarget;
     }
@@ -67,10 +75,10 @@ class MethodHandleWrapper {
     }
 
     private static class NullMethodHandleWrapper extends MethodHandleWrapper {
-        public static final NullMethodHandleWrapper INSTANCE = new NullMethodHandleWrapper(null, null, false);
+        public static final NullMethodHandleWrapper INSTANCE = new NullMethodHandleWrapper();
 
-        private NullMethodHandleWrapper(MethodHandle cachedMethodHandle, MethodHandle targetMethodHandle, boolean canSetTarget) {
-            super(cachedMethodHandle, targetMethodHandle, canSetTarget);
+        private NullMethodHandleWrapper() {
+            super(null, null, null, false);
         }
     }
 }
diff --git a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyInterfaceCallSiteTargetTest.groovy b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyInterfaceCallSiteTargetTest.groovy
new file mode 100644
index 0000000000..2a499a222a
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyInterfaceCallSiteTargetTest.groovy
@@ -0,0 +1,499 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.codehaus.groovy.vmplugin.v8
+
+import org.codehaus.groovy.reflection.CachedMethod
+import org.codehaus.groovy.runtime.metaclass.MetaClassRegistryImpl
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.MethodHandle
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.MethodType
+import java.lang.reflect.Field
+import java.lang.reflect.Method
+import java.lang.reflect.Modifier
+import java.util.concurrent.atomic.AtomicInteger
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertNotSame
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+
+final class IndyInterfaceCallSiteTargetTest {
+
+    private String foo() {
+        return 'foo-result'
+    }
+
+    private static String staticFoo() {
+        return 'static-foo-result'
+    }
+
+    private static String staticEcho(String value) {
+        return "static-echo-$value"
+    }
+
+    private static final class PerInstanceMetaClassStaticTarget {
+        private static String ping() {
+            return 'per-instance-static-result'
+        }
+    }
+
+    private static final class ClassA {
+        private static String bar() { return 'bar-from-A' }
+    }
+
+    private static final class ClassB {
+        private static String bar() { return 'bar-from-B' }
+    }
+
+    private static final class InstanceStaticCallTarget {
+        private static String valueOf(String value) { return "instance-static-$value" }
+    }
+
+    @Test
+    void testDeprecatedFromCacheRelinksTargetImmediatelyForStaticClassReceiver() {
+        MethodType type = MethodType.methodType(Object, Class)
+        CacheableCallSite callSite = newCallSite(type)
+        Object[] args = [IndyInterfaceCallSiteTargetTest] as Object[]
+
+        Object result = IndyInterface.fromCache(
+            callSite,
+            IndyInterfaceCallSiteTargetTest,
+            'staticFoo',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE,
+            Boolean.FALSE,
+            Boolean.FALSE,
+            1,
+            args
+        )
+
+        assertEquals(staticFoo(), result)
+        assertNotSame(callSite.defaultTarget, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandleKeepsDefaultTargetForSpreadCall() {
+        MethodType type = MethodType.methodType(Object, Class, Object[])
+        CacheableCallSite callSite = newCallSite(type)
+        Object[] args = [IndyInterfaceCallSiteTargetTest, ['bar'] as Object[]] as Object[]
+
+        MethodHandle methodHandle = invokeFromCacheHandle(
+            callSite,
+            IndyInterfaceCallSiteTargetTest,
+            'staticEcho',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE,
+            Boolean.FALSE,
+            Boolean.TRUE,
+            1,
+            args
+        )
+
+        assertEquals(staticEcho('bar'), methodHandle.invokeWithArguments([args] as Object[]))
+        assertSame(callSite.defaultTarget, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandlePromotesCachedInstanceTargetAfterOptimizeThreshold() {
+        MethodType type = MethodType.methodType(Object, IndyInterfaceCallSiteTargetTest)
+        CacheableCallSite callSite = newCallSite(type)
+        def receiver = new IndyInterfaceCallSiteTargetTest()
+        Object[] args = [receiver] as Object[]
+        MethodHandleWrapper wrapper = newCachedWrapper(
+            type, 'cached-instance-result', 'optimized-target-result',
+            CachedMethod.find(IndyInterfaceCallSiteTargetTest.getDeclaredMethod('foo')), true
+        )
+
+        cacheWrapper(callSite, receiver, wrapper)
+        primeLatestHitCount(callSite, receiver, wrapper, readIndyLong('INDY_OPTIMIZE_THRESHOLD'))
+
+        MethodHandle methodHandle = invokeFromCacheHandle(
+            callSite, IndyInterfaceCallSiteTargetTest, 'foo',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.TRUE, Boolean.FALSE, 1, args
+        )
+
+        assertSame(wrapper.cachedMethodHandle, methodHandle)
+        assertSame(wrapper.targetMethodHandle, callSite.target)
+        assertEquals(0L, wrapper.latestHitCount)
+    }
+
+    @Test
+    void testFromCacheHandleLeavesDefaultTargetAfterFallbackCutoff() {
+        assertFallbackCutoffLeavesDefaultTarget(true)
+        assertFallbackCutoffLeavesDefaultTarget(false)
+    }
+
+    @Test
+    void testFromCacheHandleSkipsTargetChangesWhenCachedWrapperCannotSetTarget() {
+        MethodType type = MethodType.methodType(Object, IndyInterfaceCallSiteTargetTest)
+        CacheableCallSite callSite = newCallSite(type)
+        def receiver = new IndyInterfaceCallSiteTargetTest()
+        Object[] args = [receiver] as Object[]
+        MethodHandleWrapper wrapper = newCachedWrapper(type, 'uncacheable-result', 'ignored-target', null, false)
+
+        cacheWrapper(callSite, receiver, wrapper)
+
+        MethodHandle methodHandle = invokeFromCacheHandle(
+            callSite, IndyInterfaceCallSiteTargetTest, 'foo',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.TRUE, Boolean.FALSE, 1, args
+        )
+
+        assertSame(wrapper.cachedMethodHandle, methodHandle)
+        assertSame(callSite.defaultTarget, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandleReturnsNullHandleForSafeNavigationReceiver() {
+        MethodType type = MethodType.methodType(Object, Object)
+        CacheableCallSite callSite = newCallSite(type)
+        Object[] args = [null] as Object[]
+
+        MethodHandle methodHandle = invokeFromCacheHandle(
+            callSite, IndyInterfaceCallSiteTargetTest, 'foo',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.TRUE, Boolean.FALSE, Boolean.FALSE, 1, args
+        )
+
+        assertEquals(null, methodHandle.invokeWithArguments([args] as Object[]))
+        assertSame(callSite.defaultTarget, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandleUsesFallbackForPerInstanceMetaClassStaticReceiver() {
+        MethodType type = MethodType.methodType(Object, Class)
+        CacheableCallSite callSite = newCallSite(type)
+        Object[] args = [PerInstanceMetaClassStaticTarget] as Object[]
+        MetaClassRegistryImpl registry = (MetaClassRegistryImpl) GroovySystem.metaClassRegistry
+        ExpandoMetaClass emc = new ExpandoMetaClass(PerInstanceMetaClassStaticTarget, false, true)
+        emc.initialize()
+
+        registry.setMetaClass((Object) PerInstanceMetaClassStaticTarget, emc)
+        try {
+            2.times {
+                MethodHandle methodHandle = invokeFromCacheHandle(
+                    callSite, PerInstanceMetaClassStaticTarget, 'ping',
+                    IndyInterface.CallType.METHOD.getOrderNumber(),
+                    Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
+                )
+
+                assertEquals(PerInstanceMetaClassStaticTarget.ping(), methodHandle.invokeWithArguments([args] as Object[]))
+            }
+            assertSame(callSite.defaultTarget, callSite.target)
+        } finally {
+            registry.setMetaClass((Object) PerInstanceMetaClassStaticTarget, (MetaClass) null)
+        }
+    }
+
+    @Test
+    void testFromCacheHandleLeavesAlreadyLinkedCachedTargetUntouched() {
+        MethodType type = MethodType.methodType(Object, IndyInterfaceCallSiteTargetTest)
+        CacheableCallSite callSite = newCallSite(type)
+        def receiver = new IndyInterfaceCallSiteTargetTest()
+        Object[] args = [receiver] as Object[]
+        MethodHandleWrapper wrapper = newCachedWrapper(
+            type, 'already-linked-result', 'linked-target-result',
+            CachedMethod.find(IndyInterfaceCallSiteTargetTest.getDeclaredMethod('foo')), true
+        )
+
+        cacheWrapper(callSite, receiver, wrapper)
+        callSite.target = wrapper.targetMethodHandle
+
+        MethodHandle methodHandle = invokeFromCacheHandle(
+            callSite, IndyInterfaceCallSiteTargetTest, 'foo',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.TRUE, Boolean.FALSE, 1, args
+        )
+
+        assertSame(wrapper.cachedMethodHandle, methodHandle)
+        assertSame(wrapper.targetMethodHandle, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandleDoesNotRelinkWhenCallSiteParamIsObjectEvenIfReceiverIsClass() {
+        MethodType type = MethodType.methodType(Object, Object)
+        CacheableCallSite callSite = newCallSite(type)
+        Object[] args = [ClassA] as Object[]
+        MethodHandleWrapper wrapper = newCachedWrapper(
+            type, 'class-a-result', 'class-a-target',
+            CachedMethod.find(ClassA.getDeclaredMethod('bar')), true
+        )
+
+        cacheWrapper(callSite, ClassA, wrapper)
+
+        MethodHandle methodHandle = invokeFromCacheHandle(
+            callSite, ClassA, 'bar',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
+        )
+
+        assertSame(wrapper.cachedMethodHandle, methodHandle)
+        assertSame(callSite.defaultTarget, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandleDistinguishesDifferentClassReceivers() {
+        MethodType type = MethodType.methodType(Object, Class)
+        CacheableCallSite callSite = newCallSite(type)
+
+        Object[] argsA = [ClassA] as Object[]
+        MethodHandle handleA = invokeFromCacheHandle(
+            callSite, ClassA, 'bar',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, argsA
+        )
+        assertEquals(ClassA.bar(), handleA.invokeWithArguments([argsA] as Object[]))
+
+        Object[] argsB = [ClassB] as Object[]
+        MethodHandle handleB = invokeFromCacheHandle(
+            callSite, ClassB, 'bar',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, argsB
+        )
+        assertEquals(ClassB.bar(), handleB.invokeWithArguments([argsB] as Object[]))
+    }
+
+    @Test
+    void testFromCacheHandleRelinksImmediatelyForClassTypedCallSiteWithStaticMethod() {
+        MethodType type = MethodType.methodType(Object, Class)
+        CacheableCallSite callSite = newCallSite(type)
+        MethodHandleWrapper wrapper = newCachedWrapper(
+            type, 'cached-static-result', 'static-target-result',
+            CachedMethod.find(ClassA.getDeclaredMethod('bar')), true
+        )
+
+        cacheWrapper(callSite, ClassA, wrapper)
+
+        Object[] args = [ClassA] as Object[]
+        MethodHandle methodHandle = invokeFromCacheHandle(
+            callSite, ClassA, 'bar',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
+        )
+
+        assertSame(wrapper.cachedMethodHandle, methodHandle)
+        assertSame(wrapper.targetMethodHandle, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandleKeepsDefaultTargetForClassTypedCallSiteWhenMethodMetadataIsMissing() {
+        MethodType type = MethodType.methodType(Object, Class)
+        CacheableCallSite callSite = newCallSite(type)
+        MethodHandleWrapper wrapper = newCachedWrapper(type, 'cached-class-result', 'ignored-target', null, true)
+
+        cacheWrapper(callSite, ClassA, wrapper)
+
+        Object[] args = [ClassA] as Object[]
+        MethodHandle methodHandle = invokeFromCacheHandle(
+            callSite, ClassA, 'bar',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
+        )
+
+        assertSame(wrapper.cachedMethodHandle, methodHandle)
+        assertSame(callSite.defaultTarget, callSite.target)
+    }
+
+    @Test
+    void testFromCacheHandleDoesNotRelinkStaticMethodInvokedThroughInstanceReceiver() {
+        MethodType type = MethodType.methodType(Object, InstanceStaticCallTarget, String)
+        CacheableCallSite callSite = newCallSite(type)
+        def receiver = new InstanceStaticCallTarget()
+        Object[] args = [receiver, 'abc'] as Object[]
+
+        MethodHandle selectedHandle = invokeSelectMethodHandle(
+            callSite, InstanceStaticCallTarget, 'valueOf',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
+        )
+
+        assertEquals(InstanceStaticCallTarget.valueOf('abc'), selectedHandle.invokeWithArguments([args] as Object[]))
+        MethodHandleWrapper cachedWrapper = requireCachedWrapper(callSite, receiver)
+        assertTrue(Modifier.isStatic(cachedWrapper.method.modifiers))
+        assertSame(callSite.defaultTarget, callSite.target)
+
+        MethodHandle cachedHandle = invokeFromCacheHandle(
+            callSite, InstanceStaticCallTarget, 'valueOf',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, args
+        )
+
+        assertSame(cachedWrapper.cachedMethodHandle, cachedHandle)
+        assertEquals(InstanceStaticCallTarget.valueOf('abc'), cachedHandle.invokeWithArguments([args] as Object[]))
+        assertSame(callSite.defaultTarget, callSite.target)
+    }
+
+    @Test
+    void testSelectMethodHandleCachesDistinctEntriesForDifferentClassReceivers() {
+        MethodType type = MethodType.methodType(Object, Class)
+        CacheableCallSite callSite = newCallSite(type)
+
+        Object[] argsA = [ClassA] as Object[]
+        MethodHandle handleA = invokeSelectMethodHandle(
+            callSite, ClassA, 'bar',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, argsA
+        )
+        assertEquals(ClassA.bar(), handleA.invokeWithArguments([argsA] as Object[]))
+
+        Object[] argsB = [ClassB] as Object[]
+        MethodHandle handleB = invokeSelectMethodHandle(
+            callSite, ClassB, 'bar',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, 1, argsB
+        )
+        assertEquals(ClassB.bar(), handleB.invokeWithArguments([argsB] as Object[]))
+
+        MethodHandleWrapper cachedA = requireCachedWrapper(callSite, ClassA)
+        MethodHandleWrapper cachedB = requireCachedWrapper(callSite, ClassB)
+        assertNotSame(cachedA, cachedB)
+        assertEquals(ClassA.bar(), cachedA.cachedMethodHandle.invokeWithArguments([argsA] as Object[]))
+        assertEquals(ClassB.bar(), cachedB.cachedMethodHandle.invokeWithArguments([argsB] as Object[]))
+        assertSame(callSite.defaultTarget, callSite.target)
+    }
+
+    @Test
+    void testSelectMethodHandleStoresSentinelForUncacheableSpreadCall() {
+        MethodType type = MethodType.methodType(Object, Class, Object[])
+        CacheableCallSite callSite = newCallSite(type)
+        Object[] args = [IndyInterfaceCallSiteTargetTest, ['bar'] as Object[]] as Object[]
+
+        MethodHandle methodHandle = invokeSelectMethodHandle(
+            callSite, IndyInterfaceCallSiteTargetTest, 'staticEcho',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.FALSE, Boolean.TRUE, 1, args
+        )
+
+        assertEquals(staticEcho('bar'), methodHandle.invokeWithArguments([args] as Object[]))
+        assertSame(MethodHandleWrapper.getNullMethodHandleWrapper(), requireCachedWrapper(callSite, IndyInterfaceCallSiteTargetTest))
+        assertSame(callSite.defaultTarget, callSite.target)
+    }
+
+    private static CacheableCallSite newCallSite(MethodType type) {
+        CacheableCallSite callSite = new CacheableCallSite(type, MethodHandles.lookup())
+        MethodHandle dummyTarget = targetHandle(type, null)
+        callSite.target = dummyTarget
+        callSite.defaultTarget = dummyTarget
+        callSite.fallbackTarget = dummyTarget
+        return callSite
+    }
+
+    private static MethodHandleWrapper newCachedWrapper(MethodType type, Object cachedValue, Object targetValue, MetaMethod method, boolean canSetTarget) {
+        new MethodHandleWrapper(cachedHandle(cachedValue), targetHandle(type, targetValue), method, canSetTarget)
+    }
+
+    private static MethodHandle cachedHandle(Object value) {
+        MethodHandles.dropArguments(MethodHandles.constant(Object, value), 0, Object[])
+    }
+
+    private static MethodHandle targetHandle(MethodType type, Object value) {
+        MethodHandles.dropArguments(MethodHandles.constant(Object, value), 0, type.parameterArray())
+    }
+
+    private static void cacheWrapper(CacheableCallSite callSite, Object receiver, MethodHandleWrapper wrapper) {
+        callSite.put(receiverClassName(receiver), wrapper)
+    }
+
+    private static void primeLatestHitCount(CacheableCallSite callSite, Object receiver, MethodHandleWrapper wrapper, long value) {
+        assertSame(wrapper, callSite.getAndPut(receiverClassName(receiver), { wrapper }))
+        latestHitCountField().get(wrapper).set(value)
+    }
+
+    private static void assertFallbackCutoffLeavesDefaultTarget(boolean startAwayFromDefaultTarget) {
+        MethodType type = MethodType.methodType(Object, IndyInterfaceCallSiteTargetTest)
+        CacheableCallSite callSite = newCallSite(type)
+        def receiver = new IndyInterfaceCallSiteTargetTest()
+        Object[] args = [receiver] as Object[]
+        MethodHandleWrapper wrapper = newCachedWrapper(
+            type, 'cached-instance-result', 'optimized-target-result',
+            CachedMethod.find(IndyInterfaceCallSiteTargetTest.getDeclaredMethod('foo')), true
+        )
+
+        cacheWrapper(callSite, receiver, wrapper)
+        primeLatestHitCount(callSite, receiver, wrapper, readIndyLong('INDY_OPTIMIZE_THRESHOLD'))
+        callSite.fallbackRound.set(readIndyLong('INDY_FALLBACK_CUTOFF') + 1L)
+        if (startAwayFromDefaultTarget) {
+            callSite.target = targetHandle(type, 'non-default-target')
+        }
+
+        MethodHandle methodHandle = invokeFromCacheHandle(
+            callSite, IndyInterfaceCallSiteTargetTest, 'foo',
+            IndyInterface.CallType.METHOD.getOrderNumber(),
+            Boolean.FALSE, Boolean.TRUE, Boolean.FALSE, 1, args
+        )
+
+        assertSame(wrapper.cachedMethodHandle, methodHandle)
+        assertSame(callSite.defaultTarget, callSite.target)
+        assertEquals(0L, wrapper.latestHitCount)
+    }
+
+    private static MethodHandleWrapper requireCachedWrapper(CacheableCallSite callSite, Object receiver) {
+        AtomicInteger providerCalls = new AtomicInteger()
+        MethodHandleWrapper wrapper = callSite.getAndPut(receiverClassName(receiver), { key ->
+            providerCalls.incrementAndGet()
+            MethodHandleWrapper.getNullMethodHandleWrapper()
+        })
+        assertEquals(0, providerCalls.get())
+        wrapper
+    }
+
+    private static String receiverClassName(Object receiver) {
+        if (receiver == null) return 'org.codehaus.groovy.runtime.NullObject'
+        if (receiver instanceof Class) return 'java.lang.Class:' + ((Class<?>) receiver).getName()
+        return receiver.getClass().name
+    }
+
+    private static long readIndyLong(String fieldName) {
+        Field field = IndyInterface.getDeclaredField(fieldName)
+        field.accessible = true
+        field.getLong(null)
+    }
+
+    private static Field latestHitCountField() {
+        Field field = MethodHandleWrapper.getDeclaredField('latestHitCount')
+        field.accessible = true
+        field
+    }
+
+    private static MethodHandle invokeFromCacheHandle(CacheableCallSite callSite, Class<?> sender, String methodName,
+            int callID, Boolean safeNavigation, Boolean thisCall, Boolean spreadCall, Object dummyReceiver, Object[] arguments) {
+        Method method = IndyInterface.getDeclaredMethod('fromCacheHandle',
+            CacheableCallSite, Class, String, Integer.TYPE, Boolean, Boolean, Boolean, Object, Object[]
+        )
+        method.accessible = true
+        return (MethodHandle) method.invoke(null,
+            callSite, sender, methodName, callID, safeNavigation, thisCall, spreadCall, dummyReceiver, arguments
+        )
+    }
+
+    private static MethodHandle invokeSelectMethodHandle(CacheableCallSite callSite, Class<?> sender, String methodName,
+            int callID, Boolean safeNavigation, Boolean thisCall, Boolean spreadCall, Object dummyReceiver, Object[] arguments) {
+        Method method = IndyInterface.getDeclaredMethod('selectMethodHandle',
+            CacheableCallSite, Class, String, Integer.TYPE, Boolean, Boolean, Boolean, Object, Object[]
+        )
+        method.accessible = true
+        return (MethodHandle) method.invoke(null,
+            callSite, sender, methodName, callID, safeNavigation, thisCall, spreadCall, dummyReceiver, arguments
+        )
+    }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/StaticMethodCallIndy.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/StaticMethodCallIndy.groovy
new file mode 100644
index 0000000000..5024e1e853
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/StaticMethodCallIndy.groovy
@@ -0,0 +1,124 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package org.apache.groovy.bench
+
+import groovy.transform.CompileStatic
+
+/**
+ * Helper class for {@link StaticMethodCallIndyBench}.
+ * <p>
+ * Dynamic methods emit {@code invokedynamic}
+ * call sites whose targets are resolved through {@code IndyInterface}.
+ * Static method calls benefit from the early {@code setTarget} optimization
+ * because the receiver is always a {@code Class} object, allowing the JIT
+ * compiler to inline the target sooner.
+ */
+class StaticMethodCallIndy {
+
+    // ---------- Dynamic Groovy — invokedynamic call sites ----------
+
+    static int staticAdd(int a, int b) {
+        return a + b
+    }
+
+    static int staticSum(int n) {
+        int sum = 0
+        for (int i = 0; i < n; i++) {
+            sum = staticAdd(sum, i)
+        }
+        return sum
+    }
+
+    static int staticFib(int n) {
+        if (n < 2) return n
+        return staticFib(n - 1) + staticFib(n - 2)
+    }
+
+    static int staticSquare(int x) { return x * x }
+    static int staticIncrement(int x) { return x + 1 }
+    static int staticDouble(int x) { return x * 2 }
+
+    static int staticChain(int x) {
+        return staticDouble(staticIncrement(staticSquare(x)))
+    }
+
+    // ---------- Instance methods — no early setTarget ----------
+
+    int instanceAdd(int a, int b) {
+        return a + b
+    }
+
+    int instanceSum(int n) {
+        int sum = 0
+        for (int i = 0; i < n; i++) {
+            sum = instanceAdd(sum, i)
+        }
+        return sum
+    }
+
+    int instanceFib(int n) {
+        if (n < 2) return n
+        return instanceFib(n - 1) + instanceFib(n - 2)
+    }
+
+    int instanceSquare(int x) { return x * x }
+    int instanceIncrement(int x) { return x + 1 }
+    int instanceDouble(int x) { return x * 2 }
+
+    int instanceChain(int x) {
+        return instanceDouble(instanceIncrement(instanceSquare(x)))
+    }
+
+    // ---------- @CompileStatic — invokestatic, no invokedynamic ----------
+
+    @CompileStatic
+    static int staticAddCS(int a, int b) {
+        return a + b
+    }
+
+    @CompileStatic
+    static int staticSumCS(int n) {
+        int sum = 0
+        for (int i = 0; i < n; i++) {
+            sum = staticAddCS(sum, i)
+        }
+        return sum
+    }
+
+    @CompileStatic
+    static int staticFibCS(int n) {
+        if (n < 2) return n
+        return staticFibCS(n - 1) + staticFibCS(n - 2)
+    }
+
+    @CompileStatic
+    static int staticSquareCS(int x) { return x * x }
+
+    @CompileStatic
+    static int staticIncrementCS(int x) { return x + 1 }
+
+    @CompileStatic
+    static int staticDoubleCS(int x) { return x * 2 }
+
+    @CompileStatic
+    static int staticChainCS(int x) {
+        return staticDoubleCS(staticIncrementCS(staticSquareCS(x)))
+    }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/StaticMethodCallIndyBench.java b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/StaticMethodCallIndyBench.java
new file mode 100644
index 0000000000..918fc3f71e
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/StaticMethodCallIndyBench.java
@@ -0,0 +1,180 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.bench;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Benchmarks for the early {@code setTarget} optimisation on static method
+ * call sites in the invokedynamic dispatch path.
+ * <p>
+ * When the receiver of an {@code invokedynamic} call is a {@code Class} object
+ * and the resolved method is {@code static}, the call site target is set
+ * immediately — bypassing the normal hit-count threshold — so that the JIT
+ * compiler can inline the target method handle sooner.
+ * <p>
+ * This benchmark compares:
+ * <ul>
+ *   <li><b>Java</b> — direct {@code invokestatic}, the theoretical optimum</li>
+ *   <li><b>Groovy dynamic</b> — {@code invokedynamic} with early setTarget</li>
+ *   <li><b>Groovy {@code @CompileStatic}</b> — direct {@code invokestatic}</li>
+ *   <li><b>Groovy instance</b> — {@code invokedynamic} without early setTarget (control group)</li>
+ * </ul>
+ */
+@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@State(Scope.Thread)
+public class StaticMethodCallIndyBench {
+
+    private static final int SUM_N = 1000;
+    private static final int FIB_N = 25;
+    private static final int CHAIN_ITERATIONS = 1000;
+
+    private StaticMethodCallIndy instance;
+
+    @Setup
+    public void setUp() {
+        instance = new StaticMethodCallIndy();
+    }
+
+    // ---- loop with static call: sum(0..n) via repeated add ----
+
+    @Benchmark
+    public int staticSum_java() {
+        return JavaStaticMethods.sum(SUM_N);
+    }
+
+    @Benchmark
+    public int staticSum_groovy() {
+        return StaticMethodCallIndy.staticSum(SUM_N);
+    }
+
+    @Benchmark
+    public int staticSum_groovyCS() {
+        return StaticMethodCallIndy.staticSumCS(SUM_N);
+    }
+
+    @Benchmark
+    public int instanceSum_groovy() {
+        return instance.instanceSum(SUM_N);
+    }
+
+    // ---- recursive static call: Fibonacci ----
+
+    @Benchmark
+    public int staticFib_java() {
+        return JavaStaticMethods.fib(FIB_N);
+    }
+
+    @Benchmark
+    public int staticFib_groovy() {
+        return StaticMethodCallIndy.staticFib(FIB_N);
+    }
+
+    @Benchmark
+    public int staticFib_groovyCS() {
+        return StaticMethodCallIndy.staticFibCS(FIB_N);
+    }
+
+    @Benchmark
+    public int instanceFib_groovy() {
+        return instance.instanceFib(FIB_N);
+    }
+
+    // ---- chained static calls ----
+
+    @Benchmark
+    public int staticChain_java() {
+        int result = 0;
+        for (int i = 0; i < CHAIN_ITERATIONS; i++) {
+            result += JavaStaticMethods.chain(i);
+        }
+        return result;
+    }
+
+    @Benchmark
+    public int staticChain_groovy() {
+        int result = 0;
+        for (int i = 0; i < CHAIN_ITERATIONS; i++) {
+            result += StaticMethodCallIndy.staticChain(i);
+        }
+        return result;
+    }
+
+    @Benchmark
+    public int staticChain_groovyCS() {
+        int result = 0;
+        for (int i = 0; i < CHAIN_ITERATIONS; i++) {
+            result += StaticMethodCallIndy.staticChainCS(i);
+        }
+        return result;
+    }
+
+    @Benchmark
+    public int instanceChain_groovy() {
+        int result = 0;
+        for (int i = 0; i < CHAIN_ITERATIONS; i++) {
+            result += instance.instanceChain(i);
+        }
+        return result;
+    }
+
+    // ---- Java baseline implementations ----
+
+    private static class JavaStaticMethods {
+        static int add(int a, int b) {
+            return a + b;
+        }
+
+        static int sum(int n) {
+            int s = 0;
+            for (int i = 0; i < n; i++) {
+                s = add(s, i);
+            }
+            return s;
+        }
+
+        static int fib(int n) {
+            if (n < 2) return n;
+            return fib(n - 1) + fib(n - 2);
+        }
+
+        static int square(int x) { return x * x; }
+        static int increment(int x) { return x + 1; }
+        static int doubleIt(int x) { return x * 2; }
+
+        static int chain(int x) {
+            return doubleIt(increment(square(x)));
+        }
+    }
+}