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

(groovy) 01/01: GROOVY-12325: Speed up CachedMethod.invoke with a generated JIT-constant trampoline

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

asf-gitbox-commits pushed a commit to branch GROOVY-12325
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit c17676b9e7e3625fdcf0bfdaa7bb1e993e641d47
Author: Daniel Sun <su...@apache.org>
AuthorDate: Wed Sep 2 02:43:05 2026 +0900

    GROOVY-12325: Speed up CachedMethod.invoke with a generated JIT-constant trampoline
    
    After groovy.cachedmethod.invoker.threshold hits (default 100), install a
    hidden nestmate that INVOKE*s the target (or invokeExacts a classData
    MethodHandle) instead of Method.invoke on the MOP path. Indy keeps the
    call-site Lookup and is not fed the trampoline.
---
 ARCHITECTURE.md                                    |  10 +
 .../internal/runtime/invoke/DirectInvoker.java     |  56 ++++
 .../internal/runtime/invoke/InvokerBytecode.java   | 232 +++++++++++++
 .../internal/runtime/invoke/InvokerFactory.java    | 337 +++++++++++++++++++
 .../internal/runtime/invoke/package-info.java      |  25 ++
 .../org/apache/groovy/util/HiddenClassDefiner.java | 118 +++++++
 .../codehaus/groovy/reflection/CachedMethod.java   |  90 +++++-
 .../runtime/invoke/InvokerFactoryTest.groovy       | 358 +++++++++++++++++++++
 .../groovy/util/HiddenClassDefinerTest.groovy      | 120 +++++++
 .../runtime/invoke/DirectInvokerSubjects.java      | 119 +++++++
 .../reflection/CachedMethodDirectInvokerTest.java  | 231 +++++++++++++
 .../groovy/bench/CachedMethodInvokerBench.java     | 154 +++++++++
 12 files changed, 1849 insertions(+), 1 deletion(-)

diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b9b6814ee4..88a2e755eb 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -284,6 +284,16 @@ above. Each bites contributors quickly if missed:
   reproduce Java/module access rules manually when a `Lookup` can
   perform the check. The cached member representation describes
   the member; the call site supplies the access context.
+- **JIT-constant invoke for MOP internals.** After a call target
+  is selected and stable, `CachedMethod.invoke` may install a
+  generated `DirectInvoker` (direct `INVOKE*` or a classData
+  `MethodHandle`) so the invoke is a JIT-constant call rather than
+  `Method.invoke`. This is the MOP "Groovy as caller" path; indy
+  continues to use the call-site `Lookup` and must not be fed the
+  trampoline. Kill switch:
+  `groovy.cachedmethod.invoker.disable`. Hit threshold:
+  `groovy.cachedmethod.invoker.threshold` (default 100). See
+  `org.apache.groovy.internal.runtime.invoke`.
 
 ## Operator families
 
