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