You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/08/23 21:30:18 UTC

(groovy) branch master updated: GROOVY-12285: STC: index extension methods by name and skip cloning non-generic parameters

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

paulk-asert 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 fc38080973 GROOVY-12285: STC: index extension methods by name and skip cloning non-generic parameters
fc38080973 is described below

commit fc3808097367cc9133facaa64e62dcd449e2ae49
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Aug 22 17:06:34 2026 +0900

    GROOVY-12285: STC: index extension methods by name and skip cloning non-generic parameters
---
 ARCHITECTURE.md                                    |   4 +
 .../stc/AbstractExtensionMethodCache.java          | 130 +++++++++-
 .../groovy/transform/stc/ExtensionMethodCache.java |   3 +-
 .../transform/stc/StaticTypeCheckingSupport.java   |  71 ++++--
 .../transform/stc/DgmMethodLookupTest.groovy       | 269 +++++++++++++++++++++
 .../stc/StaticTypeCheckingSupportTest.groovy       | 171 +++++++++++++
 .../apache/groovy/perf/ChooseBestMethodBench.java  | 180 ++++++++++++++
 .../apache/groovy/perf/DgmMethodLookupBench.java   | 122 ++++++++++
 .../apache/groovy/perf/StcCompilePerfBench.java    | 179 ++++++++++++++
 9 files changed, 1098 insertions(+), 31 deletions(-)

diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a7f29ab2af..5c73176864 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -140,6 +140,10 @@ verbatim keeps the reference precise; paraphrasing tends to drift.
 - Extensible from user code via type-checking extension scripts; see
   `src/spec/doc/_type-checking-extensions.adoc` for the user-facing
   documentation of that mechanism.
+- Extension methods consulted during checking are cached per class
+  loader (`ExtensionMethodCache`). Each receiver's method list is
+  indexed by name so a lookup does not scan every DGM method on types
+  such as `Object` or `Collection`.
 
 ### Class generation (phase 7)
 
diff --git a/src/main/java/org/codehaus/groovy/transform/stc/AbstractExtensionMethodCache.java b/src/main/java/org/codehaus/groovy/transform/stc/AbstractExtensionMethodCache.java
index fb298110fb..9af8c477af 100644
--- a/src/main/java/org/codehaus/groovy/transform/stc/AbstractExtensionMethodCache.java
+++ b/src/main/java/org/codehaus/groovy/transform/stc/AbstractExtensionMethodCache.java
@@ -28,6 +28,7 @@ import org.codehaus.groovy.runtime.m12n.MetaInfExtensionModule;
 import org.codehaus.groovy.runtime.memoize.EvictableCache;
 import org.codehaus.groovy.runtime.memoize.StampedCommonCache;
 
+import java.util.AbstractList;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -37,6 +38,8 @@ import java.util.LinkedHashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.RandomAccess;
 import java.util.Set;
 import java.util.WeakHashMap;
 import java.util.function.Function;