diff --git a/src/main/java/org/apache/groovy/internal/runtime/invoke/DirectInvoker.java b/src/main/java/org/apache/groovy/internal/runtime/invoke/DirectInvoker.java
new file mode 100644
index 0000000000..4b4eceed54
--- /dev/null
+++ b/src/main/java/org/apache/groovy/internal/runtime/invoke/DirectInvoker.java
@@ -0,0 +1,56 @@
+/*
+ *  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.internal.runtime.invoke;
+
+import groovy.transform.Internal;
+
+/**
+ * JIT-constant trampoline for a single {@link java.lang.reflect.Method}.
+ *
+ * <p>Not user API. Not an invokedynamic target. Valid for the {@code Method}
+ * lifetime, not across {@code MetaMethod} wrappers.
+ *
+ * <p>Public because generated nestmates of a foreign host (other package,
+ * other loader) must {@code implements} this interface. The containing
+ * {@code internal} package is japicmp-excluded.
+ *
+ * @since 6.0.0
+ */
+@Internal
+@FunctionalInterface
+public interface DirectInvoker {
+
+    /**
+     * Invokes the bound method on {@code receiver} with {@code arguments}.
+     *
+     * <p>Arguments are assumed already coerced (see
+     * {@code MetaMethod.doMethodInvoke}) except when a
+     * {@code TransformMetaMethod} skipped coercion. Generated trampolines
+     * require a non-null {@code arguments} array (use
+     * {@code MetaClassHelper.EMPTY_ARRAY} for no args);
+     * {@code CachedMethod.invoke} performs that substitution. Primitive
+     * returns are boxed; the caller runs {@code normalizeBoxedReturn}.
+     *
+     * @param receiver  the receiver, ignored for static methods
+     * @param arguments the positional arguments (non-null; empty if none)
+     * @return the boxed result, or {@code null} for {@code void}
+     * @throws Throwable the target's thrown exception, unwrapped
+     */
+    Object invoke(Object receiver, Object[] arguments) throws Throwable;
+}
diff --git a/src/main/java/org/apache/groovy/internal/runtime/invoke/InvokerBytecode.java b/src/main/java/org/apache/groovy/internal/runtime/invoke/InvokerBytecode.java
new file mode 100644
index 0000000000..c71fcce229
--- /dev/null
+++ b/src/main/java/org/apache/groovy/internal/runtime/invoke/InvokerBytecode.java
@@ -0,0 +1,232 @@
+/*
+ *  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.internal.runtime.invoke;
+
+import org.codehaus.groovy.classgen.asm.BytecodeHelper;
+import org.codehaus.groovy.classgen.asm.util.TypeUtil;
+import org.codehaus.groovy.control.CompilerConfiguration;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.ConstantDynamic;
+import org.objectweb.asm.Handle;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Emits a {@link DirectInvoker} class for one {@link Method}.
+ *
+ * <p>Two encodings, same public shape ({@code public final} class, public
+ * no-arg {@code <init>}, {@code invoke(Object, Object[])}):
+ * <ul>
+ *   <li>direct {@code INVOKE*} of the target (Steps 1, 2, 4)</li>
+ *   <li>{@code ConstantDynamic} classData {@code MethodHandle} +
+ *       {@code invokeExact} (Step 3)</li>
+ * </ul>
+ *
+ * Package-private. Dummy internal names live in this package so
+ * {@code HiddenClassDefiner.alignPackage} rewrites them onto the nest host.
+ */
+final class InvokerBytecode {
+
+    private static final String OBJECT = "java/lang/Object";
+    private static final String DIRECT_INVOKER = Type.getInternalName(DirectInvoker.class);
+    private static final String INVOKE_DESC = "(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;";
+    private static final String METHOD_HANDLE_DESC = "Ljava/lang/invoke/MethodHandle;";
+    private static final String METHOD_HANDLE_INTERNAL = "java/lang/invoke/MethodHandle";
+    private static final String THROWABLE = "java/lang/Throwable";
+
+    /**
+     * BSM for {@link java.lang.invoke.MethodHandles#classData} — already has
+     * the {@code ConstantDynamic} bootstrap signature, so {@code <clinit>} is
+     * not required and there is no checked {@code IllegalAccessException}.
+     */
+    private static final Handle CLASS_DATA_BSM = new Handle(
+            Opcodes.H_INVOKESTATIC,
+            "java/lang/invoke/MethodHandles",
+            "classData",
+            "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;",
+            false);
+
+    private static final AtomicInteger NAMES = new AtomicInteger();
+
+    private InvokerBytecode() {
+    }
+
+    /**
+     * Unique dummy internal name in this package. Hidden-class define rewrites
+     * the package to the nest host; the suffix keeps {@code javap} dumps readable.
+     */
+    static String nextInternalName() {
+        return DIRECT_INVOKER.substring(0, DIRECT_INVOKER.lastIndexOf('/') + 1)
+                + "MHInvoker$" + NAMES.getAndIncrement();
+    }
+
+    static byte[] emitInvokeStar(final Method method) {
+        return emitInvokeStar(method, nextInternalName());
+    }
+
+    static byte[] emitInvokeStar(final Method method, final String internalName) {
+        return emit(method, internalName, false);
+    }
+
+    static byte[] emitClassData(final Method method) {
+        return emit(method, nextInternalName(), true);
+    }
+
+    private static byte[] emit(final Method method, final String internalName, final boolean classData) {
+        final ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES);
+        cw.visit(
+                CompilerConfiguration.DEFAULT.getBytecodeVersion(),
+                Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL | Opcodes.ACC_SUPER | Opcodes.ACC_SYNTHETIC,
+                internalName,
+                null,
+                OBJECT,
+                new String[]{DIRECT_INVOKER});
+
+        emitConstructor(cw);
+        emitInvoke(cw, method, classData);
+
+        cw.visitEnd();
+        return cw.toByteArray();
+    }
+
+    private static void emitConstructor(final ClassWriter cw) {
+        final MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        mv.visitCode();
+        mv.visitVarInsn(Opcodes.ALOAD, 0);
+        mv.visitMethodInsn(Opcodes.INVOKESPECIAL, OBJECT, "<init>", "()V", false);
+        mv.visitInsn(Opcodes.RETURN);
+        mv.visitMaxs(0, 0);
+        mv.visitEnd();
+    }
+
+    private static void emitInvoke(final ClassWriter cw, final Method method, final boolean classData) {
+        final MethodVisitor mv = cw.visitMethod(
+                Opcodes.ACC_PUBLIC, "invoke", INVOKE_DESC, null, new String[]{THROWABLE});
+        mv.visitCode();
+
+        if (classData) {
+            mv.visitLdcInsn(new ConstantDynamic("_", METHOD_HANDLE_DESC, CLASS_DATA_BSM));
+            mv.visitTypeInsn(Opcodes.CHECKCAST, METHOD_HANDLE_INTERNAL);
+        }
+
+        loadReceiverAndArguments(mv, method);
+
+        if (classData) {
+            mv.visitMethodInsn(
+                    Opcodes.INVOKEVIRTUAL,
+                    METHOD_HANDLE_INTERNAL,
+                    "invokeExact",
+                    invokeExactDescriptor(method),
+                    false);
+        } else {
+            final Class<?> declaring = method.getDeclaringClass();
+            mv.visitMethodInsn(
+                    invokeOpcode(method),
+                    BytecodeHelper.getClassInternalName(declaring),
+                    method.getName(),
+                    BytecodeHelper.getMethodDescriptor(method.getReturnType(), method.getParameterTypes()),
+                    declaring.isInterface());
+        }
+
+        boxAndReturn(mv, method.getReturnType());
+        mv.visitMaxs(0, 0);
+        mv.visitEnd();
+    }
+
+    /**
+     * Receiver (instance only) then each argument, with {@link BytecodeHelper#doCast}.
+     * The Java wrapper already substitutes {@code EMPTY_ARRAY} for a null
+     * {@code arguments} local, so the bytecode assumes a non-null array.
+     */
+    private static void loadReceiverAndArguments(final MethodVisitor mv, final Method method) {
+        final boolean isStatic = Modifier.isStatic(method.getModifiers());
+        if (!isStatic) {
+            mv.visitVarInsn(Opcodes.ALOAD, 1);
+            BytecodeHelper.doCast(mv, method.getDeclaringClass());
+        }
+        final Class<?>[] params = method.getParameterTypes();
+        for (int i = 0; i < params.length; i++) {
+            mv.visitVarInsn(Opcodes.ALOAD, 2);
+            BytecodeHelper.pushConstant(mv, i);
+            mv.visitInsn(Opcodes.AALOAD);
+            BytecodeHelper.doCast(mv, params[i]);
+        }
+    }
+
+    static int invokeOpcode(final Method method) {
+        final int mods = method.getModifiers();
+        if (Modifier.isStatic(mods)) {
+            return Opcodes.INVOKESTATIC;
+        }
+        if (Modifier.isPrivate(mods)) {
+            // Hidden nestmates extend Object; they do not subclass / implement
+            // the declaring type. INVOKESPECIAL of a non-<init> method then
+            // fails verification ("current class isn't assignable to reference
+            // class"). JEP 371: use invokevirtual / invokeinterface instead.
+            // itf is still declaring.isInterface() at the call site.
+            if (method.getDeclaringClass().isInterface()) {
+                return Opcodes.INVOKEINTERFACE;
+            }
+            return Opcodes.INVOKEVIRTUAL;
+        }
+        if (method.getDeclaringClass().isInterface()) {
+            return Opcodes.INVOKEINTERFACE;
+        }
+        return Opcodes.INVOKEVIRTUAL;
+    }
+
+    /**
+     * {@code invokeExact} descriptor: instance prepends the declaring type;
+     * return is the method's (possibly primitive) type.
+     */
+    static String invokeExactDescriptor(final Method method) {
+        final StringBuilder sb = new StringBuilder(64);
+        sb.append('(');
+        if (!Modifier.isStatic(method.getModifiers())) {
+            sb.append(BytecodeHelper.getTypeDescription(method.getDeclaringClass()));
+        }
+        for (Class<?> p : method.getParameterTypes()) {
+            sb.append(BytecodeHelper.getTypeDescription(p));
+        }
+        sb.append(')');
+        sb.append(BytecodeHelper.getTypeDescription(method.getReturnType()));
+        return sb.toString();
+    }
+
+    private static void boxAndReturn(final MethodVisitor mv, final Class<?> returnType) {
+        if (returnType == void.class) {
+            mv.visitInsn(Opcodes.ACONST_NULL);
+        } else if (returnType.isPrimitive()) {
+            final Class<?> wrapper = TypeUtil.autoboxType(returnType);
+            mv.visitMethodInsn(
+                    Opcodes.INVOKESTATIC,
+                    BytecodeHelper.getClassInternalName(wrapper),
+                    "valueOf",
+                    "(" + BytecodeHelper.getTypeDescription(returnType) + ")"
+                            + BytecodeHelper.getTypeDescription(wrapper),
+                    false);
+        }
+        mv.visitInsn(Opcodes.ARETURN);
+    }
+}
diff --git a/src/main/java/org/apache/groovy/internal/runtime/invoke/InvokerFactory.java b/src/main/java/org/apache/groovy/internal/runtime/invoke/InvokerFactory.java
new file mode 100644
index 0000000000..260334fbc6
--- /dev/null
+++ b/src/main/java/org/apache/groovy/internal/runtime/invoke/InvokerFactory.java
@@ -0,0 +1,337 @@
+/*
+ *  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.internal.runtime.invoke;
+
+import groovy.transform.Internal;
+import org.apache.groovy.util.HiddenClassDefiner;
+import org.apache.groovy.util.SystemUtil;
+import org.codehaus.groovy.reflection.CachedMethod;
+import org.codehaus.groovy.reflection.ClassLoaderForClassArtifacts;
+import org.codehaus.groovy.reflection.android.AndroidSupport;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodHandles.Lookup;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+
+/**
+ * Factory for {@link DirectInvoker} instances bound to one {@link CachedMethod}.
+ *
+ * <p>Not user API. {@code CachedMethod.invoke} is the only production caller.
+ * Package-visible helpers exist so tests can drive generation without going
+ * through the hit-count hook.
+ *
+ * <p>Define path (one order):
+ * <ol>
+ *   <li>InvokerFactory nestmate + {@code INVOKE*} when the member is publicly
+ *       invocable from this class and every type is resolvable here
+ *       ({@code String.startsWith}).</li>
+ *   <li>Declaring-class nestmate + {@code INVOKE*} when
+ *       {@code privateLookupIn} is worth attempting and the host can resolve
+ *       {@link DirectInvoker}.</li>
+ *   <li>InvokerFactory nestmate + classData {@code MethodHandle} when this
+ *       loader can resolve the erased {@code invokeExact} types.</li>
+ *   <li>{@link ClassLoaderForClassArtifacts} visible class, only when that
+ *       loader can resolve {@link DirectInvoker} — never for bootstrap hosts.</li>
+ * </ol>
+ *
+ * Failures sticky-return {@code null}; they must not propagate to
+ * {@code CachedMethod.invoke}.
+ *
+ * @since 6.0.0
+ */
+@Internal
+public final class InvokerFactory {
+
+    /**
+     * Hits before generation. Default 100 — below
+     * {@code groovy.indy.optimize.threshold} (1000) so cold indy still sits on
+     * {@code doMethodInvoke} when the trampoline appears.
+     */
+    public static final String PROPERTY_THRESHOLD = "groovy.cachedmethod.invoker.threshold";
+
+    /** Kill switch. Default {@code false}. */
+    public static final String PROPERTY_DISABLE = "groovy.cachedmethod.invoker.disable";
+
+    /**
+     * Full-privilege lookup for <em>this</em> class — production nest host for
+     * Steps 1 and 3. Caller-sensitive: must be captured here, not in
+     * {@link HiddenClassDefiner}.
+     */
+    static final Lookup LOOKUP = MethodHandles.lookup();
+
+    private InvokerFactory() {
+    }
+
+    /**
+     * Attempts to bind a trampoline for {@code method}. Returns {@code null}
+     * on any failure (sticky-fail). Public so {@code CachedMethod} in another
+     * package can call it; not user API ({@link Internal}, {@code internal}
+     * package). Tests in this package also drive generation through this
+     * method without going through the hit-count hook.
+     *
+     * @param method the cached method to bind
+     * @return a trampoline, or {@code null}
+     */
+    public static DirectInvoker tryCreate(final CachedMethod method) {
+        if (method == null || !generationAllowed()) {
+            return null;
+        }
+        if (method.isCallerSensitive() || Modifier.isAbstract(method.getModifiers())) {
+            return null;
+        }
+        try {
+            final Method m = method.getCachedMethod();
+            final Class<?> declaring = m.getDeclaringClass();
+            final Class<?>[] params = m.getParameterTypes();
+            final Class<?> returnType = m.getReturnType();
+
+            // Step 1: InvokerFactory nestmate + INVOKE* (String.startsWith).
+            if (isPubliclyInvocableFromInvokerFactory(method)
+                    && canResolveInvokeTypes(LOOKUP.lookupClass(), declaring, params, returnType)) {
+                final DirectInvoker step1 = defineInvokeStarOnLookup(m, LOOKUP);
+                if (step1 != null) {
+                    return step1;
+                }
+            }
+
+            // Step 2: declaring-class nestmate + INVOKE*.
+            if (HiddenClassDefiner.canAttemptPrivateLookup(declaring)
+                    && loaderCanResolve(declaring, DirectInvoker.class)) {
+                final DirectInvoker step2 = defineInvokeStarOnDeclaringClass(m, declaring);
+                if (step2 != null) {
+                    return step2;
+                }
+            }
+
+            // Step 3: InvokerFactory nestmate + classData MH.
+            if (canResolveInvokeTypes(LOOKUP.lookupClass(), declaring, params, returnType)) {
+                final DirectInvoker step3 = tryCreateClassData(method);
+                if (step3 != null) {
+                    return step3;
+                }
+            }
+
+            // Step 4: visible artifact, never for bootstrap hosts.
+            if (declaring.getClassLoader() != null
+                    && loaderCanResolve(declaring, DirectInvoker.class)
+                    && isPubliclyInvocableFromInvokerFactory(method)) {
+                return defineVisibleArtifact(method, m);
+            }
+            return null;
+        } catch (Exception ignored) {
+            // Sticky-fail expected define/link/access problems. Do not swallow
+            // Error (OOME / SOE): those must not permanently disable this method.
+            return null;
+        }
+    }
+
+    /**
+     * Package-visible predicate. Android cannot be flipped with a property
+     * ({@code AndroidSupport.isRunningAndroid()} is {@code Class.forName(
+     * "android.app.Activity")} captured at class-init). Native image is
+     * {@link HiddenClassDefiner#isEnabled()} — do <em>not</em> also gate on
+     * {@code AotDispatch.isAotLinkRequested()}, which is true on HotSpot when
+     * {@code -Dgroovy.indy.aot.link=true} and would silently disable generation
+     * in AOT-dispatch unit tests.
+     *
+     * @return {@code true} when generation may be attempted
+     */
+    public static boolean generationAllowed() {
+        if (SystemUtil.getBooleanSafe(PROPERTY_DISABLE, false)) {
+            return false;
+        }
+        if (!HiddenClassDefiner.isEnabled()) {
+            return false;
+        }
+        return !AndroidSupport.isRunningAndroid();
+    }
+
+    /**
+     * Non-nestmate {@code INVOKE*} gate (Step 1 / Step 4). Same public-type
+     * test as {@code CallSiteGenerator.isCompilable} minus Android / name-char
+     * bits (those live on {@link #generationAllowed()} / hidden names).
+     *
+     * @param method the candidate
+     * @return {@code true} when an InvokerFactory nestmate can legally
+     *         {@code INVOKE*} the member
+     */
+    static boolean isPubliclyInvocableFromInvokerFactory(final CachedMethod method) {
+        final Class<?> declaring = method.getDeclaringClass().getTheClass();
+        if (!Modifier.isPublic(declaring.getModifiers())) {
+            return false;
+        }
+        if (!method.isPublic()) {
+            return false;
+        }
+        for (Class<?> p : method.getNativeParameterTypes()) {
+            if (!p.isPrimitive() && !Modifier.isPublic(p.getModifiers())) {
+                return false;
+            }
+        }
+        final Class<?> ret = method.getReturnType();
+        return ret.isPrimitive() || Modifier.isPublic(ret.getModifiers());
+    }
+
+    /**
+     * Loader visibility copied from {@code ProxyClassDefiner.loaderCanResolve}:
+     * child loaders see parents; parents do not see children. Bootstrap types
+     * ({@code getClassLoader() == null}) are visible to everyone.
+     *
+     * @param host the class whose loader is asked to resolve {@code type}
+     * @param type the type named by the bytecode
+     * @return {@code true} when {@code host}'s loader can resolve {@code type}
+     */
+    static boolean loaderCanResolve(final Class<?> host, final Class<?> type) {
+        if (type == null || type.isPrimitive()) {
+            return true;
+        }
+        final ClassLoader defining = type.getClassLoader();
+        if (defining == null) {
+            return true;
+        }
+        if (host == null) {
+            return false;
+        }
+        for (ClassLoader cl = host.getClassLoader(); cl != null; cl = cl.getParent()) {
+            if (cl == defining) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Step 3 encoding, package-visible so tests can exercise classData without
+     * waiting for Steps 1–2 to fail.
+     *
+     * @param method the cached method
+     * @return a classData trampoline, or {@code null}
+     */
+    static DirectInvoker tryCreateClassData(final CachedMethod method) {
+        if (method == null || !generationAllowed()) {
+            return null;
+        }
+        try {
+            final Method m = method.getCachedMethod();
+            final MethodHandle mh = LOOKUP.unreflect(m);
+            final byte[] bytes = InvokerBytecode.emitClassData(m);
+            final Lookup hidden = HiddenClassDefiner.tryDefineNestmateWithClassData(
+                    LOOKUP, bytes, mh, true);
+            return instantiate(hidden);
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
+    // -------------------------------------------------------------------------
+
+    private static boolean canResolveInvokeTypes(
+            final Class<?> host,
+            final Class<?> declaring,
+            final Class<?>[] params,
+            final Class<?> returnType) {
+        if (!loaderCanResolve(host, declaring) || !loaderCanResolve(host, returnType)) {
+            return false;
+        }
+        for (Class<?> p : params) {
+            if (!loaderCanResolve(host, p)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Step 1: today's {@code tryDefineNestmate} returning {@code Class}, then
+     * public no-arg {@code getConstructor().newInstance()}.
+     */
+    private static DirectInvoker defineInvokeStarOnLookup(final Method method, final Lookup hostLookup) {
+        final byte[] bytes = InvokerBytecode.emitInvokeStar(method);
+        final Class<?> cls = HiddenClassDefiner.tryDefineNestmate(hostLookup, bytes, true);
+        return instantiate(cls);
+    }
+
+    /**
+     * Step 2: nestmate of the declaring class, not of InvokerFactory.
+     * {@code hostLookup = privateLookupIn(declaringClass, InvokerFactory.LOOKUP)}
+     * — passing {@code InvokerFactory.LOOKUP} as the <em>define</em> lookup
+     * would make the hidden class a nestmate of this factory and private
+     * {@code INVOKE*} would {@code IllegalAccessError}.
+     */
+    private static DirectInvoker defineInvokeStarOnDeclaringClass(
+            final Method method, final Class<?> declaring) {
+        try {
+            final Lookup hostLookup = MethodHandles.privateLookupIn(declaring, LOOKUP);
+            final byte[] bytes = InvokerBytecode.emitInvokeStar(method);
+            final Lookup hidden = HiddenClassDefiner.tryDefineNestmateLookup(hostLookup, bytes, true);
+            return instantiate(hidden);
+        } catch (IllegalAccessException | SecurityException e) {
+            return null;
+        }
+    }
+
+    private static DirectInvoker defineVisibleArtifact(final CachedMethod cached, final Method method) {
+        try {
+            final ClassLoaderForClassArtifacts loader =
+                    cached.cachedClass.classInfo.getArtifactClassLoader();
+            if (loader == null) {
+                return null;
+            }
+            final String binaryName = loader.createClassName(method.getName());
+            final byte[] bytes = InvokerBytecode.emitInvokeStar(method, binaryName.replace('.', '/'));
+            final Constructor<?> ctor = loader.defineClassAndGetConstructor(binaryName, bytes);
+            if (ctor == null) {
+                return null;
+            }
+            return (DirectInvoker) ctor.newInstance();
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
+    private static DirectInvoker instantiate(final Class<?> cls) {
+        if (cls == null) {
+            return null;
+        }
+        try {
+            return (DirectInvoker) cls.getConstructor().newInstance();
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
+    private static DirectInvoker instantiate(final Lookup hidden) {
+        if (hidden == null) {
+            return null;
+        }
+        try {
+            final MethodHandle ctor = hidden.findConstructor(
+                    hidden.lookupClass(), MethodType.methodType(void.class));
+            return (DirectInvoker) ctor.invoke();
+        } catch (Error e) {
+            throw e;
+        } catch (Throwable ignored) {
+            return null;
+        }
+    }
+}
diff --git a/src/main/java/org/apache/groovy/internal/runtime/invoke/package-info.java b/src/main/java/org/apache/groovy/internal/runtime/invoke/package-info.java
new file mode 100644
index 0000000000..76776d1e92
--- /dev/null
+++ b/src/main/java/org/apache/groovy/internal/runtime/invoke/package-info.java
@@ -0,0 +1,25 @@
+/*
+ *  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.
+ */
+
+/**
+ * Internal JIT-constant trampolines for MOP invoke ({@code CachedMethod.invoke}).
+ * Not user API. Japicmp-excluded by the {@code **internal**} package filter.
+ * Must not be used as an invokedynamic target (wrong {@code Lookup}).
+ */
+package org.apache.groovy.internal.runtime.invoke;
diff --git a/src/main/java/org/apache/groovy/util/HiddenClassDefiner.java b/src/main/java/org/apache/groovy/util/HiddenClassDefiner.java
index 4da39dd314..32738038d7 100644
--- a/src/main/java/org/apache/groovy/util/HiddenClassDefiner.java
+++ b/src/main/java/org/apache/groovy/util/HiddenClassDefiner.java
@@ -200,6 +200,87 @@ public final class HiddenClassDefiner {
         }
     }
 
+    /**
+     * Like {@link #tryDefineNestmate(Lookup, byte[], boolean)}, but returns the
+     * hidden {@link Lookup} so the caller can {@code findConstructor} / read
+     * classData without a public-constructor round-trip.
+     *
+     * <p>{@link ExceptionInInitializerError} and {@link NoClassDefFoundError}
+     * are sticky-failed as {@code null} (in addition to the usual soft-fail
+     * set). Needed from {@code InvokerFactory} in another package; not user API.
+     *
+     * @param lookup     full-privilege lookup for the nest host
+     * @param bytes      class-file bytes
+     * @param initialize {@code true} to run {@code <clinit>} immediately
+     * @return the hidden lookup, or {@code null} if definition is not possible
+     * @since 6.0.0
+     */
+    @Internal
+    public static Lookup tryDefineNestmateLookup(
+            final Lookup lookup,
+            final byte[] bytes,
+            final boolean initialize) {
+        return defineHidden(lookup, bytes, initialize, /*classData*/ null, false);
+    }
+
+    /**
+     * Foreign host: {@code privateLookupIn(host, LOOKUP)} then the
+     * Lookup-returning define. Nest host is {@code host}, not this utility.
+     *
+     * @param host       nest host and class-loader / package donor
+     * @param bytes      class-file bytes
+     * @param initialize {@code true} to run {@code <clinit>} immediately
+     * @return the hidden lookup, or {@code null} if private lookup or definition fails
+     * @since 6.0.0
+     */
+    @Internal
+    public static Lookup tryDefineNestmateLookup(
+            final Class<?> host,
+            final byte[] bytes,
+            final boolean initialize) {
+        if (!isEnabled() || !canAttemptPrivateLookup(host) || bytes == null) {
+            return null;
+        }
+        try {
+            final Lookup hostLookup = MethodHandles.privateLookupIn(host, LOOKUP);
+            return tryDefineNestmateLookup(hostLookup, bytes, initialize);
+        } catch (IllegalAccessException | SecurityException e) {
+            return null;
+        } catch (Error e) {
+            if (isUnsupportedFeatureError(e)) {
+                return null;
+            }
+            throw e;
+        }
+    }
+
+    /**
+     * Defines {@code bytes} as a hidden nestmate of {@code lookup.lookupClass()}
+     * with {@code classData} attached ({@link Lookup#defineHiddenClassWithClassData}).
+     * Returns the hidden {@link Lookup}. Initialization errors sticky-fail as
+     * {@code null}. Needed from {@code InvokerFactory} in another package; not
+     * user API.
+     *
+     * @param lookup     full-privilege lookup for the nest host
+     * @param bytes      class-file bytes
+     * @param classData  value retrieved by {@link MethodHandles#classData} /
+     *                   {@code ConstantDynamic} in the hidden class
+     * @param initialize {@code true} to run {@code <clinit>} immediately
+     * @return the hidden lookup, or {@code null} if definition is not possible
+     * @since 6.0.0
+     */
+    @Internal
+    public static Lookup tryDefineNestmateWithClassData(
+            final Lookup lookup,
+            final byte[] bytes,
+            final Object classData,
+            final boolean initialize) {
+        if (classData == null) {
+            return null;
+        }
+        return defineHidden(lookup, bytes, initialize, classData, true);
+    }
+
     /**
      * Internal policy: whether {@code privateLookupIn} from this utility into
      * {@code host} is worth attempting.
@@ -249,6 +330,43 @@ public final class HiddenClassDefiner {
         throw e;
     }
 
+    /**
+     * Shared hidden-class define. {@code withClassData == false} uses
+     * {@link Lookup#defineHiddenClass}; otherwise
+     * {@link Lookup#defineHiddenClassWithClassData}. {@link LinkageError}
+     * (including {@link ExceptionInInitializerError} and
+     * {@link NoClassDefFoundError} on current JDKs) sticky-fails as {@code null}.
+     */
+    private static Lookup defineHidden(
+            final Lookup lookup,
+            final byte[] bytes,
+            final boolean initialize,
+            final Object classData,
+            final boolean withClassData) {
+        if (!isEnabled() || lookup == null || bytes == null) {
+            return null;
+        }
+        try {
+            final byte[] aligned = alignPackage(bytes, lookup.lookupClass());
+            if (withClassData) {
+                return lookup.defineHiddenClassWithClassData(
+                        aligned, classData, initialize, NESTMATE_WEAK);
+            }
+            return lookup.defineHiddenClass(aligned, initialize, NESTMATE_WEAK);
+        } catch (IllegalAccessException | SecurityException | LinkageError e) {
+            // LinkageError includes NoClassDefFoundError and, on current JDKs,
+            // ExceptionInInitializerError — both sticky-fail as null.
+            return null;
+        } catch (IllegalArgumentException | IndexOutOfBoundsException e) {
+            return null;
+        } catch (Error e) {
+            if (e instanceof ExceptionInInitializerError || isUnsupportedFeatureError(e)) {
+                return null;
+            }
+            throw e;
+        }
+    }
+
     /**
      * Rewrites {@code this_class} (and internal references to it) into
      * {@code host}'s run-time package. No-op when already aligned.
diff --git a/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java b/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java
index 774d8d42ac..9d4075243f 100644
--- a/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java
+++ b/src/main/java/org/codehaus/groovy/reflection/CachedMethod.java
@@ -20,6 +20,9 @@ package org.codehaus.groovy.reflection;
 
 import groovy.lang.MetaMethod;
 import groovy.lang.MissingMethodException;
+import org.apache.groovy.internal.runtime.invoke.DirectInvoker;
+import org.apache.groovy.internal.runtime.invoke.InvokerFactory;
+import org.apache.groovy.util.SystemUtil;
 import org.codehaus.groovy.classgen.asm.BytecodeHelper;
 import org.codehaus.groovy.runtime.InvokerInvocationException;
 import org.codehaus.groovy.runtime.MetaClassHelper;
@@ -31,6 +34,7 @@ import java.io.ObjectOutputStream;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
 import java.util.Arrays;
 
 /**
@@ -82,6 +86,24 @@ public class CachedMethod extends MetaMethod implements Comparable {
     private boolean makeAccessibleDone;
     private CachedMethod transformedMethod;
 
+    /**
+     * Installed JIT-constant trampoline, or {@code null} if not yet generated
+     * / sticky-failed. Volatile: the fast path reads it outside the generation
+     * lock.
+     */
+    private transient volatile DirectInvoker invoker;
+    /**
+     * Racy hit counter. {@code long} to match
+     * {@code IndyInterface.INDY_OPTIMIZE_THRESHOLD}. Over-counting delays
+     * generation slightly; under-counting generates slightly early. Both harmless.
+     */
+    private transient long invokeHits;
+    /**
+     * Sticky failure: {@link InvokerFactory#tryCreate} returned {@code null}.
+     * Volatile because the fast path reads it outside the generation lock.
+     */
+    private transient volatile boolean invokerAttempted;
+
     /**
      * The JDK's {@code CallerSensitive} marker lives in {@code jdk.internal}
      * and is not a compile-time dependency. Resolved once; {@code null} means
@@ -377,23 +399,89 @@ public class CachedMethod extends MetaMethod implements Comparable {
      * Invokes this method on the given object with the specified arguments.
      * Handles accessibility, exception wrapping, and method invocation.
      *
+     * <p>After {@code groovy.cachedmethod.invoker.threshold} hits (default 100)
+     * a {@link DirectInvoker} may be installed so the call is a JIT-constant
+     * {@code INVOKE*} / classData {@code invokeExact} rather than
+     * {@link Method#invoke}. Caller-sensitive and abstract methods stay
+     * reflective. This is the MOP "Groovy as caller" path — the trampoline
+     * must not be used as an invokedynamic target.
+     *
      * @param object the target object (may be {@code null} for static methods)
      * @param arguments the arguments to pass to the method
      * @return the result of the method invocation
      * @throws InvokerInvocationException if the method raises a checked exception or illegal access occurs
      * @throws RuntimeException if the method raises an uncaught exception (except MissingMethodException)
+     * @throws ClassCastException on the generated path when an argument cannot
+     *         be {@code CHECKCAST} to the parameter type (the reflective path
+     *         wraps {@code IllegalArgumentException} in {@code InvokerInvocationException}
+     *         instead; same delta as DGM / {@code CallSiteGenerator})
      */
     @Override
     public final Object invoke(final Object object, final Object[] arguments) {
         makeAccessibleIfNecessary();
+        DirectInvoker di = invoker;
+        if (di != null) {
+            return invokeGenerated(di, object, arguments);
+        }
+        if (shouldAttemptGeneration()) {
+            synchronized (this) {
+                if (invoker == null && !invokerAttempted) {
+                    DirectInvoker created = InvokerFactory.tryCreate(this);
+                    if (created != null) {
+                        invoker = created;
+                    } else {
+                        invokerAttempted = true;
+                    }
+                }
+                di = invoker;
+            }
+            if (di != null) {
+                return invokeGenerated(di, object, arguments);
+            }
+        }
+        return invokeReflective(object, arguments);
+    }
+
+    private boolean shouldAttemptGeneration() {
+        if (invokerAttempted) {
+            return false;
+        }
+        if (!InvokerFactory.generationAllowed()) {
+            return false;
+        }
+        if (isCallerSensitive() || Modifier.isAbstract(getModifiers())) {
+            invokerAttempted = true;
+            return false;
+        }
+        long hits = ++invokeHits;
+        return hits > SystemUtil.getLongSafe(InvokerFactory.PROPERTY_THRESHOLD, 100L);
+    }
+
+    private Object invokeGenerated(final DirectInvoker di, final Object object, final Object[] arguments) {
+        try {
+            Object[] args = (arguments != null) ? arguments : MetaClassHelper.EMPTY_ARRAY;
+            return normalizeBoxedReturn(di.invoke(object, args));
+        } catch (Throwable t) {
+            // Match Method.invoke / ITE policy: RuntimeException other than
+            // MissingMethodException is rethrown; Error, checked, and MME become
+            // InvokerInvocationException (ITE used to wrap those as its cause).
+            if (t instanceof RuntimeException && !(t instanceof MissingMethodException)) {
+                throw (RuntimeException) t;
+            }
+            throw new InvokerInvocationException(t);
+        }
+    }
 
+    private Object invokeReflective(final Object object, final Object[] arguments) {
         try {
             return normalizeBoxedReturn(cachedMethod.invoke(object, arguments));
         } catch (IllegalArgumentException | IllegalAccessException e) {
             throw new InvokerInvocationException(e);
         } catch (InvocationTargetException e) {
             Throwable cause = e.getCause();
-            throw (cause instanceof RuntimeException && !(cause instanceof MissingMethodException)) ? (RuntimeException) cause : new InvokerInvocationException(e);
+            throw (cause instanceof RuntimeException && !(cause instanceof MissingMethodException))
+                    ? (RuntimeException) cause
+                    : new InvokerInvocationException(e);
         }
     }
 
diff --git a/src/test/groovy/org/apache/groovy/internal/runtime/invoke/InvokerFactoryTest.groovy b/src/test/groovy/org/apache/groovy/internal/runtime/invoke/InvokerFactoryTest.groovy
new file mode 100644
index 0000000000..503fff5431
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/internal/runtime/invoke/InvokerFactoryTest.groovy
@@ -0,0 +1,358 @@
+/*
+ *  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.internal.runtime.invoke
+
+import groovy.lang.GroovyClassLoader
+import org.apache.groovy.util.HiddenClassDefiner
+import org.codehaus.groovy.reflection.CachedMethod
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.parallel.ResourceLock
+import org.junit.jupiter.api.parallel.Resources
+import org.objectweb.asm.Opcodes
+
+import java.lang.reflect.Method
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertNotNull
+import static org.junit.jupiter.api.Assertions.assertNull
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertThrows
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Unit tests for {@link InvokerFactory} / {@link InvokerBytecode}.
+ *
+ * <p>Uses {@code new CachedMethod(method)} so generation does not install a
+ * trampoline on the interned MOP {@code CachedMethod}.
+ */
+final class InvokerFactoryTest {
+
+    private static CachedMethod cm(Method m) {
+        new CachedMethod(m)
+    }
+
+    private static DirectInvoker invoker(Method m) {
+        DirectInvoker di = InvokerFactory.tryCreate(cm(m))
+        assertNotNull(di, "expected DirectInvoker for ${m}")
+        di
+    }
+
+    // -------------------------------------------------------------------------
+    // Step 1 — InvokerFactory nestmate + INVOKE* (bootstrap public)
+    // -------------------------------------------------------------------------
+
+    @Test
+    void testStringStartsWithIsInvokerFactoryNestmate() {
+        DirectInvoker di = invoker(String.getMethod('startsWith', String))
+        assertEquals(Boolean.TRUE, di.invoke('abc', ['a'] as Object[]))
+        assertEquals(Boolean.FALSE, di.invoke('abc', ['z'] as Object[]))
+        assertSame(InvokerFactory, di.class.nestHost)
+        assertTrue(di.class.hidden)
+    }
+
+    @Test
+    void testIntegerParseIntStatic() {
+        DirectInvoker di = invoker(Integer.getMethod('parseInt', String))
+        assertEquals(Integer.valueOf(42), di.invoke(null, ['42'] as Object[]))
+        assertSame(InvokerFactory, di.class.nestHost)
+    }
+
+    @Test
+    void testPublicInstanceOnTestSubject() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.getMethod('ping'))
+        assertEquals('pong', di.invoke(new DirectInvokerSubjects(), null))
+        assertSame(InvokerFactory, di.class.nestHost)
+    }
+
+    @Test
+    void testPublicStaticOnTestSubject() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.getMethod('staticPing'))
+        assertEquals('static-pong', di.invoke(null, null))
+    }
+
+    @Test
+    void testVoidReturnIsNull() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.getMethod('noop'))
+        assertNull(di.invoke(new DirectInvokerSubjects(), null))
+    }
+
+    @Test
+    void testPrimitiveArgsAndReturn() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.getMethod('add', int, int))
+        assertEquals(Integer.valueOf(7), di.invoke(new DirectInvokerSubjects(), [3, 4] as Object[]))
+    }
+
+    @Test
+    void testBooleanPrimitive() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.getMethod('flag', boolean))
+        assertEquals(Boolean.TRUE, di.invoke(new DirectInvokerSubjects(), [true] as Object[]))
+    }
+
+    @Test
+    void testInterfaceDefaultMethod() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.Defaults.getMethod('greet'))
+        assertEquals('hello', di.invoke(new DirectInvokerSubjects.DefaultsImpl(), null))
+        assertEquals(Opcodes.INVOKEINTERFACE, InvokerBytecode.invokeOpcode(
+                DirectInvokerSubjects.Defaults.getMethod('greet')))
+    }
+
+    @Test
+    void testInterfaceStaticMethod() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.Defaults.getMethod('staticGreet'))
+        assertEquals('static-hello', di.invoke(null, null))
+        assertEquals(Opcodes.INVOKESTATIC, InvokerBytecode.invokeOpcode(
+                DirectInvokerSubjects.Defaults.getMethod('staticGreet')))
+    }
+
+    @Test
+    void testPrivateInterfaceMethodUsesInvokeInterface() {
+        Method hidden = DirectInvokerSubjects.Defaults.getDeclaredMethod('hidden')
+        assertEquals(Opcodes.INVOKEINTERFACE, InvokerBytecode.invokeOpcode(hidden))
+        DirectInvoker di = invoker(hidden)
+        assertEquals('hidden-iface', di.invoke(new DirectInvokerSubjects.DefaultsImpl(), null))
+        assertEquals(DirectInvokerSubjects.Defaults.nestHost, di.class.nestHost)
+    }
+
+    @Test
+    void testNullArgumentsTreatedAsEmptyByCaller() {
+        // Bytecode assumes non-null args; CachedMethod.invokeGenerated substitutes EMPTY_ARRAY.
+        DirectInvoker di = invoker(DirectInvokerSubjects.getMethod('ping'))
+        assertEquals('pong', di.invoke(new DirectInvokerSubjects(), new Object[0]))
+    }
+
+    @Test
+    void testVarargsAlreadyPacked() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.getMethod('join', String, String[]))
+        assertEquals('ab', di.invoke(new DirectInvokerSubjects(), ['a', ['b'] as String[]] as Object[]))
+    }
+
+    // -------------------------------------------------------------------------
+    // Step 2 — declaring-class nestmate (non-public members)
+    // -------------------------------------------------------------------------
+
+    @Test
+    void testPrivateInstanceNestHostIsDeclaringClass() {
+        Method secret = DirectInvokerSubjects.getDeclaredMethod('secret')
+        DirectInvoker di = invoker(secret)
+        assertEquals('secret', di.invoke(new DirectInvokerSubjects(), null))
+        assertEquals(DirectInvokerSubjects.nestHost, di.class.nestHost)
+        assertEquals(Opcodes.INVOKEVIRTUAL, InvokerBytecode.invokeOpcode(secret))
+    }
+
+    @Test
+    void testPrivateStaticUsesInvokeStatic() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.getDeclaredMethod('staticSecret'))
+        assertEquals('static-secret', di.invoke(null, null))
+        assertSame(DirectInvokerSubjects, di.class.nestHost)
+        assertEquals(Opcodes.INVOKESTATIC, InvokerBytecode.invokeOpcode(
+                DirectInvokerSubjects.getDeclaredMethod('staticSecret')))
+    }
+
+    @Test
+    void testProtectedInstance() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.getDeclaredMethod('protectedPing'))
+        assertEquals('protected-pong', di.invoke(new DirectInvokerSubjects(), null))
+        assertSame(DirectInvokerSubjects, di.class.nestHost)
+    }
+
+    @Test
+    void testPackagePrivateInstance() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.getDeclaredMethod('packagePing'))
+        assertEquals('package-pong', di.invoke(new DirectInvokerSubjects(), null))
+        assertSame(DirectInvokerSubjects, di.class.nestHost)
+    }
+
+    @Test
+    void testPublicMethodOnNonPublicClass() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.PackageHost.getMethod('visible'))
+        assertEquals('pkg-visible', di.invoke(new DirectInvokerSubjects.PackageHost(), null))
+        // Nested class nest host is the outermost class, not PackageHost itself.
+        assertEquals(DirectInvokerSubjects.PackageHost.nestHost, di.class.nestHost)
+        assertFalse(InvokerFactory.isPubliclyInvocableFromInvokerFactory(
+                cm(DirectInvokerSubjects.PackageHost.getMethod('visible'))))
+    }
+
+    @Test
+    void testGroovyClassLoaderPublicMethodIsDeclaringClassNestmate() {
+        GroovyClassLoader gcl = new GroovyClassLoader(InvokerFactory.classLoader)
+        try {
+            Class<?> host = gcl.parseClass('''
+                package gcl.directinvoker
+                class GclDirectInvokerHost { String ping() { "gcl-pong" } }
+            ''')
+            DirectInvoker di = invoker(host.getMethod('ping'))
+            assertEquals('gcl-pong', di.invoke(host.getDeclaredConstructor().newInstance(), null))
+            // Child-loader types may land on Step 2 (declaring-class nestmate)
+            // or Step 4 (ClassLoaderForClassArtifacts visible class) when
+            // privateLookupIn into the GroovyClassLoader type is unavailable.
+        } finally {
+            gcl.close()
+        }
+    }
+
+    // -------------------------------------------------------------------------
+    // Step 3 — classData encoding (forced)
+    // -------------------------------------------------------------------------
+
+    @Test
+    void testClassDataEncodingInvokesPublicMethod() {
+        DirectInvoker di = InvokerFactory.tryCreateClassData(
+                cm(DirectInvokerSubjects.getMethod('echo', String)))
+        assertNotNull(di)
+        assertEquals('xyz', di.invoke(new DirectInvokerSubjects(), ['xyz'] as Object[]))
+        assertSame(InvokerFactory, di.class.nestHost)
+    }
+
+    @Test
+    void testClassDataEncodingStaticPrimitive() {
+        DirectInvoker di = InvokerFactory.tryCreateClassData(
+                cm(Integer.getMethod('parseInt', String)))
+        assertNotNull(di)
+        assertEquals(Integer.valueOf(7), di.invoke(null, ['7'] as Object[]))
+    }
+
+    // -------------------------------------------------------------------------
+    // Gates
+    // -------------------------------------------------------------------------
+
+    @Test
+    void testCallerSensitiveIsNotGenerated() {
+        assertNull(InvokerFactory.tryCreate(cm(Class.getMethod('forName', String))))
+    }
+
+    @Test
+    void testAbstractIsNotGenerated() {
+        assertNull(InvokerFactory.tryCreate(
+                cm(DirectInvokerSubjects.AbstractHost.getMethod('abs'))))
+    }
+
+    @Test
+    void testNullMethodIsNotGenerated() {
+        assertNull(InvokerFactory.tryCreate(null))
+        assertNull(InvokerFactory.tryCreateClassData(null))
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testKillSwitchDisablesGeneration() {
+        String previous = System.getProperty(InvokerFactory.PROPERTY_DISABLE)
+        try {
+            System.setProperty(InvokerFactory.PROPERTY_DISABLE, 'true')
+            assertFalse(InvokerFactory.generationAllowed())
+            assertNull(InvokerFactory.tryCreate(cm(DirectInvokerSubjects.getMethod('ping'))))
+        } finally {
+            restoreProperty(InvokerFactory.PROPERTY_DISABLE, previous)
+        }
+        assertTrue(InvokerFactory.generationAllowed())
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testHiddenClassKillSwitchDisablesGeneration() {
+        String previous = System.getProperty(org.apache.groovy.util.HiddenClassDefiner.PROPERTY_DISABLE)
+        try {
+            System.setProperty(org.apache.groovy.util.HiddenClassDefiner.PROPERTY_DISABLE, 'true')
+            assertFalse(InvokerFactory.generationAllowed())
+            assertNull(InvokerFactory.tryCreate(cm(DirectInvokerSubjects.getMethod('ping'))))
+        } finally {
+            restoreProperty(org.apache.groovy.util.HiddenClassDefiner.PROPERTY_DISABLE, previous)
+        }
+        assertTrue(InvokerFactory.generationAllowed())
+    }
+
+    @Test
+    void testPrivateJavaLangMethodStickyFailsOnStockJdk() {
+        // Private java.lang member: Step 1 no, Step 2 usually no, Step 3 unreflect
+        // fails under strong encapsulation, Step 4 refused for bootstrap.
+        Method m = String.declaredMethods.find { method ->
+            java.lang.reflect.Modifier.isPrivate(method.modifiers) &&
+                    !method.synthetic &&
+                    !java.lang.reflect.Modifier.isAbstract(method.modifiers)
+        }
+        if (m == null) {
+            return
+        }
+        CachedMethod cached = cm(m)
+        if (cached.callerSensitive) {
+            assertNull(InvokerFactory.tryCreate(cached))
+            return
+        }
+        DirectInvoker di = InvokerFactory.tryCreate(cached)
+        // Stock modular JDK: null. --add-opens may succeed.
+        if (di != null) {
+            assertNotNull(di.class)
+        }
+    }
+
+    @Test
+    void testIsPubliclyInvocableFromInvokerFactory() {
+        assertTrue(InvokerFactory.isPubliclyInvocableFromInvokerFactory(
+                cm(String.getMethod('startsWith', String))))
+        assertFalse(InvokerFactory.isPubliclyInvocableFromInvokerFactory(
+                cm(DirectInvokerSubjects.getDeclaredMethod('secret'))))
+        assertFalse(InvokerFactory.isPubliclyInvocableFromInvokerFactory(
+                cm(DirectInvokerSubjects.PackageHost.getMethod('visible'))))
+    }
+
+    @Test
+    void testLoaderCanResolveBootstrapAndSelf() {
+        assertTrue(InvokerFactory.loaderCanResolve(InvokerFactory, String))
+        assertTrue(InvokerFactory.loaderCanResolve(InvokerFactory, int))
+        assertTrue(InvokerFactory.loaderCanResolve(InvokerFactory, DirectInvoker))
+        assertFalse(InvokerFactory.loaderCanResolve(String, DirectInvoker))
+        assertTrue(InvokerFactory.loaderCanResolve(null, int))
+        assertFalse(InvokerFactory.loaderCanResolve(null, DirectInvoker))
+    }
+
+    @Test
+    void testInvokeExactDescriptor() {
+        Method ping = DirectInvokerSubjects.getMethod('ping')
+        assertEquals('(Lorg/apache/groovy/internal/runtime/invoke/DirectInvokerSubjects;)Ljava/lang/String;',
+                InvokerBytecode.invokeExactDescriptor(ping))
+        Method parse = Integer.getMethod('parseInt', String)
+        assertEquals('(Ljava/lang/String;)I', InvokerBytecode.invokeExactDescriptor(parse))
+        Method add = DirectInvokerSubjects.getMethod('add', int, int)
+        assertEquals('(Lorg/apache/groovy/internal/runtime/invoke/DirectInvokerSubjects;II)I',
+                InvokerBytecode.invokeExactDescriptor(add))
+    }
+
+    @Test
+    void testInvokeOpcodePublicVirtual() {
+        assertEquals(Opcodes.INVOKEVIRTUAL, InvokerBytecode.invokeOpcode(
+                DirectInvokerSubjects.getMethod('ping')))
+    }
+
+    @Test
+    void testTargetExceptionsPropagateAsThrown() {
+        DirectInvoker di = invoker(DirectInvokerSubjects.getMethod('boomRuntime'))
+        IllegalStateException ex = assertThrows(IllegalStateException) {
+            di.invoke(new DirectInvokerSubjects(), null)
+        }
+        assertEquals('runtime-boom', ex.message)
+    }
+
+    private static void restoreProperty(String name, String previous) {
+        if (previous == null) {
+            System.clearProperty(name)
+        } else {
+            System.setProperty(name, previous)
+        }
+    }
+}
diff --git a/src/test/groovy/org/apache/groovy/util/HiddenClassDefinerTest.groovy b/src/test/groovy/org/apache/groovy/util/HiddenClassDefinerTest.groovy
index 138ec5df71..001236136a 100644
--- a/src/test/groovy/org/apache/groovy/util/HiddenClassDefinerTest.groovy
+++ b/src/test/groovy/org/apache/groovy/util/HiddenClassDefinerTest.groovy
@@ -22,6 +22,8 @@ import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.parallel.ResourceLock
 import org.junit.jupiter.api.parallel.Resources
 import org.objectweb.asm.ClassWriter
+import org.objectweb.asm.ConstantDynamic
+import org.objectweb.asm.Handle
 
 import java.lang.invoke.MethodHandles
 import java.lang.invoke.MethodHandles.Lookup
@@ -34,8 +36,14 @@ import static org.junit.jupiter.api.Assertions.assertSame
 import static org.junit.jupiter.api.Assertions.assertThrows
 import static org.junit.jupiter.api.Assertions.assertTrue
 import static org.objectweb.asm.Opcodes.ACC_PUBLIC
+import static org.objectweb.asm.Opcodes.ACC_STATIC
 import static org.objectweb.asm.Opcodes.ALOAD
+import static org.objectweb.asm.Opcodes.ARETURN
+import static org.objectweb.asm.Opcodes.ATHROW
+import static org.objectweb.asm.Opcodes.DUP
+import static org.objectweb.asm.Opcodes.H_INVOKESTATIC
 import static org.objectweb.asm.Opcodes.INVOKESPECIAL
+import static org.objectweb.asm.Opcodes.NEW
 import static org.objectweb.asm.Opcodes.RETURN
 import static org.objectweb.asm.Opcodes.V17
 
@@ -324,4 +332,116 @@ class HiddenClassDefinerTest {
     void testForeignHostNullBytesRejected() {
         assertNull(HiddenClassDefiner.tryDefineNestmate(HiddenClassDefinerTest, null, true))
     }
+
+    // -------------------------------------------------------------------------
+    // Lookup-returning / classData overloads (DirectInvoker support)
+    // -------------------------------------------------------------------------
+
+    @Test
+    void testTryDefineNestmateLookupReturnsHiddenLookup() {
+        byte[] bytes = minimalClassBytes('org/apache/groovy/util/LookupRet1')
+        Lookup hidden = HiddenClassDefiner.tryDefineNestmateLookup(LOOKUP, bytes, true)
+        assertNotNull(hidden)
+        assertTrue(hidden.lookupClass().hidden)
+        assertEquals(HiddenClassDefinerTest.nestHost, hidden.lookupClass().nestHost)
+        assertNotNull(hidden.lookupClass().getConstructor().newInstance())
+    }
+
+    @Test
+    void testTryDefineNestmateLookupForeignHost() {
+        byte[] bytes = minimalClassBytes('org/apache/groovy/util/LookupRetForeign')
+        Lookup hidden = HiddenClassDefiner.tryDefineNestmateLookup(HiddenClassDefinerTest, bytes, true)
+        assertNotNull(hidden)
+        assertEquals(HiddenClassDefinerTest.nestHost, hidden.lookupClass().nestHost)
+    }
+
+    @Test
+    void testTryDefineNestmateLookupRejectsNulls() {
+        byte[] bytes = minimalClassBytes('org/apache/groovy/util/LookupRetNull')
+        assertNull(HiddenClassDefiner.tryDefineNestmateLookup((Lookup) null, bytes, true))
+        assertNull(HiddenClassDefiner.tryDefineNestmateLookup(LOOKUP, null, true))
+        assertNull(HiddenClassDefiner.tryDefineNestmateLookup((Class) null, bytes, true))
+        assertNull(HiddenClassDefiner.tryDefineNestmateLookup(HiddenClassDefinerTest, null, true))
+        assertNull(HiddenClassDefiner.tryDefineNestmateLookup(LOOKUP, new byte[]{0, 1, 2, 3}, true))
+    }
+
+    @Test
+    void testTryDefineNestmateLookupStickyFailsClinitError() {
+        byte[] bytes = throwingClinitBytes('org/apache/groovy/util/LookupRetClinit')
+        assertNull(HiddenClassDefiner.tryDefineNestmateLookup(LOOKUP, bytes, true))
+    }
+
+    @Test
+    void testTryDefineNestmateWithClassDataRoundTrip() {
+        byte[] bytes = classDataGetterBytes('org/apache/groovy/util/ClassData1')
+        Lookup hidden = HiddenClassDefiner.tryDefineNestmateWithClassData(
+                LOOKUP, bytes, 'payload', true)
+        assertNotNull(hidden)
+        Object inst = hidden.lookupClass().getConstructor().newInstance()
+        assertEquals('payload', hidden.lookupClass().getMethod('get').invoke(inst))
+    }
+
+    @Test
+    void testTryDefineNestmateWithClassDataRejectsNullClassData() {
+        byte[] bytes = classDataGetterBytes('org/apache/groovy/util/ClassDataNull')
+        assertNull(HiddenClassDefiner.tryDefineNestmateWithClassData(LOOKUP, bytes, null, true))
+        assertNull(HiddenClassDefiner.tryDefineNestmateWithClassData(null, bytes, 'x', true))
+        assertNull(HiddenClassDefiner.tryDefineNestmateWithClassData(LOOKUP, null, 'x', true))
+    }
+
+    @Test
+    void testTryDefineNestmateLookupIntoJavaBaseDoesNotThrow() {
+        byte[] bytes = minimalClassBytes('java/lang/ShouldNotAppearLookup')
+        Lookup result = HiddenClassDefiner.tryDefineNestmateLookup(String, bytes, true)
+        if (!HiddenClassDefiner.canAttemptPrivateLookup(String)) {
+            assertNull(result, 'unopened java.lang must soft-fail to null')
+        } else if (result != null) {
+            assertTrue(result.lookupClass().hidden)
+        }
+    }
+
+    private static byte[] throwingClinitBytes(String internalName) {
+        def cw = new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES)
+        cw.visit(V17, ACC_PUBLIC, internalName, null, 'java/lang/Object', null)
+        def init = cw.visitMethod(ACC_PUBLIC, '<init>', '()V', null, null)
+        init.visitCode()
+        init.visitVarInsn(ALOAD, 0)
+        init.visitMethodInsn(INVOKESPECIAL, 'java/lang/Object', '<init>', '()V', false)
+        init.visitInsn(RETURN)
+        init.visitMaxs(0, 0)
+        init.visitEnd()
+        def clinit = cw.visitMethod(ACC_STATIC, '<clinit>', '()V', null, null)
+        clinit.visitCode()
+        clinit.visitTypeInsn(NEW, 'java/lang/RuntimeException')
+        clinit.visitInsn(DUP)
+        clinit.visitLdcInsn('clinit-boom')
+        clinit.visitMethodInsn(INVOKESPECIAL, 'java/lang/RuntimeException', '<init>', '(Ljava/lang/String;)V', false)
+        clinit.visitInsn(ATHROW)
+        clinit.visitMaxs(0, 0)
+        clinit.visitEnd()
+        cw.visitEnd()
+        cw.toByteArray()
+    }
+
+    private static byte[] classDataGetterBytes(String internalName) {
+        def cw = new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES)
+        cw.visit(V17, ACC_PUBLIC, internalName, null, 'java/lang/Object', null)
+        def init = cw.visitMethod(ACC_PUBLIC, '<init>', '()V', null, null)
+        init.visitCode()
+        init.visitVarInsn(ALOAD, 0)
+        init.visitMethodInsn(INVOKESPECIAL, 'java/lang/Object', '<init>', '()V', false)
+        init.visitInsn(RETURN)
+        init.visitMaxs(0, 0)
+        init.visitEnd()
+        def bsm = new Handle(H_INVOKESTATIC, 'java/lang/invoke/MethodHandles', 'classData',
+                '(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;', false)
+        def get = cw.visitMethod(ACC_PUBLIC, 'get', '()Ljava/lang/Object;', null, null)
+        get.visitCode()
+        get.visitLdcInsn(new ConstantDynamic('_', 'Ljava/lang/String;', bsm))
+        get.visitInsn(ARETURN)
+        get.visitMaxs(0, 0)
+        get.visitEnd()
+        cw.visitEnd()
+        cw.toByteArray()
+    }
 }
