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 & 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";
+ }
+}