@@ -45,11 +48,16 @@ import java.util.function.Predicate;
 import static org.codehaus.groovy.ast.ClassHelper.makeWithoutCaching;
 
 /**
+ * Per-{@link ClassLoader} cache of extension methods (DGM and friends, or
+ * another mapper such as macro methods). Values are immutable lists that
+ * also carry a name index, so a named lookup does not scan every method
+ * stored under a receiver (there are hundreds on {@code Object}).
+ *
  * @since 3.0.0
  */
 public abstract class AbstractExtensionMethodCache {
     /** Caches extension methods per class loader. */
-    final EvictableCache<ClassLoader, Map<String, List<MethodNode>>> cache = new StampedCommonCache<>(new WeakHashMap<>());
+    private final EvictableCache<ClassLoader, Map<String, List<MethodNode>>> cache = new StampedCommonCache<>(new WeakHashMap<>());
     /** Caches, per class loader, the names of extension methods that declare {@code @ClassTag(preempt=true)} (GROOVY-12115). */
     private final EvictableCache<ClassLoader, Set<String>> preemptiveNamesCache = new StampedCommonCache<>(new WeakHashMap<>());
     private final String disabledString = SystemUtil.getSystemPropertySafe(getDisablePropertyName());
@@ -58,17 +66,61 @@ public abstract class AbstractExtensionMethodCache {
 
     /**
      * Returns the cached extension methods for the supplied class loader.
+     * Each list is immutable and indexed by method name.
      */
-    public Map<String, List<MethodNode>> get(ClassLoader loader) {
+    public final Map<String, List<MethodNode>> get(ClassLoader loader) {
         return cache.getAndPut(loader, this::getMethodsFromClassLoader);
     }
 
+    /**
+     * Returns methods stored under {@code key} whose
+     * {@linkplain MethodNode#getName() name} is {@code name}.
+     * Never {@code null}; empty when the key is absent or no method of
+     * that name exists. The name index is built when the loader's
+     * methods are scanned, so this is a hash get rather than a linear
+     * scan of the receiver's DGM methods.
+     */
+    List<MethodNode> get(final ClassLoader loader, final String key, final String name) {
+        List<MethodNode> methods = get(loader).get(key);
+        if (methods == null || methods.isEmpty()) {
+            return Collections.emptyList();
+        }
+        if (methods instanceof MethodsByName) {
+            return ((MethodsByName) methods).named(name);
+        }
+        List<MethodNode> matches = new ArrayList<>(2);
+        for (MethodNode method : methods) {
+            if (method.getName().equals(name)) {
+                matches.add(method);
+            }
+        }
+        return matches.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(matches);
+    }
+
+    /**
+     * Drops cached extension methods for the supplied class loader,
+     * including derived data such as the preemptive-name set.
+     */
+    void invalidate(final ClassLoader loader) {
+        cache.remove(loader);
+        preemptiveNamesCache.remove(loader);
+    }
+
+    /**
+     * Drops all cached extension methods and derived indexes.
+     */
+    void invalidateAll() {
+        cache.clearAll();
+        preemptiveNamesCache.clearAll();
+    }
+
     /**
      * Returns the names of extension methods that declare {@code @ClassTag(preempt=true)}, for the
      * supplied class loader (GROOVY-12115). Preemption matching consults this to skip the full
      * candidate scan for the overwhelmingly common name that has no preemptive extension overload;
-     * derived once (lazily) from the already-cached method map and sharing its loader lifecycle, so
-     * it never goes stale independently. Out of the box this holds only {@code "withDefault"}.
+     * derived once (lazily) from the already-cached method map. {@link #invalidate(ClassLoader)}
+     * and {@link #invalidateAll()} drop this set with the method map so it cannot go stale
+     * independently. Out of the box this holds only {@code "withDefault"}.
      */
     Set<String> getPreemptiveNames(ClassLoader loader) {
         return preemptiveNamesCache.getAndPut(loader, l -> {
@@ -129,9 +181,13 @@ public abstract class AbstractExtensionMethodCache {
         return methods;
     }
 
-    private Map<String, List<MethodNode>> makeMethodsUnmodifiable(Map<String, List<MethodNode>> methods) {
-        methods.replaceAll((k, v) -> Collections.unmodifiableList(v));
-
+    /**
+     * Freezes each per-key list as a {@link MethodsByName} so callers can
+     * look up overloads by name without a linear scan. {@link #get(ClassLoader, String, String)}
+     * relies on this wrapping.
+     */
+    private Map<String, List<MethodNode>> makeMethodsUnmodifiable(final Map<String, List<MethodNode>> methods) {
+        methods.replaceAll((k, v) -> new MethodsByName(v));
         return Collections.unmodifiableMap(methods);
     }
 
@@ -194,4 +250,64 @@ public abstract class AbstractExtensionMethodCache {
         List<MethodNode> nodes = accumulator.computeIfAbsent(key, k -> new ArrayList<>());
         nodes.add(node);
     }
+
+    /**
+     * Immutable random-access list of extension methods with a name index.
+     * Built once per cache key when a loader is scanned; the lists stored
+     * in {@link #named(String)} are themselves unmodifiable.
+     */
+    private static final class MethodsByName extends AbstractList<MethodNode> implements RandomAccess {
+        private static final MethodNode[] EMPTY = new MethodNode[0];
+
+        private final MethodNode[] methods;
+        private final Map<String, List<MethodNode>> byName;
+
+        MethodsByName(final List<MethodNode> source) {
+            this.methods = source.toArray(EMPTY);
+            int count = this.methods.length;
+            if (count == 0) {
+                this.byName = Collections.emptyMap();
+            } else if (count == 1) {
+                MethodNode m = this.methods[0];
+                this.byName = Collections.singletonMap(m.getName(), Collections.singletonList(m));
+            } else if (allSameName(this.methods)) {
+                this.byName = Collections.singletonMap(this.methods[0].getName(), this);
+            } else {
+                Map<String, List<MethodNode>> index = new HashMap<>(Math.max(4, (int) (count / 0.75f) + 1));
+                for (MethodNode method : this.methods) {
+                    index.computeIfAbsent(method.getName(), k -> new ArrayList<>(2)).add(method);
+                }
+                index.replaceAll((k, v) -> v.size() == 1
+                        ? Collections.singletonList(v.get(0))
+                        : Collections.unmodifiableList(v));
+                this.byName = Collections.unmodifiableMap(index);
+            }
+        }
+
+        private static boolean allSameName(final MethodNode[] methods) {
+            String firstName = methods[0].getName();
+            for (int i = 1, n = methods.length; i < n; i += 1) {
+                if (!firstName.equals(methods[i].getName())) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        List<MethodNode> named(final String name) {
+            List<MethodNode> found = byName.get(name);
+            return found != null ? found : Collections.emptyList();
+        }
+
+        @Override
+        public MethodNode get(final int index) {
+            Objects.checkIndex(index, methods.length);
+            return methods[index];
+        }
+
+        @Override
+        public int size() {
+            return methods.length;
+        }
+    }
 }
diff --git a/src/main/java/org/codehaus/groovy/transform/stc/ExtensionMethodCache.java b/src/main/java/org/codehaus/groovy/transform/stc/ExtensionMethodCache.java
index a55d2af646..49b524204f 100644
--- a/src/main/java/org/codehaus/groovy/transform/stc/ExtensionMethodCache.java
+++ b/src/main/java/org/codehaus/groovy/transform/stc/ExtensionMethodCache.java
@@ -35,7 +35,8 @@ import static org.codehaus.groovy.runtime.metaclass.MetaClassRegistryImpl.EXTENS
  * This class is used to make extension methods lookup faster. Basically, it will only
  * collect the list of extension methods (see {@link ExtensionModule}) if the list of
  * extension modules has changed. It avoids recomputing the whole list each time we perform
- * a method lookup.
+ * a method lookup. Cached lists are indexed by method name so the static type checker can
+ * collect DGM candidates without a linear scan of every method on the receiver type.
  */
 public class ExtensionMethodCache extends AbstractExtensionMethodCache {
     /** Shared cache instance used by static type checking. */
diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingSupport.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingSupport.java
index e7405611be..869c5e8a72 100644
--- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingSupport.java
+++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingSupport.java
@@ -273,17 +273,18 @@ public abstract class StaticTypeCheckingSupport {
     protected static final ExtensionMethodCache EXTENSION_METHOD_CACHE = ExtensionMethodCache.INSTANCE;
 
     /**
-     * Clears cached extension methods for the supplied class loader.
+     * Drops cached extension methods for the supplied class loader,
+     * including the per-receiver name index and the preemptive-name set.
      */
     public static void clearExtensionMethodCache(final ClassLoader loader) {
-        EXTENSION_METHOD_CACHE.cache.remove(loader);
+        EXTENSION_METHOD_CACHE.invalidate(loader);
     }
 
     /**
-     * Clears all cached extension methods.
+     * Drops all cached extension methods and derived indexes.
      */
     public static void clearExtensionMethodCache() {
-        EXTENSION_METHOD_CACHE.cache.clearAll();
+        EXTENSION_METHOD_CACHE.invalidateAll();
     }
 
     /**
@@ -341,6 +342,8 @@ public abstract class StaticTypeCheckingSupport {
 
     /**
      * Returns extension methods with the supplied name for the receiver hierarchy.
+     * Uses the name-indexed extension-method cache; repeated lookups of the same
+     * name on the same type do not rescan that type's DGM methods.
      */
     public static Set<MethodNode> findDGMMethodsForClassNode(final ClassLoader loader, final ClassNode clazz, final String name) {
         TreeSet<MethodNode> accumulator = new TreeSet<>(DGM_METHOD_NODE_COMPARATOR);
@@ -358,14 +361,11 @@ public abstract class StaticTypeCheckingSupport {
 
     /**
      * Collects extension methods with the supplied name for the receiver hierarchy.
+     * Named lookup uses the per-receiver index built when the loader's extension
+     * methods were scanned, so it does not walk every DGM method on the type.
      */
     protected static void findDGMMethodsForClassNode(final ClassLoader loader, final ClassNode clazz, final String name, final TreeSet<MethodNode> accumulator) {
-        List<MethodNode> fromDGM = EXTENSION_METHOD_CACHE.get(loader).get(clazz.getName());
-        if (fromDGM != null) {
-            for (MethodNode node : fromDGM) {
-                if (node.getName().equals(name)) accumulator.add(node);
-            }
-        }
+        accumulator.addAll(EXTENSION_METHOD_CACHE.get(loader, clazz.getName(), name));
         for (ClassNode node : clazz.getInterfaces()) {
             findDGMMethodsForClassNode(loader, node, name, accumulator);
         }
@@ -1100,19 +1100,7 @@ public abstract class StaticTypeCheckingSupport {
         // phase 1: argument-parameter distance classifier
         int bestDist = Integer.MAX_VALUE;
         for (MethodNode method : methods) {
-            Parameter[] parameters = method.getParameters();
-            int nParameters = parameters.length;
-            if (nParameters > 0) {
-                parameters = parameters.clone();
-                for (int i = 0; i < nParameters; i += 1) {
-                    Parameter p = parameters[i];
-                    ClassNode t = p.getOriginType();
-                    if (t.isGenericsPlaceHolder() || isUsingGenericsOrIsArrayUsingGenerics(t))
-                        parameters[i] = new Parameter(GenericsUtils.nonGeneric(t), p.getName());
-                }
-            }
-
-            int dist = measureParametersAndArgumentsDistance(parameters, argumentTypes);
+            int dist = measureParametersAndArgumentsDistance(parametersForDistance(method), argumentTypes);
             if (dist >= 0 && dist <= bestDist) {
                 if (dist < bestDist) {
                     bestDist = dist;
@@ -1168,6 +1156,43 @@ public abstract class StaticTypeCheckingSupport {
         return bestMethods;
     }
 
+    /**
+     * Distance measurement treats generic parameters as their erasure so a
+     * {@code List<T>} parameter does not reject a {@code List} argument.
+     * <p>
+     * <b>PERFORMANCE &amp; SAFETY CONTRACT:</b> To avoid redundant array allocations during
+     * overload resolution, this method reuses and returns {@link MethodNode#getParameters()}
+     * directly whenever no generic erasure is needed. The returned array is a cloned copy
+     * <i>only</i> when one or more parameters require generic erasure.
+     * <p>
+     * <b>CRITICAL MUTATION WARNING:</b> Callers of this method and all downstream methods in
+     * the distance measurement chain (e.g. {@link #measureParametersAndArgumentsDistance(Parameter[], ClassNode[])})
+     * <b>MUST NEVER</b> mutate the returned {@code Parameter[]} array or its elements in place.
+     * In-place mutation would silently corrupt the {@link MethodNode}'s parameter definitions
+     * for all subsequent compilation phases across the compiler.
+     */
+    private static Parameter[] parametersForDistance(final MethodNode method) {
+        Parameter[] parameters = method.getParameters();
+        Parameter[] erased = null;
+        for (int i = 0, n = parameters.length; i < n; i += 1) {
+            ClassNode t = parameters[i].getOriginType();
+            if (t.isGenericsPlaceHolder() || isUsingGenericsOrIsArrayUsingGenerics(t)) {
+                if (erased == null) {
+                    erased = parameters.clone();
+                }
+                erased[i] = new Parameter(GenericsUtils.nonGeneric(t), parameters[i].getName());
+            }
+        }
+        // WARNING: Returning the un-cloned `parameters` array here is safe ONLY because all callers
+        // in the distance-measurement chain treat the array as strictly read-only.
+        return erased == null ? parameters : erased;
+    }
+
+    /**
+     * Measures parameter-to-argument distance. Note: The supplied {@code parameters} array
+     * may be the internal backing array of a {@link MethodNode} (see {@link #parametersForDistance(MethodNode)});
+     * this method and all helper methods it calls must treat {@code parameters} as strictly read-only.
+     */
     @SuppressWarnings("removal")
     private static int measureParametersAndArgumentsDistance(final Parameter[] parameters, final ClassNode[] argumentTypes) {
         int dist = -1;
diff --git a/src/test/groovy/org/codehaus/groovy/transform/stc/DgmMethodLookupTest.groovy b/src/test/groovy/org/codehaus/groovy/transform/stc/DgmMethodLookupTest.groovy
new file mode 100644
index 0000000000..6916f390fd
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/transform/stc/DgmMethodLookupTest.groovy
@@ -0,0 +1,269 @@
+/*
+ *  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.transform.stc
+
+import groovy.lang.GroovyClassLoader
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.runtime.DefaultGroovyMethods
+import org.junit.jupiter.api.Test
+
+import java.util.function.Function
+import java.util.function.Predicate
+
+import static groovy.test.GroovyAssert.shouldFail
+import static org.codehaus.groovy.ast.ClassHelper.CLOSURE_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.LIST_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.MAP_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.STRING_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.int_TYPE
+import static org.codehaus.groovy.transform.stc.StaticTypeCheckingSupport.findDGMMethodsByNameAndArguments
+import static org.codehaus.groovy.transform.stc.StaticTypeCheckingSupport.findDGMMethodsForClassNode
+
+/**
+ * The DGM lookup used by the static type checker must keep returning the same
+ * named methods after the per-receiver name index is populated, including
+ * after the loader's extension cache is dropped and rebuilt.
+ */
+final class DgmMethodLookupTest {
+
+    private static ClassLoader testLoader() {
+        DgmMethodLookupTest.classLoader
+    }
+
+    @Test
+    void 'named DGM lookup is stable after the name index is built'() {
+        def loader = testLoader()
+        def first = findDGMMethodsForClassNode(loader, LIST_TYPE, 'each')
+        def second = findDGMMethodsForClassNode(loader, LIST_TYPE, 'each')
+        assert !first.isEmpty()
+        assert first.size() == second.size()
+        assert first.every { it.name == 'each' }
+
+        def collect = findDGMMethodsForClassNode(loader, LIST_TYPE, 'collect')
+        assert collect.every { it.name == 'collect' }
+        assert collect.size() > 0
+    }
+
+    @Test
+    void 'unknown DGM name is empty'() {
+        def missing = findDGMMethodsForClassNode(testLoader(), STRING_TYPE, 'definitelyNotADgmMethod')
+        assert missing.isEmpty()
+    }
+
+    @Test
+    void 'unknown receiver still only returns the requested name'() {
+        def unknown = ClassHelper.make('com.example.DoesNotExist')
+        def each = findDGMMethodsForClassNode(testLoader(), unknown, 'each')
+        assert each.every { it.name == 'each' }
+        assert findDGMMethodsForClassNode(testLoader(), unknown, 'definitelyNotADgmMethod').isEmpty()
+    }
+
+    @Test
+    void 'clearing the extension cache still allows a subsequent lookup'() {
+        def loader = new GroovyClassLoader()
+        def before = findDGMMethodsForClassNode(loader, OBJECT_TYPE, 'with')
+        assert !before.isEmpty()
+        StaticTypeCheckingSupport.clearExtensionMethodCache(loader)
+        def after = findDGMMethodsForClassNode(loader, OBJECT_TYPE, 'with')
+        assert after.size() == before.size()
+        assert after.every { it.name == 'with' }
+    }
+
+    @Test
+    void 'clearing all extension caches still allows a subsequent lookup'() {
+        def loader = new GroovyClassLoader()
+        def before = findDGMMethodsForClassNode(loader, OBJECT_TYPE, 'with')
+        assert !before.isEmpty()
+        StaticTypeCheckingSupport.clearExtensionMethodCache()
+        def after = findDGMMethodsForClassNode(loader, OBJECT_TYPE, 'with')
+        assert after.size() == before.size()
+    }
+
+    @Test
+    void 'clearing one loader does not drop another loader cache'() {
+        def one = new GroovyClassLoader()
+        def two = new GroovyClassLoader()
+        def fromOne = findDGMMethodsForClassNode(one, OBJECT_TYPE, 'with')
+        def fromTwo = findDGMMethodsForClassNode(two, OBJECT_TYPE, 'with')
+        assert fromOne.size() == fromTwo.size()
+
+        Set<String> namesTwo = ExtensionMethodCache.INSTANCE.getPreemptiveNames(two)
+        StaticTypeCheckingSupport.clearExtensionMethodCache(one)
+
+        def fromTwoAfter = findDGMMethodsForClassNode(two, OBJECT_TYPE, 'with')
+        assert fromTwoAfter.size() == fromTwo.size()
+        assert ExtensionMethodCache.INSTANCE.getPreemptiveNames(two) == namesTwo
+
+        def fromOneAfter = findDGMMethodsForClassNode(one, OBJECT_TYPE, 'with')
+        assert fromOneAfter.size() == fromOne.size()
+    }
+
+    @Test
+    void 'name index matches a linear scan of the cached list'() {
+        def loader = testLoader()
+        def objectMethods = ExtensionMethodCache.INSTANCE.get(loader).get(OBJECT_TYPE.name)
+        assert objectMethods != null
+        assert objectMethods.size() > 1
+
+        def linear = objectMethods.findAll { it.name == 'with' }
+        def indexed = ExtensionMethodCache.INSTANCE.get(loader, OBJECT_TYPE.name, 'with')
+        assert indexed.size() == linear.size()
+        assert indexed.containsAll(linear)
+        assert indexed.every { it.name == 'with' }
+    }
+
+    @Test
+    void 'cached lists and named views are unmodifiable'() {
+        def loader = testLoader()
+        def objectMethods = ExtensionMethodCache.INSTANCE.get(loader).get(OBJECT_TYPE.name)
+        shouldFail(UnsupportedOperationException) { objectMethods.add(null) }
+        shouldFail(UnsupportedOperationException) { objectMethods.clear() }
+        shouldFail(UnsupportedOperationException) { objectMethods.remove(0) }
+        shouldFail(UnsupportedOperationException) { objectMethods.set(0, objectMethods.get(0)) }
+        shouldFail(IndexOutOfBoundsException) { objectMethods.get(-1) }
+        shouldFail(IndexOutOfBoundsException) { objectMethods.get(objectMethods.size()) }
+
+        def named = ExtensionMethodCache.INSTANCE.get(loader, OBJECT_TYPE.name, 'with')
+        assert !named.isEmpty()
+        shouldFail(UnsupportedOperationException) { named.add(null) }
+
+        def missing = ExtensionMethodCache.INSTANCE.get(loader, OBJECT_TYPE.name, 'definitelyNotADgmMethod')
+        assert missing.isEmpty()
+        shouldFail(UnsupportedOperationException) { missing.add(null) }
+
+        def unknownClass = ExtensionMethodCache.INSTANCE.get(loader, 'com.example.DoesNotExist', 'each')
+        assert unknownClass.isEmpty()
+    }
+
+    @Test
+    void 'overloads of the same name are all indexed'() {
+        def loader = testLoader()
+        def plus = findDGMMethodsForClassNode(loader, STRING_TYPE, 'plus')
+        assert plus.size() > 1
+        assert plus.every { it.name == 'plus' }
+
+        def objectMethods = ExtensionMethodCache.INSTANCE.get(loader).get(OBJECT_TYPE.name)
+        def singletonEntry = objectMethods.countBy { it.name }.find { it.value == 1 }
+        assert singletonEntry != null
+        def singleton = ExtensionMethodCache.INSTANCE.get(loader, OBJECT_TYPE.name, singletonEntry.key)
+        assert singleton.size() == 1
+        assert singleton[0].name == singletonEntry.key
+        shouldFail(UnsupportedOperationException) { singleton.add(null) }
+    }
+
+    @Test
+    void 'lookup walks interfaces and superclasses'() {
+        def arrayList = ClassHelper.make(ArrayList)
+        def each = findDGMMethodsForClassNode(testLoader(), arrayList, 'each')
+        assert !each.isEmpty()
+        assert each.every { it.name == 'each' }
+
+        def mapEach = findDGMMethodsForClassNode(testLoader(), MAP_TYPE, 'each')
+        assert !mapEach.isEmpty()
+        assert mapEach.every { it.name == 'each' }
+    }
+
+    @Test
+    void 'lookup on arrays walks component and Object array helpers'() {
+        def loader = testLoader()
+        def stringArrayGetAt = findDGMMethodsForClassNode(loader, STRING_TYPE.makeArray(), 'getAt')
+        assert stringArrayGetAt.every { it.name == 'getAt' }
+        assert !stringArrayGetAt.isEmpty()
+
+        def listArrayGetAt = findDGMMethodsForClassNode(loader, LIST_TYPE.makeArray(), 'getAt')
+        assert !listArrayGetAt.isEmpty()
+        assert listArrayGetAt.every { it.name == 'getAt' }
+
+        def intArrayGetAt = findDGMMethodsForClassNode(loader, int_TYPE.makeArray(), 'getAt')
+        assert !intArrayGetAt.isEmpty()
+        assert intArrayGetAt.every { it.name == 'getAt' }
+
+        def objectArrayGetAt = findDGMMethodsForClassNode(loader, OBJECT_TYPE.makeArray(), 'getAt')
+        assert !objectArrayGetAt.isEmpty()
+    }
+
+    @Test
+    void 'lookup on a primitive type still finds Object extensions'() {
+        def with = findDGMMethodsForClassNode(testLoader(), int_TYPE, 'with')
+        assert !with.isEmpty()
+        assert with.every { it.name == 'with' }
+    }
+
+    @Test
+    void 'named lookup with argument types still selects DGM candidates'() {
+        def each = findDGMMethodsByNameAndArguments(testLoader(), LIST_TYPE, 'each', [CLOSURE_TYPE] as ClassNode[])
+        assert !each.isEmpty()
+        assert each.every { MethodNode it -> it.name == 'each' }
+
+        def missing = findDGMMethodsByNameAndArguments(testLoader(), LIST_TYPE, 'definitelyNotADgmMethod', [CLOSURE_TYPE] as ClassNode[])
+        assert missing.isEmpty()
+    }
+
+    @Test
+    void 'preemptive names rebuild after the loader cache is cleared'() {
+        def loader = new GroovyClassLoader()
+        def before = ExtensionMethodCache.INSTANCE.getPreemptiveNames(loader)
+        assert before.contains('withDefault')
+        StaticTypeCheckingSupport.clearExtensionMethodCache(loader)
+        def after = ExtensionMethodCache.INSTANCE.getPreemptiveNames(loader)
+        assert after == before
+    }
+
+    @Test
+    void 'abstract extension method cache handles single-name buckets and diverse buckets with MethodsByName'() {
+        def singleNameCache = new AbstractExtensionMethodCache() {
+            @Override
+            protected void addAdditionalClassesToScan(Set<Class> instanceExtClasses, Set<Class> staticExtClasses) {
+                instanceExtClasses.add(DefaultGroovyMethods)
+            }
+
+            @Override
+            protected String getDisablePropertyName() {
+                'custom.extension.disable'
+            }
+
+            @Override
+            protected Predicate<MethodNode> getMethodFilter() {
+                { MethodNode m -> false }
+            }
+
+            @Override
+            protected Function<MethodNode, String> getMethodMapper() {
+                { MethodNode m -> m.name } // key is method name, so all methods under a key have the same name
+            }
+        }
+
+        def loader = testLoader()
+        def eachMethods = singleNameCache.get(loader).get('each')
+        assert eachMethods != null
+        assert !eachMethods.isEmpty()
+        assert eachMethods.every { it.name == 'each' }
+
+        // Named lookup via get(loader, key, name) uses MethodsByName fast-path
+        def found = singleNameCache.get(loader, 'each', 'each')
+        assert found.size() == eachMethods.size()
+        assert found == eachMethods
+
+        def missing = singleNameCache.get(loader, 'each', 'definitelyNotADgmMethod')
+        assert missing.isEmpty()
+    }
+}
diff --git a/src/test/groovy/org/codehaus/groovy/transform/stc/StaticTypeCheckingSupportTest.groovy b/src/test/groovy/org/codehaus/groovy/transform/stc/StaticTypeCheckingSupportTest.groovy
index f8e49e68bd..e9253a4692 100644
--- a/src/test/groovy/org/codehaus/groovy/transform/stc/StaticTypeCheckingSupportTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/transform/stc/StaticTypeCheckingSupportTest.groovy
@@ -19,6 +19,7 @@
 package org.codehaus.groovy.transform.stc
 
 import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.GenericsType
 import org.codehaus.groovy.ast.MethodNode
 import org.codehaus.groovy.ast.Parameter
 import org.junit.jupiter.api.Test
@@ -26,8 +27,11 @@ import org.junit.jupiter.api.Test
 import java.lang.reflect.Modifier
 
 import static org.codehaus.groovy.ast.ClassHelper.Integer_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.LIST_TYPE
 import static org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE
 import static org.codehaus.groovy.ast.ClassHelper.STRING_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.VOID_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.makeWithoutCaching
 import static org.codehaus.groovy.transform.stc.StaticTypeCheckingSupport.chooseBestMethod
 
 final class StaticTypeCheckingSupportTest {
@@ -54,4 +58,171 @@ final class StaticTypeCheckingSupportTest {
             assert candidates == [am, bm]
         }
     }
+
+    @Test
+    void testChooseBestMethodDoesNotMutateNonGenericParameters() {
+        ClassNode owner = new ClassNode('C', Modifier.PUBLIC, OBJECT_TYPE)
+        MethodNode exact = method(owner, 'm', STRING_TYPE, new Parameter(STRING_TYPE, 's'))
+        MethodNode fallback = method(owner, 'm', OBJECT_TYPE, new Parameter(OBJECT_TYPE, 's'))
+        Parameter[] exactBefore = exact.parameters
+        Parameter exactParamBefore = exactBefore[0]
+        ClassNode exactTypeBefore = exactParamBefore.type
+        ClassNode exactOriginBefore = exactParamBefore.originType
+
+        Parameter[] fallbackBefore = fallback.parameters
+        Parameter fallbackParamBefore = fallbackBefore[0]
+        ClassNode fallbackTypeBefore = fallbackParamBefore.type
+        ClassNode fallbackOriginBefore = fallbackParamBefore.originType
+
+        def chosen = chooseBestMethod(owner, [exact, fallback], STRING_TYPE)
+
+        assert chosen == [exact]
+        // Array references must remain identical
+        assert exact.parameters.is(exactBefore)
+        assert fallback.parameters.is(fallbackBefore)
+        // Parameter instances within the array must remain identical and unmutated
+        assert exact.parameters[0].is(exactParamBefore)
+        assert exact.parameters[0].type.is(exactTypeBefore)
+        assert exact.parameters[0].originType.is(exactOriginBefore)
+        assert exact.parameters[0].name == 's'
+
+        assert fallback.parameters[0].is(fallbackParamBefore)
+        assert fallback.parameters[0].type.is(fallbackTypeBefore)
+        assert fallback.parameters[0].originType.is(fallbackOriginBefore)
+        assert fallback.parameters[0].name == 's'
+    }
+
+    @Test
+    void testParameterElementsAreNeverMutatedDuringResolution() {
+        ClassNode owner = new ClassNode('C', Modifier.PUBLIC, OBJECT_TYPE)
+        ClassNode listOfString = parameterized(LIST_TYPE, STRING_TYPE)
+
+        MethodNode m1 = method(owner, 'dispatch', VOID_TYPE,
+                new Parameter(STRING_TYPE, 's'),
+                new Parameter(Integer_TYPE, 'i'),
+                new Parameter(OBJECT_TYPE, 'o'))
+        MethodNode m2 = method(owner, 'dispatch', VOID_TYPE,
+                new Parameter(STRING_TYPE, 's'),
+                new Parameter(listOfString, 'list'),
+                new Parameter(OBJECT_TYPE.makeArray(), 'vargs'))
+
+        Parameter[] m1Params = m1.parameters
+        Parameter[] m1ParamCopies = m1Params.clone()
+        ClassNode[] m1Types = m1Params.collect { it.type } as ClassNode[]
+        ClassNode[] m1OriginTypes = m1Params.collect { it.originType } as ClassNode[]
+
+        Parameter[] m2Params = m2.parameters
+        Parameter[] m2ParamCopies = m2Params.clone()
+        ClassNode[] m2Types = m2Params.collect { it.type } as ClassNode[]
+        ClassNode[] m2OriginTypes = m2Params.collect { it.originType } as ClassNode[]
+
+        // Perform multiple resolutions (matching m1, matching m2, and ambiguous/mismatched calls)
+        chooseBestMethod(owner, [m1, m2], STRING_TYPE, Integer_TYPE, OBJECT_TYPE)
+        chooseBestMethod(owner, [m1, m2], STRING_TYPE, LIST_TYPE, OBJECT_TYPE.makeArray())
+        chooseBestMethod(owner, [m1, m2], STRING_TYPE, STRING_TYPE, OBJECT_TYPE)
+
+        // Verify m1 parameter array and all parameter elements
+        assert m1.parameters.is(m1Params)
+        for (int i = 0; i < m1Params.length; i += 1) {
+            assert m1.parameters[i].is(m1ParamCopies[i])
+            assert m1.parameters[i].type.is(m1Types[i])
+            assert m1.parameters[i].originType.is(m1OriginTypes[i])
+        }
+
+        // Verify m2 parameter array and all parameter elements
+        assert m2.parameters.is(m2Params)
+        for (int i = 0; i < m2Params.length; i += 1) {
+            assert m2.parameters[i].is(m2ParamCopies[i])
+            assert m2.parameters[i].type.is(m2Types[i])
+            assert m2.parameters[i].originType.is(m2OriginTypes[i])
+        }
+    }
+
+    @Test
+    void testChooseBestMethodErasesGenericParametersWithoutMutatingTheMethod() {
+        ClassNode owner = new ClassNode('C', Modifier.PUBLIC, OBJECT_TYPE)
+        ClassNode listOfString = parameterized(LIST_TYPE, STRING_TYPE)
+        MethodNode generic = method(owner, 'm', VOID_TYPE, new Parameter(listOfString, 'xs'))
+        MethodNode fallback = method(owner, 'm', VOID_TYPE, new Parameter(OBJECT_TYPE, 'xs'))
+        Parameter[] before = generic.parameters
+        ClassNode originBefore = before[0].originType
+        ClassNode typeBefore = before[0].type
+
+        def chosen = chooseBestMethod(owner, [generic, fallback], LIST_TYPE)
+
+        assert chosen == [generic]
+        assert generic.parameters.is(before)
+        assert before[0].originType.is(originBefore)
+        assert before[0].type.is(typeBefore)
+        assert before[0].type.isUsingGenerics()
+    }
+
+    @Test
+    void testChooseBestMethodErasesMixedAndPlaceholderParameters() {
+        ClassNode owner = new ClassNode('C', Modifier.PUBLIC, OBJECT_TYPE)
+        ClassNode t = placeholder('T')
+        MethodNode mixed = method(owner, 'm', VOID_TYPE,
+                new Parameter(STRING_TYPE, 's'),
+                new Parameter(parameterized(LIST_TYPE, STRING_TYPE), 'xs'))
+        MethodNode id = method(owner, 'id', t, new Parameter(t, 'x'))
+        Parameter[] mixedBefore = mixed.parameters
+        Parameter[] idBefore = id.parameters
+
+        def chosenMixed = chooseBestMethod(owner, [mixed], STRING_TYPE, LIST_TYPE)
+        def chosenId = chooseBestMethod(owner, [id], STRING_TYPE)
+
+        assert chosenMixed == [mixed]
+        assert chosenId == [id]
+        assert mixed.parameters.is(mixedBefore)
+        assert mixedBefore[0].type.is(STRING_TYPE)
+        assert mixedBefore[1].type.isUsingGenerics()
+        assert id.parameters.is(idBefore)
+        assert idBefore[0].originType.isGenericsPlaceHolder()
+    }
+
+    @Test
+    void testChooseBestMethodErasesGenericArrayAndVarargsParameters() {
+        ClassNode owner = new ClassNode('C', Modifier.PUBLIC, OBJECT_TYPE)
+        ClassNode listOfString = parameterized(LIST_TYPE, STRING_TYPE)
+        MethodNode arrayParam = method(owner, 'm', VOID_TYPE, new Parameter(listOfString.makeArray(), 'xss'))
+        MethodNode vargs = method(owner, 'v', VOID_TYPE, new Parameter(listOfString.makeArray(), 'xs'))
+        Parameter[] arrayBefore = arrayParam.parameters
+        Parameter[] vargsBefore = vargs.parameters
+
+        def chosenArray = chooseBestMethod(owner, [arrayParam], LIST_TYPE.makeArray())
+        def chosenVargs = chooseBestMethod(owner, [vargs], LIST_TYPE)
+
+        assert chosenArray == [arrayParam]
+        assert chosenVargs == [vargs]
+        assert arrayParam.parameters.is(arrayBefore)
+        assert vargs.parameters.is(vargsBefore)
+        assert arrayBefore[0].type.isArray()
+        assert arrayBefore[0].type.componentType.isUsingGenerics()
+    }
+
+    @Test
+    void testChooseBestMethodEmptyAndNullCandidates() {
+        assert chooseBestMethod(OBJECT_TYPE, [], STRING_TYPE).isEmpty()
+        assert chooseBestMethod(OBJECT_TYPE, null, STRING_TYPE).isEmpty()
+    }
+
+    private static MethodNode method(ClassNode owner, String name, ClassNode returnType, Parameter... params) {
+        owner.addMethod(name, Modifier.PUBLIC, returnType, params, ClassNode.EMPTY_ARRAY, null)
+    }
+
+    private static ClassNode parameterized(ClassNode raw, ClassNode... typeArgs) {
+        ClassNode cn = raw.getPlainNodeReference()
+        cn.usingGenerics = true
+        cn.genericsTypes = typeArgs.collect { it.asGenericsType() } as GenericsType[]
+        return cn
+    }
+
+    private static ClassNode placeholder(String name) {
+        ClassNode t = makeWithoutCaching(name)
+        t.setRedirect(OBJECT_TYPE)
+        t.genericsPlaceHolder = true
+        t.usingGenerics = true
+        t.genericsTypes = [t.asGenericsType()] as GenericsType[]
+        return t
+    }
 }
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/ChooseBestMethodBench.java b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/ChooseBestMethodBench.java
new file mode 100644
index 0000000000..2a490fab3f
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/ChooseBestMethodBench.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.perf;
+
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.GenericsType;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
+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.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static org.codehaus.groovy.ast.ClassHelper.Boolean_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.Double_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.Integer_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.LIST_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.Long_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.MAP_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.SET_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.STRING_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.VOID_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.makeWithoutCaching;
+import static org.codehaus.groovy.transform.stc.StaticTypeCheckingSupport.chooseBestMethod;
+
+/**
+ * Microbenchmark measuring chooseBestMethod distance computation and overload resolution.
+ * Isolates the performance and GC allocation impact of eliminating Parameter[] clones
+ * on non-generic parameter lists.
+ */
+@Warmup(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Benchmark)
+public class ChooseBestMethodBench {
+
+    private ClassNode owner;
+    private List<MethodNode> nonGenericOverloads;
+    private List<MethodNode> genericOverloads;
+    private List<MethodNode> mixedOverloads;
+    private List<MethodNode> singleNonGeneric;
+    private List<MethodNode> heavyOverloads;
+
+    private ClassNode[] nonGenericArgs;
+    private ClassNode[] genericArgs;
+    private ClassNode[] heavyArgs;
+
+    @Setup
+    public void setup() {
+        owner = new ClassNode("TestService", Modifier.PUBLIC, OBJECT_TYPE);
+
+        // 1. Non-generic overloads (4 candidates, concrete types)
+        nonGenericOverloads = new ArrayList<>();
+        nonGenericOverloads.add(method(owner, "process", VOID_TYPE, new Parameter(STRING_TYPE, "s"), new Parameter(Integer_TYPE, "i")));
+        nonGenericOverloads.add(method(owner, "process", VOID_TYPE, new Parameter(STRING_TYPE, "s"), new Parameter(Long_TYPE, "l")));
+        nonGenericOverloads.add(method(owner, "process", VOID_TYPE, new Parameter(STRING_TYPE, "s"), new Parameter(Double_TYPE, "d")));
+        nonGenericOverloads.add(method(owner, "process", VOID_TYPE, new Parameter(STRING_TYPE, "s"), new Parameter(OBJECT_TYPE, "o")));
+
+        // 2. Generic overloads (2 candidates with type parameters)
+        ClassNode t = placeholder("T");
+        ClassNode listOfT = parameterized(LIST_TYPE, t);
+        ClassNode setOfT = parameterized(SET_TYPE, t);
+        genericOverloads = new ArrayList<>();
+        genericOverloads.add(method(owner, "handle", VOID_TYPE, new Parameter(listOfT, "items"), new Parameter(t, "sample")));
+        genericOverloads.add(method(owner, "handle", VOID_TYPE, new Parameter(setOfT, "items"), new Parameter(t, "sample")));
+
+        // 3. Mixed overloads (2 non-generic + 2 generic)
+        ClassNode k = placeholder("K");
+        ClassNode v = placeholder("V");
+        ClassNode mapOfKV = parameterized(MAP_TYPE, k, v);
+        mixedOverloads = new ArrayList<>();
+        mixedOverloads.add(method(owner, "transform", VOID_TYPE, new Parameter(STRING_TYPE, "s"), new Parameter(OBJECT_TYPE, "o")));
+        mixedOverloads.add(method(owner, "transform", VOID_TYPE, new Parameter(OBJECT_TYPE, "a"), new Parameter(OBJECT_TYPE, "b")));
+        mixedOverloads.add(method(owner, "transform", VOID_TYPE, new Parameter(listOfT, "list"), new Parameter(t, "val")));
+        mixedOverloads.add(method(owner, "transform", VOID_TYPE, new Parameter(mapOfKV, "map"), new Parameter(k, "key")));
+
+        // 4. Single non-generic method
+        singleNonGeneric = new ArrayList<>();
+        singleNonGeneric.add(method(owner, "execute", VOID_TYPE, new Parameter(STRING_TYPE, "s"), new Parameter(Integer_TYPE, "i")));
+
+        // 5. Heavy non-generic overloads (8 candidates with 3 parameters)
+        heavyOverloads = new ArrayList<>();
+        heavyOverloads.add(method(owner, "dispatch", VOID_TYPE, new Parameter(STRING_TYPE, "s"), new Parameter(Integer_TYPE, "i"), new Parameter(Boolean_TYPE, "b")));
+        heavyOverloads.add(method(owner, "dispatch", VOID_TYPE, new Parameter(STRING_TYPE, "s"), new Parameter(Long_TYPE, "l"), new Parameter(Boolean_TYPE, "b")));
+        heavyOverloads.add(method(owner, "dispatch", VOID_TYPE, new Parameter(STRING_TYPE, "s"), new Parameter(Double_TYPE, "d"), new Parameter(Boolean_TYPE, "b")));
+        heavyOverloads.add(method(owner, "dispatch", VOID_TYPE, new Parameter(STRING_TYPE, "s"), new Parameter(OBJECT_TYPE, "o"), new Parameter(Boolean_TYPE, "b")));
+        heavyOverloads.add(method(owner, "dispatch", VOID_TYPE, new Parameter(OBJECT_TYPE, "s"), new Parameter(Integer_TYPE, "i"), new Parameter(Boolean_TYPE, "b")));
+        heavyOverloads.add(method(owner, "dispatch", VOID_TYPE, new Parameter(OBJECT_TYPE, "s"), new Parameter(Long_TYPE, "l"), new Parameter(Boolean_TYPE, "b")));
+        heavyOverloads.add(method(owner, "dispatch", VOID_TYPE, new Parameter(OBJECT_TYPE, "s"), new Parameter(Double_TYPE, "d"), new Parameter(Boolean_TYPE, "b")));
+        heavyOverloads.add(method(owner, "dispatch", VOID_TYPE, new Parameter(OBJECT_TYPE, "s"), new Parameter(OBJECT_TYPE, "o"), new Parameter(OBJECT_TYPE, "b")));
+
+        nonGenericArgs = new ClassNode[]{STRING_TYPE, Integer_TYPE};
+        genericArgs = new ClassNode[]{LIST_TYPE, STRING_TYPE};
+        heavyArgs = new ClassNode[]{STRING_TYPE, Integer_TYPE, Boolean_TYPE};
+
+        // Warm up invocation
+        chooseBestMethod(owner, nonGenericOverloads, nonGenericArgs);
+    }
+
+    @Benchmark
+    public List<MethodNode> chooseNonGenericOverloads() {
+        return chooseBestMethod(owner, nonGenericOverloads, nonGenericArgs);
+    }
+
+    @Benchmark
+    public List<MethodNode> chooseSingleNonGeneric() {
+        return chooseBestMethod(owner, singleNonGeneric, nonGenericArgs);
+    }
+
+    @Benchmark
+    public List<MethodNode> chooseMixedOverloads() {
+        return chooseBestMethod(owner, mixedOverloads, genericArgs);
+    }
+
+    @Benchmark
+    public List<MethodNode> chooseGenericOverloads() {
+        return chooseBestMethod(owner, genericOverloads, genericArgs);
+    }
+
+    @Benchmark
+    public List<MethodNode> chooseHeavyNonGenericOverloads() {
+        return chooseBestMethod(owner, heavyOverloads, heavyArgs);
+    }
+
+    private static MethodNode method(ClassNode owner, String name, ClassNode returnType, Parameter... params) {
+        MethodNode mn = new MethodNode(name, Modifier.PUBLIC, returnType, params, ClassNode.EMPTY_ARRAY, null);
+        mn.setDeclaringClass(owner);
+        return mn;
+    }
+
+    private static ClassNode parameterized(ClassNode raw, ClassNode... typeArgs) {
+        ClassNode cn = raw.getPlainNodeReference();
+        cn.setUsingGenerics(true);
+        GenericsType[] gt = new GenericsType[typeArgs.length];
+        for (int i = 0; i < typeArgs.length; i++) {
+            gt[i] = new GenericsType(typeArgs[i]);
+        }
+        cn.setGenericsTypes(gt);
+        return cn;
+    }
+
+    private static ClassNode placeholder(String name) {
+        ClassNode t = makeWithoutCaching(name);
+        t.setRedirect(OBJECT_TYPE);
+        t.setGenericsPlaceHolder(true);
+        t.setUsingGenerics(true);
+        t.setGenericsTypes(new GenericsType[]{new GenericsType(t)});
+        return t;
+    }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/DgmMethodLookupBench.java b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/DgmMethodLookupBench.java
new file mode 100644
index 0000000000..8d254c0058
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/DgmMethodLookupBench.java
@@ -0,0 +1,122 @@
+/*
+ *  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.perf;
+
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.transform.stc.StaticTypeCheckingSupport;
+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.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import static org.codehaus.groovy.ast.ClassHelper.CLOSURE_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.LIST_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.MAP_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.STRING_TYPE;
+import static org.codehaus.groovy.ast.ClassHelper.int_TYPE;
+
+/**
+ * Microbenchmark for DGM extension method lookup across various receiver types and methods.
+ * Measures the algorithmic speedup of name-indexed cache lookup vs linear scanning.
+ */
+@Warmup(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Benchmark)
+public class DgmMethodLookupBench {
+
+    private ClassLoader loader;
+    private ClassNode[] closureArgs;
+    private ClassNode stringArrayType;
+    private ClassNode intArrayType;
+
+    @Setup
+    public void setup() {
+        loader = getClass().getClassLoader();
+        closureArgs = new ClassNode[]{CLOSURE_TYPE};
+        stringArrayType = STRING_TYPE.makeArray();
+        intArrayType = int_TYPE.makeArray();
+        // Warm up the extension method cache
+        StaticTypeCheckingSupport.findDGMMethodsForClassNode(loader, OBJECT_TYPE, "with");
+    }
+
+    @Benchmark
+    public Set<MethodNode> lookupObjectHit() {
+        return StaticTypeCheckingSupport.findDGMMethodsForClassNode(loader, OBJECT_TYPE, "with");
+    }
+
+    @Benchmark
+    public Set<MethodNode> lookupObjectMiss() {
+        return StaticTypeCheckingSupport.findDGMMethodsForClassNode(loader, OBJECT_TYPE, "definitelyNotADgmMethod");
+    }
+
+    @Benchmark
+    public Set<MethodNode> lookupListHit() {
+        return StaticTypeCheckingSupport.findDGMMethodsForClassNode(loader, LIST_TYPE, "each");
+    }
+
+    @Benchmark
+    public Set<MethodNode> lookupListMiss() {
+        return StaticTypeCheckingSupport.findDGMMethodsForClassNode(loader, LIST_TYPE, "definitelyNotADgmMethod");
+    }
+
+    @Benchmark
+    public Set<MethodNode> lookupStringHit() {
+        return StaticTypeCheckingSupport.findDGMMethodsForClassNode(loader, STRING_TYPE, "padLeft");
+    }
+
+    @Benchmark
+    public Set<MethodNode> lookupStringMiss() {
+        return StaticTypeCheckingSupport.findDGMMethodsForClassNode(loader, STRING_TYPE, "definitelyNotADgmMethod");
+    }
+
+    @Benchmark
+    public Set<MethodNode> lookupMapHit() {
+        return StaticTypeCheckingSupport.findDGMMethodsForClassNode(loader, MAP_TYPE, "collect");
+    }
+
+    @Benchmark
+    public Set<MethodNode> lookupArrayHit() {
+        return StaticTypeCheckingSupport.findDGMMethodsForClassNode(loader, stringArrayType, "getAt");
+    }
+
+    @Benchmark
+    public Set<MethodNode> lookupPrimitiveArrayHit() {
+        return StaticTypeCheckingSupport.findDGMMethodsForClassNode(loader, intArrayType, "getAt");
+    }
+
+    @Benchmark
+    public List<MethodNode> lookupByNameAndArgs() {
+        return StaticTypeCheckingSupport.findDGMMethodsByNameAndArguments(loader, LIST_TYPE, "each", closureArgs);
+    }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/StcCompilePerfBench.java b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/StcCompilePerfBench.java
new file mode 100644
index 0000000000..bc07392d68
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/StcCompilePerfBench.java
@@ -0,0 +1,179 @@
+/*
+ *  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.perf;
+
+import groovy.transform.CompileStatic;
+import org.codehaus.groovy.control.CompilationUnit;
+import org.codehaus.groovy.control.CompilerConfiguration;
+import org.codehaus.groovy.control.Phases;
+import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+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.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Macrobenchmark measuring static type checking (STC) compilation throughput and latency
+ * under @CompileStatic across diverse extension method lookups and method overload resolutions.
+ */
+@Warmup(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@State(Scope.Benchmark)
+public class StcCompilePerfBench {
+
+    private static final int CLASSES = 50;
+    private static final int CALLS_PER_CLASS = 60;
+
+    @Param({"dgmDense", "overloadDense", "mixedDense"})
+    private String suite;
+
+    private Map<String, String> corpus;
+
+    @Setup(Level.Trial)
+    public void generateCorpus() {
+        corpus = new LinkedHashMap<>();
+        for (int c = 0; c < CLASSES; c += 1) {
+            corpus.put("StcGen" + c, sourceFor(c));
+        }
+    }
+
+    @Benchmark
+    public int compileStcCorpus() {
+        CompilerConfiguration config = new CompilerConfiguration();
+        config.addCompilationCustomizers(new ASTTransformationCustomizer(CompileStatic.class));
+        CompilationUnit unit = new CompilationUnit(config);
+        for (Map.Entry<String, String> source : corpus.entrySet()) {
+            unit.addSource(source.getKey() + ".groovy", source.getValue());
+        }
+        unit.compile(Phases.INSTRUCTION_SELECTION);
+        return unit.getAST().getModules().size();
+    }
+
+    private String sourceFor(final int index) {
+        switch (suite) {
+            case "dgmDense":      return dgmDenseClass(index);
+            case "overloadDense": return overloadDenseClass(index);
+            case "mixedDense":    return mixedDenseClass(index);
+            default: throw new IllegalArgumentException("Unknown suite: " + suite);
+        }
+    }
+
+    private String dgmDenseClass(final int index) {
+        String[] templates = {
+            "xs.each { sink(it) }",
+            "sink(xs.collect { it.length() })",
+            "sink(xs.findAll { it.length() > 0 })",
+            "sink(xs.find { it == 'a' })",
+            "sink(xs.join(','))",
+            "sink(xs.max())",
+            "sink(xs.min())",
+            "sink(xs.reverse())",
+            "sink(xs.groupBy { it.length() })",
+            "sink(xs.inject('') { acc, val -> acc + val })",
+            "sink(nums.sum())",
+            "sink(nums.findAll { it > 0 })",
+            "sink(nums.collect { it * 2 })",
+            "m.each { k, v -> sink(v) }",
+            "sink(m.collect { k, v -> k + v })",
+            "sink(m.findAll { k, v -> v > 0 })",
+            "sink(str.padLeft(10))",
+            "sink(str.padRight(10))"
+        };
+        StringBuilder body = new StringBuilder();
+        for (int i = 0; i < CALLS_PER_CLASS; i += 1) {
+            body.append("        ").append(templates[i % templates.length]).append('\n');
+        }
+        return "class StcGen" + index + " {\n"
+             + "    void sink(Object o) {}\n"
+             + "    void run(List<String> xs, Map<String, Integer> m, List<Integer> nums, String str) {\n"
+             + body
+             + "    }\n"
+             + "}\n";
+    }
+
+    private String overloadDenseClass(final int index) {
+        String[] templates = {
+            "sink(dispatch('a', 1, true))",
+            "sink(dispatch('a', 2L, false))",
+            "sink(dispatch('a', 3.0d, true))",
+            "sink(dispatch('a', new Object(), false))",
+            "sink(dispatch(1, 'a', true))",
+            "sink(dispatch(2L, 'a', false))",
+            "sink(dispatch(3.0d, 'a', true))"
+        };
+        StringBuilder body = new StringBuilder();
+        for (int i = 0; i < CALLS_PER_CLASS; i += 1) {
+            body.append("        ").append(templates[i % templates.length]).append('\n');
+        }
+        return "class StcGen" + index + " {\n"
+             + "    void sink(Object o) {}\n"
+             + "    String dispatch(String s, Integer i, Boolean b) { s + i + b }\n"
+             + "    String dispatch(String s, Long l, Boolean b) { s + l + b }\n"
+             + "    String dispatch(String s, Double d, Boolean b) { s + d + b }\n"
+             + "    String dispatch(String s, Object o, Boolean b) { s + o + b }\n"
+             + "    String dispatch(Integer i, String s, Boolean b) { i + s + b }\n"
+             + "    String dispatch(Long l, String s, Boolean b) { l + s + b }\n"
+             + "    String dispatch(Double d, String s, Boolean b) { d + s + b }\n"
+             + "    void run() {\n"
+             + body
+             + "    }\n"
+             + "}\n";
+    }
+
+    private String mixedDenseClass(final int index) {
+        String[] templates = {
+            "xs.each { sink(dispatch(it, 1, true)) }",
+            "sink(xs.collect { dispatch(it, 2L, false) })",
+            "sink(xs.findAll { it.length() > 0 })",
+            "sink(m.collect { k, v -> dispatch(k, v, true) })",
+            "sink(nums.sum())",
+            "sink(dispatch('str', nums.size(), false))",
+            "sink(str.padLeft(10))"
+        };
+        StringBuilder body = new StringBuilder();
+        for (int i = 0; i < CALLS_PER_CLASS; i += 1) {
+            body.append("        ").append(templates[i % templates.length]).append('\n');
+        }
+        return "class StcGen" + index + " {\n"
+             + "    void sink(Object o) {}\n"
+             + "    String dispatch(String s, Integer i, Boolean b) { s + i + b }\n"
+             + "    String dispatch(String s, Long l, Boolean b) { s + l + b }\n"
+             + "    String dispatch(String s, Double d, Boolean b) { s + d + b }\n"
+             + "    String dispatch(String s, Object o, Boolean b) { s + o + b }\n"
+             + "    void run(List<String> xs, Map<String, Integer> m, List<Integer> nums, String str) {\n"
+             + body
+             + "    }\n"
+             + "}\n";
+    }
+}