diff --git a/src/main/java/org/apache/groovy/runtime/indy/AotDispatch.java b/src/main/java/org/apache/groovy/runtime/indy/AotDispatch.java
new file mode 100644
index 00000000000..b9986b95671
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/indy/AotDispatch.java
@@ -0,0 +1,92 @@
+/*
+ * 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.runtime.indy;
+
+import java.lang.invoke.SwitchPoint;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Ahead-of-time link mode for indy dispatch (spike).
+ *
+ * GraalVM native image supports every {@code java.lang.invoke} building block Groovy's indy
+ * runtime uses except retargeting an existing call site: both
+ * {@code MutableCallSite.setTarget} and {@code SwitchPoint.invalidateAll} fail with
+ * {@code Unsupported method java.lang.invoke.MethodHandleNatives.setCallSiteTargetNormal}.
+ * In Groovy's design those two primitives only ever install or invalidate caches —
+ * the dispatch semantics live entirely in method selection — so under AOT the runtime links
+ * every site once to its cache-consulting default path ({@code ConstantCallSite}) and carries
+ * freshness in data instead:
+ *
batch) {
if (batch.isEmpty()) {
return;
}
- SwitchPoint.invalidateAll(batch.toArray(EMPTY_SWITCH_POINTS));
+ // AOT-safe: advances the AotDispatch stamp; the real invalidateAll runs only on a JVM
+ AotDispatch.invalidateAll(batch.toArray(EMPTY_SWITCH_POINTS));
}
// -------------------------------------------------------------------------
diff --git a/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
index 79cb0ddbc0a..4a57a875e34 100644
--- a/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
+++ b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
@@ -124,7 +124,8 @@ public static void invalidateIfLive(final SwitchPoint sp) {
synchronized (SINGLE_INVALIDATE_LOCK) {
SINGLE_INVALIDATE_BUF[0] = sp;
try {
- SwitchPoint.invalidateAll(SINGLE_INVALIDATE_BUF);
+ // AOT-safe: stamp always advances; real invalidateAll only on a JVM
+ AotDispatch.invalidateAll(SINGLE_INVALIDATE_BUF);
} finally {
SINGLE_INVALIDATE_BUF[0] = null;
}
diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java
index 4462f7aad5d..58d17f506a6 100644
--- a/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java
+++ b/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java
@@ -181,6 +181,19 @@ protected interface UseExistingReference {
// off the emitted-bytecode surface.
private static final String DISPATCHERS_GETTER = "$getPackedDispatchers$";
private static final String DISPATCHERS_GETTER_DESC = "()Ljava/lang/Object;";
+ // The factory emitted into the hosting class that adapts its three tables to their functional
+ // interfaces through bytecode-level LambdaMetafactory sites (see writeDispatchersFactory).
+ private static final String DISPATCHERS_FACTORY = "$packedDispatchersFactory$";
+ private static final String BUNDLE_TYPE = "org/codehaus/groovy/runtime/GeneratedDispatcher$Bundle";
+ private static final String DISPATCHER_TYPE = "org/codehaus/groovy/runtime/GeneratedDispatcher";
+ private static final String ARITY1_TYPE = "org/codehaus/groovy/runtime/GeneratedDispatcher$Arity1";
+ private static final String ARITY2_TYPE = "org/codehaus/groovy/runtime/GeneratedDispatcher$Arity2";
+ private static final Handle LMF_BOOTSTRAP = new Handle(
+ H_INVOKESTATIC, "java/lang/invoke/LambdaMetafactory", "metafactory",
+ "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;"
+ + "Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)"
+ + "Ljava/lang/invoke/CallSite;",
+ false);
// Max tableswitch cases per dispatch method (power of two: the two-level entry method selects a
// chunk with a shift); sized so a full chunk stays well under the JIT's 325-byte inlining budget.
private static final int DISPATCH_CHUNK = 8;
@@ -1082,20 +1095,28 @@ public void writePackedDispatcher() {
org.objectweb.asm.ClassVisitor cv = controller.getClassVisitor();
// the accessor: return INDY packedDispatchers()Object — IndyInterface.packedDispatchers
- // (delegating to GeneratedDispatcher.bootstrap) links the class's three dispatch tables
- // (through LambdaMetafactory, with this class's lookup) once, on first adapter creation,
- // and every later call returns the constant bundle, so the accessor is also the cache
+ // (delegating to GeneratedDispatcher.bootstrap) invokes this class's emitted factory
+ // once, on first adapter creation, and every later call returns the constant bundle,
+ // so the accessor is also the cache
MethodVisitor mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCHERS_GETTER, DISPATCHERS_GETTER_DESC, null, null);
mv.visitCode();
+ // The bundle is built by a factory emitted into this class (see writeDispatchersFactory)
+ // and reached as a constant bootstrap argument, so the bootstrap needs neither a runtime
+ // Lookup.findStatic nor a programmatic LambdaMetafactory call — both of which fail under
+ // GraalVM native image (GROOVY-12227).
Handle bootstrap = new Handle(
H_INVOKESTATIC, INDY_INTERFACE_TYPE, "packedDispatchers",
- "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;",
+ "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;"
+ + "Ljava/lang/invoke/MethodHandle;)Ljava/lang/invoke/CallSite;",
false);
- mv.visitInvokeDynamicInsn("packedDispatchers", DISPATCHERS_GETTER_DESC, bootstrap);
+ mv.visitInvokeDynamicInsn("packedDispatchers", DISPATCHERS_GETTER_DESC, bootstrap,
+ new Handle(H_INVOKESTATIC, internal, DISPATCHERS_FACTORY, DISPATCHERS_GETTER_DESC, false));
mv.visitInsn(ARETURN);
mv.visitMaxs(0, 0);
mv.visitEnd();
+ writeDispatchersFactory(cv, internal);
+
// the array-free per-arity tables, over the targets whose captured-plus-argument count
// matches (membership is sparse over the id space, so cases use lookupswitch); routing is
// the adapter's responsibility, so any other id landing here is a compiler bug
@@ -1199,6 +1220,47 @@ private static void writeDispatchSwitch(final MethodVisitor mv, final String int
* switches over its id-range's members (at most {@code DISPATCH_CHUNK}, since a range spans
* {@code DISPATCH_CHUNK} consecutive ids).
*/
+ /**
+ * Emits the hosting class's dispatcher factory: three bytecode-level
+ * {@code LambdaMetafactory} sites adapting its private static tables to their functional
+ * interfaces, wrapped in one {@code Bundle}.
+ *
+ * Emitting the linkage here rather than calling {@code LambdaMetafactory} programmatically
+ * from the bootstrap matters twice over. The sites are ordinary {@code invokedynamic}, so
+ * GraalVM native image pre-processes them at build time — no class is defined at run time,
+ * and the JVM path is unchanged (the VM spins the same hidden class when it links the site).
+ * And because the factory lives in the hosting class, its method references reach that
+ * class's own private tables directly, so no {@code Lookup.findStatic} — and hence no
+ * per-class reflection metadata — is needed either (GROOVY-12227).
+ */
+ private static void writeDispatchersFactory(final org.objectweb.asm.ClassVisitor cv, final String internal) {
+ MethodVisitor mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCHERS_FACTORY, DISPATCHERS_GETTER_DESC, null, null);
+ mv.visitCode();
+ mv.visitTypeInsn(NEW, BUNDLE_TYPE);
+ mv.visitInsn(DUP);
+ emitLambda(mv, internal, "dispatch", DISPATCHER_TYPE, DISPATCH_METHOD, DISPATCH_DESC);
+ emitLambda(mv, internal, "dispatch1", ARITY1_TYPE, DISPATCH1_METHOD, DISPATCH1_DESC);
+ emitLambda(mv, internal, "dispatch2", ARITY2_TYPE, DISPATCH2_METHOD, DISPATCH2_DESC);
+ mv.visitMethodInsn(INVOKESPECIAL, BUNDLE_TYPE, "",
+ "(L" + DISPATCHER_TYPE + ";L" + ARITY1_TYPE + ";L" + ARITY2_TYPE + ";)V", false);
+ mv.visitInsn(ARETURN);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ }
+
+ /**
+ * Emits one {@code invokedynamic} adapting {@code tableMethod} to the single abstract method
+ * {@code samName} of {@code ifaceType}. The table's descriptor is both the erased and the
+ * instantiated signature, so the metafactory inserts no adaptation.
+ */
+ private static void emitLambda(final MethodVisitor mv, final String internal, final String samName,
+ final String ifaceType, final String tableMethod, final String tableDesc) {
+ mv.visitInvokeDynamicInsn(samName, "()L" + ifaceType + ";", LMF_BOOTSTRAP,
+ org.objectweb.asm.Type.getMethodType(tableDesc),
+ new Handle(H_INVOKESTATIC, internal, tableMethod, tableDesc, false),
+ org.objectweb.asm.Type.getMethodType(tableDesc));
+ }
+
private static void writeArityTable(final org.objectweb.asm.ClassVisitor cv, final String internal,
final ClassNode enclosing, final List targets, final int paramCount,
final String tableMethod, final String tableDesc) {
diff --git a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
index ec2042012af..4279132b251 100644
--- a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
+++ b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
@@ -239,7 +239,8 @@ public void invalidateIndySwitchPoint() {
List batch = new ArrayList<>(2);
collectLiveIndySwitchPoints(batch);
if (!batch.isEmpty()) {
- SwitchPoint.invalidateAll(batch.toArray(new SwitchPoint[0]));
+ // AOT-safe: stamp always advances; real invalidateAll only on a JVM
+ org.apache.groovy.runtime.indy.AotDispatch.invalidateAll(batch.toArray(new SwitchPoint[0]));
}
}
diff --git a/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java b/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java
index 779aefec635..ecc5430dd83 100644
--- a/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java
+++ b/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java
@@ -21,9 +21,11 @@
import java.lang.invoke.CallSite;
import java.lang.invoke.ConstantCallSite;
import java.lang.invoke.LambdaMetafactory;
+import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
+
/**
* A per-class table of compiler-generated dispatch targets, reached by a compact
* integer id instead of a {@link java.lang.invoke.MethodHandle}.
@@ -115,7 +117,11 @@ final class Bundle {
final Arity1 arity1;
final Arity2 arity2;
- Bundle(final GeneratedDispatcher dispatcher, final Arity1 arity1, final Arity2 arity2) {
+ /**
+ * Public because the hosting class's compiler-emitted factory constructs it directly
+ * (see {@code ClosureWriter#writeDispatchersFactory}); not API for hand-written code.
+ */
+ public Bundle(final GeneratedDispatcher dispatcher, final Arity1 arity1, final Arity2 arity2) {
this.dispatcher = dispatcher;
this.arity1 = arity1;
this.arity2 = arity2;
@@ -150,11 +156,12 @@ static Class>[] paramTypes(final MethodHandles.Lookup caller, final String nam
}
/**
- * Invokedynamic bootstrap for the hosting class's dispatcher accessor: adapts the class's
- * three private static dispatch tables to their functional interfaces (one hidden class
- * each, via {@code LambdaMetafactory} with the caller's full-privilege lookup) and returns
- * them as one constant {@link Bundle}. Linked once per class, on first adapter creation.
- * Emitted bytecode reaches this through
+ * Legacy invokedynamic bootstrap for the dispatcher accessor, kept for class files emitted
+ * by earlier 6.0 pre-releases: adapts the class's three private static dispatch tables to
+ * their functional interfaces (one hidden class each, via {@code LambdaMetafactory} with the
+ * caller's full-privilege lookup) and returns them as one constant {@link Bundle}. Current
+ * class files link through the one-{@code MethodHandle} overload instead. Emitted bytecode
+ * reaches this through
* {@code org.codehaus.groovy.vmplugin.v8.IndyInterface#packedDispatchers} — the central
* bytecode-facing bootstrap surface — which delegates here.
*
@@ -182,4 +189,26 @@ static CallSite bootstrap(final MethodHandles.Lookup caller, final String name,
twoType, caller.findStatic(host, TABLE2_METHOD, twoType), twoType).getTarget().invokeExact();
return new ConstantCallSite(MethodHandles.constant(type.returnType(), new Bundle(dispatcher, arity1, arity2)));
}
+
+ /**
+ * Invokedynamic bootstrap for the dispatcher accessor: the hosting class supplies (as a
+ * constant bootstrap argument) a compiler-emitted factory that builds the bundle from its
+ * own bytecode-level {@code LambdaMetafactory} sites, so linking is one call and
+ * this method neither looks anything up nor defines any class. Works unchanged under GraalVM
+ * native image, where those sites are pre-processed at image build time and the factory's
+ * method references reach the class's own private tables without reflection metadata
+ * (GROOVY-12227). Emitted bytecode reaches this through
+ * {@code org.codehaus.groovy.vmplugin.v8.IndyInterface#packedDispatchers}.
+ *
+ * @param caller the hosting class's lookup (supplied by the JVM, unused)
+ * @param name the invoked name (unused)
+ * @param type the accessor's type (see the three-argument overload)
+ * @param factory the hosting class's {@code $packedDispatchersFactory$}, {@code () -> Bundle}
+ * @return a constant call site producing the bundle
+ * @throws Throwable if the factory fails (a compiler bug)
+ */
+ static CallSite bootstrap(final MethodHandles.Lookup caller, final String name, final MethodType type,
+ final MethodHandle factory) throws Throwable {
+ return new ConstantCallSite(MethodHandles.constant(type.returnType(), factory.invoke()));
+ }
}
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java
index d412b2b6cb3..5603549ad0b 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/CacheableCallSite.java
@@ -58,6 +58,7 @@ public class CacheableCallSite extends MutableCallSite {
private volatile SoftReference latestHitMethodHandleWrapperSoftReference = null;
private final AtomicLong fallbackCount = new AtomicLong();
private final AtomicLong fallbackRound = new AtomicLong();
+ private final boolean aotLinked;
private MethodHandle defaultTarget;
private MethodHandle fallbackTarget;
private final Map> lruCache =
@@ -85,6 +86,34 @@ protected boolean removeEldestEntry(Map.Entry eldest) {
public CacheableCallSite(MethodType type, MethodHandles.Lookup lookup) {
super(type);
this.lookup = lookup;
+ // captured once, at link time (this constructor only runs while linking a site), so
+ // per-invocation code reads a plain field instead of probing system properties
+ this.aotLinked = org.apache.groovy.runtime.indy.AotDispatch.isAotLinkRequested();
+ }
+
+ /**
+ * Whether this site was linked in AOT mode (GraalVM native image, or the
+ * {@code groovy.indy.aot.link} diagnostic knob): the site is wrapped in a
+ * {@code ConstantCallSite} over the cache-consulting default path, is never retargeted,
+ * and cache freshness is carried by the {@code AotDispatch} stamp instead of SwitchPoints.
+ */
+ public boolean isAotLinked() {
+ return aotLinked;
+ }
+
+ /**
+ * Fails fast on any retarget attempt in AOT mode. Under a real native image
+ * {@code setTarget} throws {@code UnsupportedFeatureError} anyway — and worse, execution
+ * would continue with the stale target if that error were swallowed — so a missed gate is
+ * a bug on every platform; this surfaces it on the JVM, where tests run with the
+ * diagnostic knob.
+ */
+ @Override
+ public void setTarget(final MethodHandle newTarget) {
+ if (aotLinked) {
+ throw new IllegalStateException("call site retargeting is disabled in AOT link mode (GROOVY-12227 spike)");
+ }
+ super.setTarget(newTarget);
}
/**
@@ -94,6 +123,22 @@ public CacheableCallSite(MethodType type, MethodHandles.Lookup lookup) {
* @param valueProvider the provider used to compute a missing entry
* @return the cached or newly created wrapper
*/
+ /**
+ * Read-only PIC lookup: the cached wrapper for the receiver class, or {@code null} when
+ * absent or its soft reference has been cleared. Used by the AOT dispatch path, which
+ * resolves misses itself and must not pay for a value-provider allocation per call.
+ *
+ * @param className the receiver cache key
+ * @return the cached wrapper or {@code null}
+ */
+ public MethodHandleWrapper getIfPresent(String className) {
+ final SoftReference ref;
+ synchronized (lruCache) {
+ ref = lruCache.get(className);
+ }
+ return ref == null ? null : ref.get();
+ }
+
public MethodHandleWrapper getAndPut(String className, MemoizeCache.ValueProvider super String, ? extends MethodHandleWrapper> valueProvider) {
MethodHandleWrapper result = null;
SoftReference resultSoftReference;
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
index 05cd8761505..47e27534afe 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
@@ -20,6 +20,7 @@
import groovy.lang.GroovyRuntimeException;
import groovy.lang.GroovySystem;
+import org.apache.groovy.runtime.indy.AotDispatch;
import org.apache.groovy.runtime.indy.IndyInvalidation;
import org.apache.groovy.util.SystemUtil;
import org.codehaus.groovy.GroovyBugError;
@@ -207,6 +208,12 @@ public int getOrderNumber() {
*/
static final MethodHandle COLD_REFLECTIVE_INVOKER;
+ /**
+ * Handle for {@link #aotDispatch}: the single plain-Java entry an AOT-linked site's
+ * constant target binds to.
+ */
+ private static final MethodHandle AOT_DISPATCH_METHOD;
+
static {
try {
MethodType mt = MethodType.methodType(MethodHandle.class, CacheableCallSite.class, Class.class, String.class, int.class, Boolean.class, Boolean.class, Boolean.class, Object.class, Object[].class);
@@ -215,11 +222,45 @@ public int getOrderNumber() {
SELECT_METHOD_HANDLE_METHOD = LOOKUP.findStatic(IndyInterface.class, "selectMethodHandle", mt);
COLD_REFLECTIVE_INVOKER = LOOKUP.findStatic(IndyInterface.class, "invokeColdReflective",
MethodType.methodType(Object.class, ColdReflectiveMethodHandleWrapper.class, Object[].class));
+ AOT_DISPATCH_METHOD = LOOKUP.findStatic(IndyInterface.class, "aotDispatch",
+ MethodType.methodType(Object.class, CacheableCallSite.class, Class.class, String.class, int.class,
+ Boolean.class, Boolean.class, Boolean.class, Object[].class));
} catch (Exception e) {
throw new GroovyBugError(e);
}
}
+ /**
+ * Guards against a GraalVM native-image gap: the runtime invokedynamic linkage invokes a
+ * bootstrap method without running its declaring class's {@code } first (observed
+ * on GraalVM CE 25.2.4; on HotSpot the bootstrap's {@code DirectMethodHandle} carries a
+ * class-initialization barrier). Every BSM entry point calls this; on an initialized class
+ * it is a single null check.
+ */
+ private static void ensureInitialized() {
+ if (FROM_CACHE_HANDLE_METHOD == null) {
+ // an ordinary cross-class static read carries the initialization barrier the
+ // native-image BSM invocation path lacks; reading our own field would not
+ ClinitBarrier.trigger();
+ if (FROM_CACHE_HANDLE_METHOD == null) {
+ throw new GroovyBugError("IndyInterface linkage handles unavailable: did not run");
+ }
+ }
+ }
+
+ /**
+ * A separate class whose read of {@link IndyInterface#LOOKUP} is an ordinary
+ * {@code getstatic} from foreign code — compiled with the standard ensure-initialized
+ * barrier that the native-image bootstrap-method invocation path is missing.
+ */
+ private static final class ClinitBarrier {
+ static void trigger() {
+ if (IndyInterface.LOOKUP == null) {
+ throw new GroovyBugError("unreachable: LOOKUP read before initialization");
+ }
+ }
+ }
+
static {
// MetaClass registry changes invalidate the affected class domain (GROOVY-12191).
// Stock MetaClassImpl/EMC → exact class; custom MetaClass kinds → bulk.
@@ -294,6 +335,7 @@ static MethodHandle applyMopSwitchPoints(final MethodHandle handle, final Method
* @since 2.1.0
*/
public static CallSite bootstrap(final MethodHandles.Lookup caller, final String callType, final MethodType type, final String name, final int flags) {
+ ensureInitialized();
CallType ct = CallType.fromCallSiteName(callType);
if (null == ct) throw new GroovyBugError("Unknown call type: " + callType);
@@ -314,10 +356,33 @@ public static CallSite bootstrap(final MethodHandles.Lookup caller, final String
}
// make an adapter for method selection, i.e. get cached method handle (fast path) or fall back
MethodHandle mh = makeBootHandle(mc, sender, name, callID, type, safe, thisCall, spreadCall, FROM_CACHE_HANDLE_METHOD);
- mc.setTarget(mh);
mc.setDefaultTarget(mh);
mc.setFallbackTarget(makeFallBack(mc, sender, name, callID, type, safe, thisCall, spreadCall));
+ if (mc.isAotLinked()) {
+ // AOT link mode (GROOVY-12227 spike): native image cannot retarget call sites
+ // (MethodHandleNatives.setCallSiteTargetNormal is unsupported), so the site links
+ // once, permanently, to a constant target. The CacheableCallSite is never installed
+ // as the call site — it serves as the state carrier (PIC, fallback target) the
+ // dispatcher consults. Retargeting only ever installs caches in this design, so
+ // semantics are unchanged; freshness moves to the AotDispatch stamp.
+ //
+ // The target is deliberately SHALLOW: one bound handle into aotDispatch, which does
+ // PIC lookup, freshness check, and invocation in ordinary compiled Java, and — for
+ // the dominant reflective tier — never re-enters the method-handle machinery.
+ // Measured caveat: entering ANY runtime-created method handle costs ~4.5us under
+ // native image (a per-entry interpreter cost, independent of chain depth or
+ // invokeExact), and the invokedynamic instruction's hop into this runtime-linked
+ // target pays it once per call regardless of the target's shape. The shallow form
+ // still wins on allocation (no per-call FallbackSupplier/provider lambdas) and
+ // keeps everything past the entry in compiled code.
+ MethodHandle aot = MethodHandles.insertArguments(AOT_DISPATCH_METHOD, 0,
+ mc, sender, name, callID, safe, thisCall, spreadCall);
+ aot = aot.asCollector(Object[].class, type.parameterCount()).asType(type);
+ return new ConstantCallSite(aot);
+ }
+ mc.setTarget(mh);
+
return mc;
}
@@ -438,7 +503,7 @@ private static MethodHandle fromCacheHandle(CacheableCallSite callSite, Class>
mhw = fallbackSupplier.get();
}
- if (mhw.isCanSetTarget() && (callSite.getTarget() != mhw.getTargetMethodHandle())) {
+ if (!callSite.isAotLinked() && mhw.isCanSetTarget() && (callSite.getTarget() != mhw.getTargetMethodHandle())) {
// GROOVY-11935: Set invokedynamic call site target immediately to enable earlier JIT inlining.
if (callSite.type().parameterType(0) == Class.class) {
var method = mhw.getMethod();
@@ -478,6 +543,32 @@ private static MethodHandle fromCacheHandle(CacheableCallSite callSite, Class>
return mhw.getCachedMethodHandle();
}
+ /**
+ * The AOT-linked site's dispatch entry: PIC lookup, stamp-based freshness, and invocation,
+ * all in ordinary compiled Java (see the AOT branch of
+ * {@link #bootstrap(MethodHandles.Lookup, String, MethodType, String, int)}). The dominant
+ * tier — the reflective cold wrapper — is invoked directly, not through its bound method
+ * handle, so steady-state dispatch never enters the native method-handle interpreter.
+ *
+ * PIC semantics mirror {@code fromCacheHandle}: uncacheable selections store the sentinel
+ * (forcing re-selection per call, since class-keyed reuse would be wrong for e.g.
+ * per-instance metaclasses), and a stamp mismatch — any MOP invalidation since the wrapper
+ * was selected — is a miss.
+ */
+ private static Object aotDispatch(CacheableCallSite callSite, Class> sender, String methodName, int callID,
+ Boolean safeNavigation, Boolean thisCall, Boolean spreadCall, Object[] arguments) throws Throwable {
+ String receiverClassName = receiverCacheKey(arguments[0]);
+ MethodHandleWrapper mhw = callSite.getIfPresent(receiverClassName);
+ if (mhw == null || mhw == UNCACHEABLE_PIC_SENTINEL || mhw.getAotStamp() != AotDispatch.stamp()) {
+ mhw = fallback(callSite, sender, methodName, callID, safeNavigation, thisCall, spreadCall, 1, arguments);
+ callSite.put(receiverClassName, mhw.isCanSetTarget() ? mhw : UNCACHEABLE_PIC_SENTINEL);
+ }
+ if (mhw instanceof ColdReflectiveMethodHandleWrapper) {
+ return invokeColdReflective((ColdReflectiveMethodHandleWrapper) mhw, arguments);
+ }
+ return mhw.getCachedMethodHandle().invokeExact(arguments);
+ }
+
/**
* Cold-tier dispatch for the {@code groovy.indy.cold.reflection} spike.
* Re-validates the cached selection with plain-Java checks and invokes the
@@ -497,8 +588,14 @@ private static MethodHandle fromCacheHandle(CacheableCallSite callSite, Class>
* reflective path on the same miss.
*/
private static Object invokeColdReflective(ColdReflectiveMethodHandleWrapper cold, Object[] arguments) throws Throwable {
- if (cold.isValidFor(arguments)) {
- if (cold.incrementReflectiveHits() > INDY_OPTIMIZE_THRESHOLD) {
+ // AOT freshness: classValidity.hasBeenInvalidated() can never report true under native
+ // image, so the stamp carries staleness; a mismatch takes the re-selection path below
+ boolean aotStale = cold.callSite.isAotLinked() && cold.getAotStamp() != AotDispatch.stamp();
+ if (!aotStale && cold.isValidFor(arguments)) {
+ // In AOT mode the reflective tier IS the steady state: method-handle chains run in
+ // the native MH interpreter (microseconds/call, no JIT to fold them) while
+ // reflective dispatch uses AOT-compiled invocation stubs — so never promote.
+ if (!cold.callSite.isAotLinked() && cold.incrementReflectiveHits() > INDY_OPTIMIZE_THRESHOLD) {
// no longer cold: build the full guarded chain and replace the
// PIC entry, so even sites the consecutive-hit promotion never
// catches (e.g. polymorphic receivers) leave the reflective
@@ -564,13 +661,17 @@ private static MethodHandle selectMethodHandle(CacheableCallSite callSite, Class
MethodHandle defaultTarget = callSite.getDefaultTarget();
long fallbackCount = callSite.incrementFallbackCount();
- if ((fallbackCount > INDY_FALLBACK_THRESHOLD) && (callSite.getTarget() != defaultTarget)) {
+ if (!callSite.isAotLinked()
+ && (fallbackCount > INDY_FALLBACK_THRESHOLD) && (callSite.getTarget() != defaultTarget)) {
callSite.setTarget(defaultTarget);
if (LOG_ENABLED) LOG.info("call site target reset to default, preparing outside invocation");
callSite.resetFallbackCount();
}
- if (callSite.getTarget() == defaultTarget) {
+ // in AOT mode the effective target is always the default path (the ConstantCallSite
+ // wraps it), so the PIC write-back below must run; getTarget() would report the
+ // never-installed placeholder
+ if (callSite.isAotLinked() || callSite.getTarget() == defaultTarget) {
// correct the stale methodHandle in the inline cache of callsite
// it is important but impacts the performance somehow when cache misses frequently
Object receiver = arguments[0];
@@ -640,10 +741,11 @@ public static CallSite staticArrayAccess(MethodHandles.Lookup lookup, String nam
}
/**
- * Invokedynamic bootstrap for a class's packed-closure dispatcher accessor (GROOVY-12151):
- * links the class's generated dispatch tables into one constant bundle, lazily on first
- * adapter creation. Delegates to {@link GeneratedDispatcher#bootstrap}; hosted here so
- * emitted bytecode references only this central bootstrap surface.
+ * Legacy invokedynamic bootstrap for a class's packed-closure dispatcher accessor
+ * (GROOVY-12151), kept for class files emitted by earlier 6.0 pre-releases: links the
+ * class's generated dispatch tables into one constant bundle, lazily on first adapter
+ * creation. Delegates to {@link GeneratedDispatcher#bootstrap}; hosted here so emitted
+ * bytecode references only this central bootstrap surface.
*
* @since 6.0.0
*/
@@ -651,6 +753,20 @@ public static CallSite packedDispatchers(MethodHandles.Lookup caller, String nam
return GeneratedDispatcher.bootstrap(caller, name, type);
}
+ /**
+ * Invokedynamic bootstrap for a class's packed-closure dispatcher accessor (GROOVY-12151):
+ * the hosting class supplies a factory that builds the bundle from its own bytecode-level
+ * {@code LambdaMetafactory} sites. Nothing is looked up and no class is defined at link
+ * time, so this links unchanged under GraalVM native image (GROOVY-12227). The
+ * three-argument form remains for class files emitted by earlier 6.0 pre-releases.
+ *
+ * @since 6.0.0
+ */
+ public static CallSite packedDispatchers(MethodHandles.Lookup caller, String name, MethodType type,
+ MethodHandle factory) throws Throwable {
+ return GeneratedDispatcher.bootstrap(caller, name, type, factory);
+ }
+
/**
* Constant-dynamic bootstrap for a packed closure literal's declared parameter types
* (GROOVY-12151): decodes a method descriptor into a {@code Class[]} resolved once per
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java
index 33ff56fb1f4..8c0be447382 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java
@@ -34,6 +34,13 @@ class MethodHandleWrapper {
private final MetaMethod method;
private final boolean canSetTarget;
private final AtomicLong latestHitCount = new AtomicLong(0);
+ /**
+ * The global {@code AotDispatch} invalidation stamp at creation. Read only for sites
+ * linked in AOT mode: a mismatch on a PIC hit means the MOP changed since this wrapper
+ * was selected and it must be treated as a miss (the AOT replacement for the SwitchPoint
+ * guards, which cannot fire under native image).
+ */
+ private final long aotStamp = org.apache.groovy.runtime.indy.AotDispatch.stamp();
/**
* Creates a wrapper for the cached and relink targets of a meta method.
@@ -73,6 +80,10 @@ public MethodHandle getTargetMethodHandle() {
*
* @return the wrapped meta method
*/
+ long getAotStamp() {
+ return aotStamp;
+ }
+
public MetaMethod getMethod() {
return method;
}
diff --git a/src/test/groovy/org/codehaus/groovy/runtime/PackedDispatcherFactoryTest.groovy b/src/test/groovy/org/codehaus/groovy/runtime/PackedDispatcherFactoryTest.groovy
new file mode 100644
index 00000000000..da40f5f6e2f
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/runtime/PackedDispatcherFactoryTest.groovy
@@ -0,0 +1,92 @@
+/*
+ * 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.runtime
+
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.junit.jupiter.api.Test
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertThrows
+
+/**
+ * The packed-closure dispatcher linkage (GROOVY-12227): the hosting class's compiler-emitted
+ * {@code $packedDispatchersFactory$} builds the bundle from bytecode-level
+ * {@code LambdaMetafactory} sites, invoked once through
+ * {@link GeneratedDispatcher#bootstrap}. Exercises every dispatch shape through that linkage,
+ * including the transparent propagation of checked exceptions the dispatch interfaces do not
+ * declare.
+ */
+final class PackedDispatcherFactoryTest {
+
+ /** Exercises every dispatch shape: array (3 values), arity-1, arity-2, and a checked throw. */
+ private static final String SRC = '''
+ class Host {
+ static List run() {
+ def results = []
+ def one = { int a -> a * 2 } // arity-1 table
+ def two = { int a, int b -> a + b } // arity-2 table
+ def three = { int a, int b, int c -> a + b + c } // array table
+ results << one(21).toString()
+ results << two(20, 22).toString()
+ results << three(10, 14, 18).toString()
+ results << [1, 2, 3].collect { it + 1 }.toString() // through the GDK
+ results
+ }
+ static void boom() {
+ def thrower = { throw new java.io.IOException('checked, undeclared') }
+ thrower()
+ }
+ }
+ '''
+
+ private static Class parsePacked() {
+ withPacking {
+ def loader = new GroovyClassLoader()
+ def host = loader.parseClass(SRC, 'Host.groovy')
+ assert host.declaredMethods.any { it.name == '$packedDispatch$' } : 'packing did not engage'
+ assert host.declaredMethods.any { it.name == '$packedDispatchersFactory$' } : 'factory not emitted'
+ host
+ }
+ }
+
+ private static T withPacking(Closure work) {
+ String previous = System.getProperty(CompilerConfiguration.CLOSURE_PACKING)
+ System.setProperty(CompilerConfiguration.CLOSURE_PACKING, 'true')
+ try {
+ work.call()
+ } finally {
+ if (previous != null) {
+ System.setProperty(CompilerConfiguration.CLOSURE_PACKING, previous)
+ } else {
+ System.clearProperty(CompilerConfiguration.CLOSURE_PACKING)
+ }
+ }
+ }
+
+ @Test
+ void 'every dispatch shape links and dispatches through the emitted factory'() {
+ assertEquals(['42', '42', '42', '[2, 3, 4]'], parsePacked().run())
+ }
+
+ @Test
+ void 'undeclared checked exceptions propagate unchanged through packed dispatch'() {
+ def thrown = assertThrows(IOException) { parsePacked().boom() }
+ assertEquals('checked, undeclared', thrown.message)
+ }
+}