diff --git a/src/test/java/org/apache/groovy/internal/runtime/invoke/DirectInvokerSubjects.java b/src/test/java/org/apache/groovy/internal/runtime/invoke/DirectInvokerSubjects.java
new file mode 100644
index 0000000000..180b4da1f4
--- /dev/null
+++ b/src/test/java/org/apache/groovy/internal/runtime/invoke/DirectInvokerSubjects.java
@@ -0,0 +1,119 @@
+/*
+ *  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.internal.runtime.invoke;
+
+import groovy.lang.MissingMethodException;
+
+import java.io.IOException;
+
+/**
+ * Java fixtures with exact modifiers for {@link InvokerFactory} / {@link DirectInvoker} tests.
+ * Groovy sources default to public, which would collapse the modifier table.
+ */
+public class DirectInvokerSubjects {
+
+    public String ping() {
+        return "pong";
+    }
+
+    public static String staticPing() {
+        return "static-pong";
+    }
+
+    public void noop() {
+    }
+
+    public int add(final int a, final int b) {
+        return a + b;
+    }
+
+    public boolean flag(final boolean v) {
+        return v;
+    }
+
+    public String echo(final String s) {
+        return s;
+    }
+
+    public String join(final String a, final String... rest) {
+        if (rest == null || rest.length == 0) {
+            return a;
+        }
+        return a + rest[0];
+    }
+
+    public String boomRuntime() {
+        throw new IllegalStateException("runtime-boom");
+    }
+
+    public String boomError() {
+        throw new AssertionError("error-boom");
+    }
+
+    public String boomChecked() throws IOException {
+        throw new IOException("checked-boom");
+    }
+
+    public String boomMme() {
+        throw new MissingMethodException("missing", DirectInvokerSubjects.class, null);
+    }
+
+    private String secret() {
+        return "secret";
+    }
+
+    private static String staticSecret() {
+        return "static-secret";
+    }
+
+    protected String protectedPing() {
+        return "protected-pong";
+    }
+
+    String packagePing() {
+        return "package-pong";
+    }
+
+    public interface Defaults {
+        default String greet() {
+            return "hello";
+        }
+
+        private String hidden() {
+            return "hidden-iface";
+        }
+
+        static String staticGreet() {
+            return "static-hello";
+        }
+    }
+
+    public static final class DefaultsImpl implements Defaults {
+    }
+
+    public abstract static class AbstractHost {
+        public abstract String abs();
+    }
+
+    static final class PackageHost {
+        public String visible() {
+            return "pkg-visible";
+        }
+    }
+}
diff --git a/src/test/java/org/codehaus/groovy/reflection/CachedMethodDirectInvokerTest.java b/src/test/java/org/codehaus/groovy/reflection/CachedMethodDirectInvokerTest.java
new file mode 100644
index 0000000000..0d70331a53
--- /dev/null
+++ b/src/test/java/org/codehaus/groovy/reflection/CachedMethodDirectInvokerTest.java
@@ -0,0 +1,231 @@
+/*
+ *  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.reflection;
+
+import groovy.lang.MissingMethodException;
+import org.apache.groovy.internal.runtime.invoke.DirectInvokerSubjects;
+import org.apache.groovy.internal.runtime.invoke.InvokerFactory;
+import org.codehaus.groovy.runtime.InvokerInvocationException;
+import org.codehaus.groovy.runtime.MetaClassHelper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.junit.jupiter.api.parallel.Resources;
+
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Hook tests for {@link CachedMethod#invoke} installing a {@code DirectInvoker}.
+ *
+ * <p>Java (not Groovy) so {@code threshold=0} does not install trampolines on
+ * Groovy test closures. Each test uses {@code new CachedMethod(method)} so the
+ * interned MOP {@code CachedMethod} is not mutated.
+ */
+final class CachedMethodDirectInvokerTest {
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testThresholdZeroUsesGeneratedPath() throws Exception {
+        withThreshold(0L, () -> {
+            CachedMethod cm = new CachedMethod(DirectInvokerSubjects.class.getMethod("ping"));
+            assertEquals("pong", cm.invoke(new DirectInvokerSubjects(), null));
+            assertEquals("pong", cm.invoke(new DirectInvokerSubjects(), MetaClassHelper.EMPTY_ARRAY));
+        });
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testNullArgumentsAreEmptyArray() throws Exception {
+        withThreshold(0L, () -> {
+            CachedMethod cm = new CachedMethod(DirectInvokerSubjects.class.getMethod("ping"));
+            assertEquals("pong", cm.invoke(new DirectInvokerSubjects(), null));
+        });
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testPrimitiveBoxingMatchesReflective() throws Exception {
+        withThreshold(0L, () -> {
+            CachedMethod generated = new CachedMethod(
+                    DirectInvokerSubjects.class.getMethod("add", int.class, int.class));
+            Object viaGenerated = generated.invoke(new DirectInvokerSubjects(), new Object[]{2, 5});
+            withDisable(() -> {
+                CachedMethod reflective = new CachedMethod(
+                        DirectInvokerSubjects.class.getMethod("add", int.class, int.class));
+                Object viaReflect = reflective.invoke(new DirectInvokerSubjects(), new Object[]{2, 5});
+                assertEquals(viaReflect, viaGenerated);
+                assertEquals(Integer.valueOf(7), viaGenerated);
+            });
+        });
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testRuntimeExceptionIsRethrown() throws Exception {
+        withThreshold(0L, () -> {
+            CachedMethod cm = new CachedMethod(DirectInvokerSubjects.class.getMethod("boomRuntime"));
+            IllegalStateException ex = assertThrows(IllegalStateException.class,
+                    () -> cm.invoke(new DirectInvokerSubjects(), null));
+            assertEquals("runtime-boom", ex.getMessage());
+        });
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testMissingMethodExceptionIsWrapped() throws Exception {
+        withThreshold(0L, () -> {
+            CachedMethod cm = new CachedMethod(DirectInvokerSubjects.class.getMethod("boomMme"));
+            InvokerInvocationException ex = assertThrows(InvokerInvocationException.class,
+                    () -> cm.invoke(new DirectInvokerSubjects(), null));
+            assertInstanceOf(MissingMethodException.class, ex.getCause());
+        });
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testErrorFromTargetIsWrapped() throws Exception {
+        withThreshold(0L, () -> {
+            CachedMethod cm = new CachedMethod(DirectInvokerSubjects.class.getMethod("boomError"));
+            InvokerInvocationException ex = assertThrows(InvokerInvocationException.class,
+                    () -> cm.invoke(new DirectInvokerSubjects(), null));
+            assertInstanceOf(AssertionError.class, ex.getCause());
+            assertEquals("error-boom", ex.getCause().getMessage());
+        });
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testCheckedExceptionIsWrapped() throws Exception {
+        withThreshold(0L, () -> {
+            CachedMethod cm = new CachedMethod(DirectInvokerSubjects.class.getMethod("boomChecked"));
+            InvokerInvocationException ex = assertThrows(InvokerInvocationException.class,
+                    () -> cm.invoke(new DirectInvokerSubjects(), null));
+            assertInstanceOf(IOException.class, ex.getCause());
+            assertEquals("checked-boom", ex.getCause().getMessage());
+        });
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testReflectivePathStillWrapsIllegalArgumentException() throws Exception {
+        withDisable(() -> {
+            CachedMethod cm = new CachedMethod(
+                    DirectInvokerSubjects.class.getMethod("echo", String.class));
+            InvokerInvocationException ex = assertThrows(InvokerInvocationException.class,
+                    () -> cm.invoke(new DirectInvokerSubjects(), new Object[]{1}));
+            assertInstanceOf(IllegalArgumentException.class, ex.getCause());
+        });
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testGeneratedPathRethrowsClassCastOnBadArgs() throws Exception {
+        withThreshold(0L, () -> {
+            CachedMethod cm = new CachedMethod(
+                    DirectInvokerSubjects.class.getMethod("echo", String.class));
+            assertThrows(ClassCastException.class,
+                    () -> cm.invoke(new DirectInvokerSubjects(), new Object[]{1}));
+        });
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testCallerSensitiveStaysReflective() throws Exception {
+        withThreshold(0L, () -> {
+            CachedMethod cm = new CachedMethod(Class.class.getMethod("forName", String.class));
+            assertTrue(cm.isCallerSensitive());
+            Class<?> loaded = (Class<?>) cm.invoke(null, new Object[]{String.class.getName()});
+            assertEquals(String.class, loaded);
+        });
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testKillSwitchUsesReflectivePath() throws Exception {
+        withDisable(() -> {
+            CachedMethod cm = new CachedMethod(DirectInvokerSubjects.class.getMethod("ping"));
+            assertEquals("pong", cm.invoke(new DirectInvokerSubjects(), null));
+        });
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testDefaultThresholdDoesNotGenerateOnFirstCall() throws Exception {
+        String previous = System.getProperty(InvokerFactory.PROPERTY_THRESHOLD);
+        try {
+            System.clearProperty(InvokerFactory.PROPERTY_THRESHOLD);
+            CachedMethod cm = new CachedMethod(DirectInvokerSubjects.class.getMethod("ping"));
+            assertEquals("pong", cm.invoke(new DirectInvokerSubjects(), null));
+        } finally {
+            restore(InvokerFactory.PROPERTY_THRESHOLD, previous);
+        }
+    }
+
+    @Test
+    @ResourceLock(Resources.SYSTEM_PROPERTIES)
+    void testStaticMethodAndVoidThroughHook() throws Exception {
+        withThreshold(0L, () -> {
+            CachedMethod stat = new CachedMethod(DirectInvokerSubjects.class.getMethod("staticPing"));
+            assertEquals("static-pong", stat.invoke(null, null));
+            CachedMethod v = new CachedMethod(DirectInvokerSubjects.class.getMethod("noop"));
+            assertNull(v.invoke(new DirectInvokerSubjects(), null));
+        });
+    }
+
+    @FunctionalInterface
+    private interface ThrowingRunnable {
+        void run() throws Exception;
+    }
+
+    private static void withThreshold(long threshold, ThrowingRunnable body) throws Exception {
+        String previous = System.getProperty(InvokerFactory.PROPERTY_THRESHOLD);
+        String disable = System.getProperty(InvokerFactory.PROPERTY_DISABLE);
+        try {
+            System.setProperty(InvokerFactory.PROPERTY_THRESHOLD, Long.toString(threshold));
+            System.clearProperty(InvokerFactory.PROPERTY_DISABLE);
+            body.run();
+        } finally {
+            restore(InvokerFactory.PROPERTY_THRESHOLD, previous);
+            restore(InvokerFactory.PROPERTY_DISABLE, disable);
+        }
+    }
+
+    private static void withDisable(ThrowingRunnable body) throws Exception {
+        String previous = System.getProperty(InvokerFactory.PROPERTY_DISABLE);
+        try {
+            System.setProperty(InvokerFactory.PROPERTY_DISABLE, "true");
+            body.run();
+        } finally {
+            restore(InvokerFactory.PROPERTY_DISABLE, previous);
+        }
+    }
+
+    private static void restore(String name, String previous) {
+        if (previous == null) {
+            System.clearProperty(name);
+        } else {
+            System.setProperty(name, previous);
+        }
+    }
+}
diff --git a/subprojects/performance/src/jmh/java/org/apache/groovy/bench/CachedMethodInvokerBench.java b/subprojects/performance/src/jmh/java/org/apache/groovy/bench/CachedMethodInvokerBench.java
new file mode 100644
index 0000000000..5a73395007
--- /dev/null
+++ b/subprojects/performance/src/jmh/java/org/apache/groovy/bench/CachedMethodInvokerBench.java
@@ -0,0 +1,154 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.bench;
+
+import org.codehaus.groovy.reflection.CachedMethod;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Pipeline-split microbench for {@code CachedMethod.invoke}: Java direct call,
+ * reflective MOP invoke, and the generated {@code DirectInvoker} trampoline.
+ * <p>
+ * The generated path is the <em>monomorphic</em> best case (one
+ * {@code CachedMethod}, one trampoline class). Real {@code MetaClassImpl}
+ * dispatch across many types is megamorphic at the
+ * {@code DirectInvoker.invoke} call site; the {@code mega} rows exercise that.
+ * Guard ratios, not absolute nanoseconds. Run with
+ * {@code :perf:jmh -PbenchInclude=CachedMethodInvoker}.
+ * <p>
+ * Fork JVM args pin the generator: {@code threshold=0} installs on first
+ * invoke; {@code disable=true} stays on {@code Method.invoke}.
+ */
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Fork(1)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@State(Scope.Thread)
+public class CachedMethodInvokerBench {
+
+    private static final String RECEIVER = "abcdef";
+    private static final String PREFIX = "abc";
+
+    private CachedMethod startsWith;
+    private CachedMethod[] mega;
+    private String[] megaReceivers;
+
+    /**
+     * Resolves the {@code CachedMethod}s used by the reflective / generated rows.
+     */
+    @Setup
+    public void setUp() throws Exception {
+        startsWith = new CachedMethod(String.class.getMethod("startsWith", String.class));
+        mega = new CachedMethod[]{
+                new CachedMethod(String.class.getMethod("startsWith", String.class)),
+                new CachedMethod(String.class.getMethod("endsWith", String.class)),
+                new CachedMethod(String.class.getMethod("contains", CharSequence.class)),
+                new CachedMethod(String.class.getMethod("isEmpty")),
+                new CachedMethod(Integer.class.getMethod("toString")),
+        };
+        megaReceivers = new String[]{"abcdef", "xyz", "foo", ""};
+        // Warm generation when threshold=0 so measurement is steady-state.
+        startsWith.invoke(RECEIVER, new Object[]{PREFIX});
+        mega[0].invoke(megaReceivers[0], new Object[]{PREFIX});
+        mega[1].invoke(megaReceivers[1], new Object[]{"z"});
+        mega[2].invoke(megaReceivers[2], new Object[]{"oo"});
+        mega[3].invoke(megaReceivers[3], new Object[0]);
+        mega[4].invoke(Integer.valueOf(7), new Object[0]);
+    }
+
+    /**
+     * Java baseline: {@code String.startsWith}.
+     *
+     * @return whether the prefix matches
+     */
+    @Benchmark
+    public boolean startsWith_java() {
+        return RECEIVER.startsWith(PREFIX);
+    }
+
+    /**
+     * {@code CachedMethod.invoke} with generation disabled.
+     *
+     * @return boxed {@code Boolean}
+     */
+    @Benchmark
+    @Fork(value = 1, jvmArgsAppend = "-Dgroovy.cachedmethod.invoker.disable=true")
+    public Object startsWith_reflective() {
+        return startsWith.invoke(RECEIVER, new Object[]{PREFIX});
+    }
+
+    /**
+     * {@code CachedMethod.invoke} after first-hit generation.
+     *
+     * @return boxed {@code Boolean}
+     */
+    @Benchmark
+    @Fork(value = 1, jvmArgsAppend = "-Dgroovy.cachedmethod.invoker.threshold=0")
+    public Object startsWith_generated() {
+        return startsWith.invoke(RECEIVER, new Object[]{PREFIX});
+    }
+
+    /**
+     * Many distinct {@code CachedMethod}s through one {@code invoke} site,
+     * generation disabled.
+     *
+     * @return last boxed result
+     */
+    @Benchmark
+    @Fork(value = 1, jvmArgsAppend = "-Dgroovy.cachedmethod.invoker.disable=true")
+    public Object mega_reflective() {
+        Object last = null;
+        last = mega[0].invoke(megaReceivers[0], new Object[]{PREFIX});
+        last = mega[1].invoke(megaReceivers[1], new Object[]{"z"});
+        last = mega[2].invoke(megaReceivers[2], new Object[]{"oo"});
+        last = mega[3].invoke(megaReceivers[3], new Object[0]);
+        last = mega[4].invoke(Integer.valueOf(7), new Object[0]);
+        return last;
+    }
+
+    /**
+     * Many distinct {@code CachedMethod}s through one {@code invoke} site,
+     * after first-hit generation.
+     *
+     * @return last boxed result
+     */
+    @Benchmark
+    @Fork(value = 1, jvmArgsAppend = "-Dgroovy.cachedmethod.invoker.threshold=0")
+    public Object mega_generated() {
+        Object last = null;
+        last = mega[0].invoke(megaReceivers[0], new Object[]{PREFIX});
+        last = mega[1].invoke(megaReceivers[1], new Object[]{"z"});
+        last = mega[2].invoke(megaReceivers[2], new Object[]{"oo"});
+        last = mega[3].invoke(megaReceivers[3], new Object[0]);
+        last = mega[4].invoke(Integer.valueOf(7), new Object[0]);
+        return last;
+    }
+}