diff --git a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
index f8a4c752e5d..14c506d0f4b 100644
--- a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
+++ b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
@@ -87,6 +87,11 @@ tasks.withType(Test).configureEach {
if (lambdaHoist != null) {
systemProperty 'groovy.target.lambda.hoist', lambdaHoist
}
+ // Forward the automatic @CompileStatic closure-packing flag (GEP-27) to the test JVM. Set only when present.
+ def closurePack = findProperty('groovy.target.closure.pack') ?: System.getProperty('groovy.target.closure.pack')
+ if (closurePack != null) {
+ systemProperty 'groovy.target.closure.pack', closurePack
+ }
def testdb = System.properties['groovy.testdb.props']
if (testdb) {
systemProperty 'groovy.testdb.props', testdb
@@ -251,6 +256,13 @@ tasks.withType(GroovyCompile).configureEach {
}
}
}
+ // Forward the opt-in closure-packing flag (GEP-27) to the forked Groovy compiler: packing happens
+ // at compile time, so test sources are only packed when the compiler JVM sees the property (the
+ // Test-task systemProperty above covers runtime assertScript compilation). Set only when present.
+ def closurePack = findProperty('groovy.target.closure.pack') ?: System.getProperty('groovy.target.closure.pack')
+ if (closurePack != null) {
+ groovyOptions.forkOptions.jvmArgs += ["-Dgroovy.target.closure.pack=${closurePack}" as String]
+ }
}
class TestCommandLineArgumentProvider implements CommandLineArgumentProvider {
diff --git a/src/main/java/groovy/lang/Closure.java b/src/main/java/groovy/lang/Closure.java
index 3845f661b60..bb7f50010d9 100644
--- a/src/main/java/groovy/lang/Closure.java
+++ b/src/main/java/groovy/lang/Closure.java
@@ -33,6 +33,8 @@
import org.codehaus.groovy.runtime.memoize.ConcurrentSoftCache;
import org.codehaus.groovy.runtime.memoize.LRUCache;
import org.codehaus.groovy.runtime.memoize.Memoize;
+import org.codehaus.groovy.runtime.metaclass.ClosureMetaClass;
+import org.codehaus.groovy.runtime.metaclass.PackedClosureMetaClass;
import java.io.IOException;
import java.io.InvalidObjectException;
@@ -604,7 +606,8 @@ public V call(final Object... arguments) {
private boolean nonStockMetaClass() {
MetaClass mc = super.getMetaClass();
Class> mcClass = (mc == null) ? null : mc.getClass();
- return mcClass != org.codehaus.groovy.runtime.metaclass.ClosureMetaClass.class && mcClass != MetaClassImpl.class;
+ return mcClass != ClosureMetaClass.class && mcClass != MetaClassImpl.class
+ && mcClass != PackedClosureMetaClass.class;
}
@Override
diff --git a/src/main/java/groovy/lang/MetaClassRegistry.java b/src/main/java/groovy/lang/MetaClassRegistry.java
index 5721d9c9ef8..f5574b1c850 100644
--- a/src/main/java/groovy/lang/MetaClassRegistry.java
+++ b/src/main/java/groovy/lang/MetaClassRegistry.java
@@ -19,7 +19,9 @@
package groovy.lang;
import org.codehaus.groovy.runtime.GeneratedClosure;
+import org.codehaus.groovy.runtime.PackedClosure;
import org.codehaus.groovy.runtime.metaclass.ClosureMetaClass;
+import org.codehaus.groovy.runtime.metaclass.PackedClosureMetaClass;
import java.lang.reflect.Constructor;
import java.util.Iterator;
@@ -167,7 +169,14 @@ private MetaClass createWithCustomLookup(Class theClass, MetaClassRegistry regis
* @return the default meta class implementation for the type
*/
protected MetaClass createNormalMetaClass(Class theClass,MetaClassRegistry registry) {
- if (GeneratedClosure.class.isAssignableFrom(theClass)) {
+ if (PackedClosure.class.isAssignableFrom(theClass)) {
+ // the packed adapter family shares its dispatch machinery, so it gets its own
+ // metaclass with reflection-free call/doCall dispatch and instance-faithful
+ // introspection. Checked before GeneratedClosure: the fixed-arity members carry
+ // that marker (for class-level arity introspection) but must not fall into
+ // ClosureMetaClass, whose reflective dispatch assumes per-literal classes.
+ return new PackedClosureMetaClass(registry, theClass);
+ } else if (GeneratedClosure.class.isAssignableFrom(theClass)) {
return new ClosureMetaClass(registry,theClass);
} else {
return new MetaClassImpl(registry, theClass);
diff --git a/src/main/java/groovy/transform/PackedClosures.java b/src/main/java/groovy/transform/PackedClosures.java
new file mode 100644
index 00000000000..cb7e7fb5ba3
--- /dev/null
+++ b/src/main/java/groovy/transform/PackedClosures.java
@@ -0,0 +1,126 @@
+/*
+ * 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 groovy.transform;
+
+import org.apache.groovy.lang.annotation.Incubating;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Opt-in for compact closure compilation: within the annotated class or method, an eligible closure
+ * literal's body is hoisted into a synthetic method on the enclosing class and the literal is replaced
+ * by a shared {@link org.codehaus.groovy.runtime.PackedClosure} adapter, instead of generating one
+ * inner class per closure. The value stays a real {@code groovy.lang.Closure}, so {@code curry},
+ * {@code memoize}, {@code trampoline} and iteration keep working; captured values are threaded by
+ * value (read-only) or via a shared {@code groovy.lang.Reference} (when written), so a packed closure
+ * behaves identically to the class-based form.
+ *
+ * Packing is best-effort: a closure that cannot be packed is compiled exactly as today (a generated
+ * closure class), so the annotation is always safe to add. A closure is kept as a class when it:
+ *
+ *
references {@code owner}, {@code delegate}, {@code thisObject}, {@code resolveStrategy},
+ * {@code directive}, {@code metaClass} or {@code super} — it needs a real {@code Closure};
+ *
has default parameter values, or contains an anonymous inner class;
+ *
is nested inside another closure, or visibly escapes its method (returned, stored to a
+ * field/property/index, initialising a field, appended, or placed in a collection literal);
+ *
is visibly serialization-bound (cast or coerced to a {@code Serializable} type, passed
+ * directly to a {@code writeObject} call, or held in a local that is) — it keeps its class
+ * so serialization works;
+ *
is written where the adapter cannot stand in for a generated class: as an argument to a
+ * {@code this(...)}/{@code super(...)} constructor call, inside a trait, or cast to an
+ * intersection type;
+ *
under dynamic compilation, resolves any free name — an implicit-this call, a bare
+ * field/property-bound or dynamic variable, or an explicit {@code this}-property — that the
+ * delegate chain or the MOP could intercept;
+ *
under {@code @CompileStatic}, resolves a name against a delegate (e.g. via {@code @DelegatesTo}
+ * or {@code with}), is left to runtime resolution by a type-checking extension (mixed-mode
+ * dynamic islands), or touches {@code this}-properties of a {@code Map}-implementing owner —
+ * packing is otherwise proven sound from the type checker's resolution.
+ *
+ * Under dynamic compilation the annotation is a trust assertion the compiler cannot verify; the
+ * {@code PackedClosure} adapter then fails fast if a delegate, or a delegate-consulting
+ * {@code resolveStrategy}, is later set on a packed closure.
+ * Automatic packing of provably-safe {@code @CompileStatic} closures — without this annotation — is
+ * available behind the {@code groovy.target.closure.pack} flag. Use {@link #mode()} to have declines
+ * reported (or to opt a scope out); on the un-annotated flag path,
+ * {@code groovy.target.closure.pack.report=true} reports every decline with its reason as a
+ * compiler warning — the operational way to see where the packability boundary falls in a codebase.
+ *
+ * Because every packed closure is an instance of a small fixed-arity adapter family
+ * ({@code PackedClosure$Fixed0..4}, {@code $FixedIt}, {@code $FixedN}) rather than its own class,
+ * three differences from the class-based form remain that cannot, in general, be detected at
+ * compile time (the visibly serialization-bound cases above are the detectable exception):
+ *
+ *
Serialization: a packed closure is not serializable — attempting it fails fast
+ * at runtime with a message naming the closure and this opt-out (note {@code dehydrate()}
+ * cannot make it serializable — the dispatch state remains — although a dehydrated packed
+ * closure stays callable), so scopes that serialize their closures should not be packed;
+ *
Class identity: {@code closure.getClass()} distinguishes arity (enough for
+ * class-level introspection such as SAM-overload selection) but not individual literals, so
+ * code keyed on per-closure generated class names or types will not find them;
+ *
Class-level metaclass changes: modifying a family member's metaclass affects
+ * every packed closure of that arity, where a per-closure-class change was scoped to one
+ * literal. Per-instance {@code setMetaClass} is fully honoured — a packed closure whose
+ * metaclass has been replaced or wrapped routes all dispatch through it, exactly as a
+ * generated closure class does.
+ *
+ *
+ * @see PackMode
+ */
+@Incubating
+@Retention(RetentionPolicy.SOURCE)
+@Target({ElementType.TYPE, ElementType.METHOD})
+public @interface PackedClosures {
+ /**
+ * The packing behaviour of this scope: {@link PackMode#LENIENT} (pack, silent on declines, the
+ * default), {@link PackMode#WARN} (pack, a compiler warning per decline with the reason),
+ * {@link PackMode#STRICT} (pack, a compiler error on any decline), or {@link PackMode#DISABLED}
+ * (do not pack). The most-specific declaration wins, so a {@code DISABLED} method opts out of a
+ * packed class (and {@code DISABLED} also overrides the automatic {@code groovy.target.closure.pack}
+ * flag); the WARN/STRICT diagnostics apply only to the annotated scope, never to the flag.
+ */
+ PackMode mode() default PackMode.LENIENT;
+
+ /**
+ * The packing behaviour of a {@link PackedClosures} scope. {@link #LENIENT}, {@link #WARN} and
+ * {@link #STRICT} all pack, differing only in how they report a closure that could not be
+ * packed (declines are otherwise silent). {@link #DISABLED} is the opposite: it opts the scope out
+ * of packing entirely, so the most-specific declaration wins — a {@code DISABLED} method inside a
+ * packed class, or vice versa, and it overrides the automatic {@code groovy.target.closure.pack}
+ * flag as well.
+ */
+ @Incubating
+ enum PackMode {
+
+ /** Do not pack any closure in this scope; overrides an enclosing opt-in and the automatic flag. */
+ DISABLED,
+
+ /** Silently fall back to a generated closure class for any closure that cannot be packed (default). */
+ LENIENT,
+
+ /** Emit a compiler warning, naming the closure and why it declined, for each that cannot be packed. */
+ WARN,
+
+ /** Emit a compiler error for any closure in the scope that cannot be packed. */
+ STRICT
+ }
+}
diff --git a/src/main/java/org/codehaus/groovy/ast/ClassNode.java b/src/main/java/org/codehaus/groovy/ast/ClassNode.java
index 5ed4541825a..02f652bd0d3 100644
--- a/src/main/java/org/codehaus/groovy/ast/ClassNode.java
+++ b/src/main/java/org/codehaus/groovy/ast/ClassNode.java
@@ -1820,23 +1820,25 @@ public void visitContents(GroovyClassVisitor visitor) {
}
private void visitMethods(GroovyClassVisitor visitor) {
- // create snapshot of the method list to avoid ConcurrentModificationException
- List methodList = new ArrayList<>(getMethods());
- for (MethodNode mn : methodList) {
- visitor.visitMethod(mn);
- }
-
- // visit the method nodes added while iterating,
- // e.g. synthetic method for constructor reference
- final List newMethodList = getMethods();
- if (newMethodList.size() > methodList.size()) { // if the newly added method nodes found, visit them
- List changedMethodList = new ArrayList<>(newMethodList);
- boolean changed = changedMethodList.removeAll(methodList);
- if (changed) {
- for (MethodNode mn : changedMethodList) {
+ // Visit each method exactly once, including methods added WHILE visiting -- a synthetic method
+ // for a constructor reference, a lambda deserialization hook, or a hoisted closure body that in
+ // turn hoists a nested one. Track already-visited methods by identity (MethodNode uses the
+ // default equals) in a hash set for O(1) membership, and re-scan getMethods() after each round
+ // for the newly added ones, until none remain.
+ Set visited = Collections.newSetFromMap(new java.util.IdentityHashMap<>());
+ List pending = new ArrayList<>(getMethods()); // snapshot avoids ConcurrentModificationException
+ while (!pending.isEmpty()) {
+ for (MethodNode mn : pending) {
+ if (visited.add(mn)) {
visitor.visitMethod(mn);
}
}
+ pending = new ArrayList<>();
+ for (MethodNode mn : getMethods()) {
+ if (!visited.contains(mn)) {
+ pending.add(mn);
+ }
+ }
}
}
diff --git a/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java b/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java
index 46bea1fb5d3..71dc444c52f 100644
--- a/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java
+++ b/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java
@@ -435,6 +435,9 @@ public void visitClass(final ClassNode classNode) {
MopWriter mopWriter = mopWriterFactory.create(controller);
mopWriter.createMopMethods();
controller.getCallSiteWriter().generateCallSiteArray();
+ // GROOVY-12151: after every method (including hoisted bodies added during the
+ // visit) has claimed its id, render the class's packed-closure dispatch table
+ controller.getClosureWriter().writePackedDispatcher();
createSyntheticStaticFields();
}
}
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 6f89a136e82..0554d8f1c0a 100644
--- a/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java
+++ b/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java
@@ -18,12 +18,18 @@
*/
package org.codehaus.groovy.classgen.asm;
+import groovy.transform.PackedClosures.PackMode;
import org.codehaus.groovy.GroovyBugError;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.apache.groovy.util.SystemUtil;
import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.ast.ClassNode;
import org.codehaus.groovy.ast.CodeVisitorSupport;
+import org.codehaus.groovy.ast.DynamicVariable;
import org.codehaus.groovy.ast.ConstructorNode;
import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.PropertyNode;
import org.codehaus.groovy.ast.InnerClassNode;
import org.codehaus.groovy.ast.MethodNode;
import org.codehaus.groovy.ast.Parameter;
@@ -32,42 +38,82 @@
import org.codehaus.groovy.ast.expr.ClassExpression;
import org.codehaus.groovy.ast.expr.ClosureExpression;
import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.ArrayExpression;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+import org.codehaus.groovy.ast.expr.CastExpression;
+import org.codehaus.groovy.ast.expr.DeclarationExpression;
import org.codehaus.groovy.ast.expr.Expression;
import org.codehaus.groovy.ast.expr.FieldExpression;
+import org.codehaus.groovy.ast.expr.ListExpression;
+import org.codehaus.groovy.ast.expr.MapEntryExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.PostfixExpression;
+import org.codehaus.groovy.ast.expr.PrefixExpression;
import org.codehaus.groovy.ast.expr.TupleExpression;
import org.codehaus.groovy.ast.expr.VariableExpression;
import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
+import org.codehaus.groovy.ast.stmt.ReturnStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
import org.codehaus.groovy.ast.tools.GenericsUtils;
import org.codehaus.groovy.classgen.AsmClassGenerator;
+import org.codehaus.groovy.classgen.ReturnAdder;
+import org.codehaus.groovy.control.messages.WarningMessage;
+import org.codehaus.groovy.runtime.GeneratedDispatcher;
+import org.codehaus.groovy.syntax.Types;
+import org.codehaus.groovy.transform.stc.StaticTypesMarker;
+import org.codehaus.groovy.transform.trait.Traits;
import org.objectweb.asm.MethodVisitor;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import static org.apache.groovy.ast.tools.ClassNodeUtils.addGeneratedMethod;
import static org.codehaus.groovy.ast.tools.GeneralUtils.args;
import static org.codehaus.groovy.ast.tools.GeneralUtils.callThisX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.callX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.constX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.ctorSuperX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.nullX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS;
import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt;
import static org.codehaus.groovy.ast.tools.GeneralUtils.varX;
+import static org.codehaus.groovy.transform.sc.StaticCompilationMetadataKeys.STATIC_COMPILE_NODE;
import static org.codehaus.groovy.transform.stc.StaticTypesMarker.INFERRED_RETURN_TYPE;
+import org.objectweb.asm.ConstantDynamic;
+import org.objectweb.asm.Handle;
import static org.objectweb.asm.Opcodes.ACC_FINAL;
import static org.objectweb.asm.Opcodes.ACC_PRIVATE;
import static org.objectweb.asm.Opcodes.ACC_PUBLIC;
import static org.objectweb.asm.Opcodes.ACC_STATIC;
+import static org.objectweb.asm.Opcodes.AALOAD;
+import static org.objectweb.asm.Opcodes.AASTORE;
import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC;
+import static org.objectweb.asm.Opcodes.ACONST_NULL;
import static org.objectweb.asm.Opcodes.ALOAD;
+import static org.objectweb.asm.Opcodes.ANEWARRAY;
+import static org.objectweb.asm.Opcodes.ARETURN;
+import static org.objectweb.asm.Opcodes.ATHROW;
+import static org.objectweb.asm.Opcodes.CHECKCAST;
import static org.objectweb.asm.Opcodes.DUP;
import static org.objectweb.asm.Opcodes.GETFIELD;
+import static org.objectweb.asm.Opcodes.H_INVOKESTATIC;
+import static org.objectweb.asm.Opcodes.ICONST_0;
+import static org.objectweb.asm.Opcodes.ILOAD;
+import static org.objectweb.asm.Opcodes.ISHR;
import static org.objectweb.asm.Opcodes.INVOKESPECIAL;
+import static org.objectweb.asm.Opcodes.INVOKESTATIC;
import static org.objectweb.asm.Opcodes.INVOKEVIRTUAL;
import static org.objectweb.asm.Opcodes.NEW;
@@ -94,6 +140,53 @@ protected interface UseExistingReference {
/** The controller coordinating all bytecode writers for the current class. */
protected final WriterController controller;
private final Map closureClasses = new HashMap<>();
+ // Closures found in a syntactic position that lets them outlive the method are cached per method.
+ private final Map> escapingClosuresByMethod = new HashMap<>();
+ // Closure literals visibly bound for serialization (cast/coerced to Serializable, or passed
+ // directly to writeObject), cached per method like the escape gate above.
+ private final Map> serializationBoundByMethod = new HashMap<>();
+ // Names assigned anywhere in a method (excluding declarations), cached per method: a capture must
+ // be Reference-threaded if it is written ANYWHERE in the enclosing method, not just inside the
+ // closure -- e.g. `def fib; fib = { n -> ... fib(n-1) ... }` writes fib after the closure is
+ // constructed, so a by-value capture would see the stale (null) value.
+ private final Map> writtenNamesByMethod = new HashMap<>();
+
+ /** Name prefix of the synthetic methods holding hoisted closure bodies. */
+ /** Name prefix of hoisted packed-closure bodies (also consulted by the static call-site writer). */
+ public static final String PACKED_METHOD_PREFIX = "$packed$closure$";
+
+ // The class's registered dispatch targets (hoisted closure bodies), in id order. Kept as node
+ // metadata on the CLASS, not on this writer: in a class mixing dynamic and @CompileStatic methods
+ // the controller routes closures to two different ClosureWriter instances (see
+ // StaticTypesWriterController#getClosureWriter), and both must share one id space. The end-of-class
+ // hook (writePackedDispatcher) turns the list into the class's GeneratedDispatcher table.
+ private static final String PACKED_TARGETS = "org.codehaus.groovy.classgen.asm.ClosureWriter.packedTargets";
+ private static final String DISPATCHER_TYPE = "org/codehaus/groovy/runtime/GeneratedDispatcher";
+ private static final String BUNDLE_TYPE = DISPATCHER_TYPE + "$Bundle";
+ // The general table covers every target via a packed argument array; the per-arity tables
+ // cover the hot one/two-value shapes with plain parameters (no array to allocate or escape).
+ private static final String DISPATCH_METHOD = GeneratedDispatcher.TABLE_METHOD;
+ private static final String DISPATCH_DESC = "(I[Ljava/lang/Object;)Ljava/lang/Object;";
+ private static final String DISPATCH1_METHOD = GeneratedDispatcher.TABLE1_METHOD;
+ private static final String DISPATCH1_DESC = "(ILjava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;";
+ private static final String DISPATCH2_METHOD = GeneratedDispatcher.TABLE2_METHOD;
+ private static final String DISPATCH2_DESC = "(ILjava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;";
+ // The accessor's single invokedynamic site links all three tables through
+ // GeneratedDispatcher.bootstrap into one constant Bundle, lazily on first adapter creation.
+ private static final String DISPATCHERS_GETTER = "$getPackedDispatchers$";
+ private static final String DISPATCHERS_GETTER_DESC = "()L" + BUNDLE_TYPE + ";";
+ // 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;
+
+ private static List packedTargets(final ClassNode classNode) {
+ List targets = classNode.getNodeMetaData(PACKED_TARGETS);
+ if (targets == null) {
+ targets = new ArrayList<>();
+ classNode.putNodeMetaData(PACKED_TARGETS, targets);
+ }
+ return targets;
+ }
/**
* Creates a closure writer with the given controller.
@@ -110,6 +203,17 @@ public ClosureWriter(final WriterController controller) {
* @param expression the closure expression
*/
public void writeClosure(final ClosureExpression expression) {
+ // @PackedClosures spike: for an eligible top-level closure in a @PackedClosures scope, hoist the
+ // body to a method on the enclosing class and emit a shared PackedClosure adapter, instead of
+ // generating a per-closure inner class. Captured variables are threaded by value. Closures nested
+ // inside another closure are left alone (their enclosing context is a generated function, not the
+ // owner class), as are closures needing real Closure semantics.
+ if (chooseStrategy(expression) == PackStrategy.PACKED_ADAPTER) {
+ writePackedClosure(expression);
+ return;
+ }
+ reportUnpacked(expression); // @PackedClosures(mode = WARN | STRICT) diagnostics for declines
+
CompileStack compileStack = controller.getCompileStack();
MethodVisitor mv = controller.getMethodVisitor();
ClassNode classNode = controller.getClassNode();
@@ -158,6 +262,1038 @@ public void writeClosure(final ClosureExpression expression) {
controller.getOperandStack().replace(ClassHelper.CLOSURE_TYPE, localVariableParams.length);
}
+ /**
+ * GEP-27 capability analysis: choose the compilation strategy for a closure literal.
+ *
+ * A closure is packed (S1, {@link PackStrategy#PACKED_ADAPTER}) when it is triggered —
+ * either the enclosing scope opts in via {@code @PackedClosures} (the dynamic trust path), or the
+ * capability analysis proves it delegate-independent under {@code @CompileStatic} (the
+ * automatic path, see {@link #isDelegateIndependent}) — and it is in a packable position:
+ * it needs no real {@code Closure} semantics we cannot reproduce ({@link #isPackable}), it is
+ * written directly in a method rather than nested in another closure (owner-retargeting for
+ * nested closures is later work), and it does not visibly escape its method. Otherwise it stays a
+ * full generated class (S0, {@link PackStrategy#FULL_CLASS}) exactly as today.
+ */
+ private PackStrategy chooseStrategy(final ClosureExpression expression) {
+ // The complete packability decision, in order (GEP-27 "The packability decision procedure";
+ // any decline keeps the closure a generated class, exactly as today):
+ // 1. needs real Closure semantics (owner/delegate/thisObject/resolveStrategy/metaClass/
+ // super, default parameter values, anonymous inner class)?
+ if (!isPackable(expression)) return PackStrategy.FULL_CLASS;
+ // 2. scope opted out? The most-specific @PackedClosures mode wins (method over class), and
+ // DISABLED beats an enclosing opt-in AND the flag.
+ PackMode mode = annotatedMode();
+ if (mode == PackMode.DISABLED) return PackStrategy.FULL_CLASS;
+ boolean annotated = (mode != null); // LENIENT/WARN/STRICT all opt in (DISABLED handled above)
+ // 3. triggered and sound? annotation (dynamic trust), flag + syntactic no-free-name proof
+ // (dynamic), or flag/annotation + the type checker's delegate-independence proof (CS,
+ // which also declines mixed-mode dynamic islands and Map-owner property semantics).
+ if (!isPackTriggered(expression, annotated)) return PackStrategy.FULL_CLASS;
+ // 4. structural position: directly in a method of the owner class (nested closures await
+ // owner-retargeting)?
+ if (controller.isInGeneratedFunction()) return PackStrategy.FULL_CLASS;
+ // 5. visibly escapes (field/property/index store, return, <<, collection literal)?
+ if (escapesEnclosingMethod(expression)) return PackStrategy.FULL_CLASS;
+ // 6. visibly serialization-bound (Serializable cast/coercion, writeObject directly or via
+ // a literal-holding local)?
+ if (serializationBound(expression)) return PackStrategy.FULL_CLASS;
+ // 7. a field initializer (a field store the escape walk cannot see)?
+ if (isFieldInitializer(expression)) return PackStrategy.FULL_CLASS;
+ // 8.-10. a context the adapter cannot inhabit: intersection cast (per-literal marker
+ // interfaces), trait body ($Trait$Helper's synthetic receiver), or a this(...)/super(...)
+ // argument (uninitializedThis cannot be the dispatch receiver)?
+ if (isIntersectionTyped(expression)) return PackStrategy.FULL_CLASS;
+ if (isInTraitContext()) return PackStrategy.FULL_CLASS;
+ if (controller.getCompileStack().isInSpecialConstructorCall()) return PackStrategy.FULL_CLASS;
+ return PackStrategy.PACKED_ADAPTER;
+ }
+
+ /**
+ * A closure in a field initializer ({@code def action = { ... }} at class level) is stored
+ * into a field by definition — the same escape the escape gate declines for in-method stores —
+ * but field initializers compile outside the enclosing method's visible code, so the escape
+ * walk cannot see them. Detected directly against the class's fields (identity match).
+ */
+ private boolean isFieldInitializer(final ClosureExpression expression) {
+ for (FieldNode fn : controller.getClassNode().getFields()) {
+ if (fn.getInitialValueExpression() == expression) return true;
+ }
+ return false;
+ }
+
+ /**
+ * A closure literal cast to an intersection type (e.g. {@code (Runnable & Serializable) { }})
+ * needs its generated class to declare the marker interfaces (see
+ * {@code StaticTypesClosureWriter#addIntersectionMarkers}), which the one shared
+ * {@code PackedClosure} adapter cannot do per-literal — so keep it a class. The marker list is
+ * recorded by the type checker; a non-empty one is the signal.
+ */
+ private static boolean isIntersectionTyped(final ClosureExpression expression) {
+ Object markers = expression.getNodeMetaData(StaticTypesMarker.LAMBDA_MARKERS);
+ return markers instanceof List && !((List>) markers).isEmpty();
+ }
+
+ /**
+ * Whether the closure is being compiled inside a trait. A trait's method bodies (and their
+ * closures) are moved into a static {@code $Trait$Helper} nested class whose {@code this} is a
+ * synthetic {@code $self} parameter, not an instance receiver — a shape the packed dispatch
+ * codegen does not reproduce (it produced invalid bytecode). Decline and keep the closure as a
+ * class until the trait context is supported. Detected by walking the enclosing class's outer
+ * chain to the {@code @Trait}-annotated interface.
+ */
+ private boolean isInTraitContext() {
+ for (ClassNode c = controller.getClassNode(); c != null; c = c.getOuterClass()) {
+ if (Traits.isTrait(c)) return true;
+ }
+ return false;
+ }
+
+ /**
+ * Whether a packable, non-escaping closure literal should actually be packed. Dynamic compilation
+ * has no proof of delegate-independence, so the only trigger is the {@code @PackedClosures} trust
+ * assertion; {@code StaticTypesClosureWriter} overrides this to add the
+ * {@code groovy.target.closure.pack} flag and the delegate-independence proof.
+ *
+ * @param annotated whether a non-DISABLED {@code @PackedClosures} is in scope
+ */
+ protected boolean isPackTriggered(final ClosureExpression expression, final boolean annotated) {
+ if (annotated) return true;
+ // Dynamic compilation has no delegate-independence proof from the type checker, so the
+ // annotation is normally the only trigger. But a closure with NO free names -- no
+ // implicit-this call, no unqualified/dynamic variable -- cannot be affected by any
+ // caller-set delegate at all, so it is delegate-independent by syntax alone, no types
+ // needed. The flag auto-packs exactly that provably-safe subset (GROOVY-12151 dynamic
+ // syntactic path); a set delegate on such a closure is a harmless no-op, so it needs no
+ // strict runtime guard (marked here for writePackedClosure).
+ if (SystemUtil.getBooleanSafe(CompilerConfiguration.CLOSURE_PACKING) && isSyntacticallyDelegateIndependent(expression)) {
+ expression.putNodeMetaData(SYNTACTIC_PACK, Boolean.TRUE);
+ return true;
+ }
+ return false;
+ }
+
+ /** Metadata marking a closure packed because it is syntactically delegate-independent (no strict guard). */
+ private static final String SYNTACTIC_PACK = "org.codehaus.groovy.classgen.asm.ClosureWriter.syntacticPack";
+
+ /**
+ * Whether the closure's body contains no name a caller-set delegate could intercept — no
+ * implicit-this method call or property access, and no unqualified/dynamic variable (a free
+ * name the runtime would resolve through the owner/delegate chain). Only parameters, locals,
+ * captured variables, constants and explicit/parameter receivers remain, none of which a
+ * delegate can touch, so such a closure is delegate-independent by construction regardless of
+ * types. Nested closures are walked too (a free name anywhere carries the delegate chain).
+ * Conservative: any doubt returns {@code false} (keep the closure as a class).
+ */
+ private boolean isSyntacticallyDelegateIndependent(final ClosureExpression expression) {
+ Statement code = expression.getCode();
+ if (code == null) return false;
+ boolean[] dependent = {false};
+ code.visit(new CodeVisitorSupport() {
+ @Override public void visitMethodCallExpression(final MethodCallExpression call) {
+ if (call.isImplicitThis()) dependent[0] = true; // foo() -> could be a delegate method
+ super.visitMethodCallExpression(call);
+ }
+ @Override public void visitPropertyExpression(final PropertyExpression pexp) {
+ // implicit-this (bar) could be a delegate property; explicit this.bar inside a
+ // dynamic closure routes through the MOP (getProperty -- where Map-owner entry
+ // semantics and metaclass interception live), which a hoisted body's direct
+ // field shortcut would bypass
+ Expression obj = pexp.getObjectExpression();
+ if (pexp.isImplicitThis()
+ || (obj instanceof VariableExpression && ((VariableExpression) obj).isThisExpression())) {
+ dependent[0] = true;
+ }
+ super.visitPropertyExpression(pexp);
+ }
+ @Override public void visitVariableExpression(final VariableExpression ve) {
+ // only a parameter/local binding is safe: a bare name bound to an owner FIELD or
+ // PROPERTY by VariableScopeVisitor is still a free name at runtime -- a dynamic
+ // closure resolves it through the delegate chain first under DELEGATE_FIRST, and
+ // through the MOP (e.g. an ExpandoMetaClass property) -- as is a DynamicVariable
+ Variable av = ve.getAccessedVariable();
+ if (av instanceof DynamicVariable || av instanceof FieldNode || av instanceof PropertyNode) {
+ dependent[0] = true;
+ }
+ super.visitVariableExpression(ve);
+ }
+ });
+ return !dependent[0];
+ }
+
+ /**
+ * A trigger-specific decline reason for {@link #declineReason}, or {@code null} if the trigger did
+ * not decline this closure. Only the static writer has one (a delegate-resolved body); the dynamic
+ * path trusts the annotation and never declines on trigger grounds.
+ */
+ protected String triggerDeclineReason(final ClosureExpression expression) {
+ return null;
+ }
+
+ /**
+ * The declared type of a read-only (by-value) capture parameter on the hoisted method. The
+ * dynamic writer types it as {@code Object}; the static writer overrides this to the capture's
+ * declared/flow-inferred type so the body compiles with static dispatch. This is the seam that
+ * keeps @CompileStatic-specific typing out of the general (dynamic) emitter.
+ */
+ protected ClassNode readOnlyCaptureType(final String name, final ClassNode declaredType, final Variable variable) {
+ return ClassHelper.OBJECT_TYPE;
+ }
+
+ /** Marks the hoisted body's compilation mode; the static writer overrides this to compile it statically. */
+ protected void markHoistedBody(final MethodNode hoisted) {
+ hoisted.putNodeMetaData(STATIC_COMPILE_NODE, Boolean.FALSE);
+ }
+
+ /**
+ * Whether the packed adapter installs the runtime delegate guard. The dynamic trust path needs it
+ * (an unverifiable assertion must fail fast on misuse); the static writer proved independence, so
+ * it overrides this to {@code false} and a caller-set delegate is then stored and ignored.
+ */
+ protected boolean packedClosureUsesDelegateGuard() {
+ return true;
+ }
+
+ /** The closure's inferred return type, normalised the same way {@code createClosureClass} does for doCall. */
+ private static ClassNode inferredClosureReturnType(final ClosureExpression expression) {
+ ClassNode returnType = expression.getNodeMetaData(INFERRED_RETURN_TYPE);
+ if (returnType == null) returnType = ClassHelper.OBJECT_TYPE;
+ else if (returnType.isPrimaryClassNode()) returnType = returnType.getPlainNodeReference();
+ else if (ClassHelper.isPrimitiveType(returnType)) returnType = ClassHelper.getWrapper(returnType);
+ else if (GenericsUtils.hasUnresolvedGenerics(returnType)) returnType = GenericsUtils.nonGeneric(returnType);
+ return returnType;
+ }
+
+ /**
+ * The erasure of a captured variable's type, safe to declare on the hoisted method: generic
+ * placeholders are replaced by their bound (the hoisted method does not declare the enclosing
+ * method's type variables) and type arguments are dropped ({@code List} → {@code List}).
+ */
+ protected static ClassNode erasedType(final ClassNode type) {
+ ClassNode t = type;
+ if (t == null) return ClassHelper.OBJECT_TYPE;
+ if (t.isGenericsPlaceHolder()) t = t.redirect();
+ return t.getPlainNodeReference();
+ }
+
+ /**
+ * Reports a top-level closure that was NOT packed, per the {@code mode} of the enclosing
+ * {@code @PackedClosures} annotation (WARN => compiler warning, STRICT => compiler error). The
+ * automatic {@code groovy.target.closure.pack} path is lenient by default, but setting
+ * {@code groovy.target.closure.pack.report=true} alongside it surfaces every decline (with its
+ * reason) as a warning — the operational way to see where the packability boundary falls in a
+ * real codebase before opting scopes in or out. A nested closure is a structural decline (its
+ * enclosing context is a generated function, not the owner class), so it is not reported.
+ */
+ private void reportUnpacked(final ClosureExpression expression) {
+ if (controller.isInGeneratedFunction()) return;
+ PackMode mode = annotatedMode();
+ if (mode == null) {
+ if (SystemUtil.getBooleanSafe(CompilerConfiguration.CLOSURE_PACKING)
+ && SystemUtil.getBooleanSafe(CompilerConfiguration.CLOSURE_PACKING_REPORT)) {
+ controller.getSourceUnit().addWarning(WarningMessage.LIKELY_ERRORS,
+ "closure packing: closure was not packed -- " + declineReason(expression), expression);
+ }
+ return;
+ }
+ // LENIENT/DISABLED are both silent -- DISABLED asked NOT to pack, so a decline is expected
+ if (mode == PackMode.LENIENT || mode == PackMode.DISABLED) return;
+ String msg = "@PackedClosures: closure was not packed -- " + declineReason(expression);
+ if (mode == PackMode.STRICT) {
+ controller.getSourceUnit().getErrorCollector()
+ .addErrorAndContinue(msg, expression, controller.getSourceUnit());
+ } else {
+ // WARN is opt-in, so emit at LIKELY_ERRORS to show at the default warning level (the plain
+ // addWarning(text, node) uses POSSIBLE_ERRORS, which the default level suppresses). groovyc
+ // surfaces collected warnings on success since GROOVY-12132; Gradle/IDEs already do.
+ controller.getSourceUnit().addWarning(WarningMessage.LIKELY_ERRORS, msg, expression);
+ }
+ }
+
+ /** The most specific {@code @PackedClosures} mode in scope (method wins over class), or null if not annotated. */
+ private PackMode annotatedMode() {
+ MethodNode m = controller.getMethodNode();
+ PackMode mode = (m != null) ? packModeOf(m.getAnnotations()) : null;
+ if (mode != null) return mode;
+ for (ClassNode c = controller.getClassNode(); c != null; c = c.getOuterClass()) {
+ mode = packModeOf(c.getAnnotations());
+ if (mode != null) return mode;
+ }
+ return null;
+ }
+
+ /** The mode of a {@code @PackedClosures} in the given set (LENIENT if present without an explicit mode), or null. */
+ private static PackMode packModeOf(final List annotations) {
+ for (AnnotationNode a : annotations) {
+ if (a.getClassNode() != null && "groovy.transform.PackedClosures".equals(a.getClassNode().getName())) {
+ Expression member = a.getMember("mode");
+ if (member instanceof PropertyExpression) {
+ try {
+ return PackMode.valueOf(((PropertyExpression) member).getPropertyAsString());
+ } catch (IllegalArgumentException ignore) {
+ // malformed member; treat as the default
+ }
+ }
+ return PackMode.LENIENT;
+ }
+ }
+ return null;
+ }
+
+ /** Why {@link #chooseStrategy} declined this (packable, top-level) closure -- the message WARN/STRICT report. */
+ private String declineReason(final ClosureExpression expression) {
+ if (!isPackable(expression)) {
+ return "it needs a real Closure (uses owner/delegate/thisObject/resolveStrategy/super or metaClass, "
+ + "has default parameter values, or contains an anonymous inner class)";
+ }
+ String triggerReason = triggerDeclineReason(expression);
+ if (triggerReason != null) return triggerReason;
+ if (escapesEnclosingMethod(expression)) {
+ return "it escapes its method (returned, stored to a field/property/index, appended, or in a collection literal)";
+ }
+ if (serializationBound(expression)) {
+ return "it is visibly serialization-bound (cast or coerced to a Serializable type, or passed directly "
+ + "to writeObject) and packed closures are not serializable";
+ }
+ if (isFieldInitializer(expression)) {
+ return "it initialises a field (a visible escape: the closure outlives its construction context)";
+ }
+ if (isIntersectionTyped(expression)) {
+ return "it is cast to an intersection type, whose marker interfaces the shared adapter cannot declare";
+ }
+ if (isInTraitContext()) {
+ return "it is declared inside a trait, whose helper-class context packed dispatch does not yet support";
+ }
+ if (controller.getCompileStack().isInSpecialConstructorCall()) {
+ return "it is an argument to a this(...)/super(...) constructor call, where the enclosing instance "
+ + "is not yet initialised, so it cannot be the packed adapter's owner";
+ }
+ return "it is not eligible for packing in this scope";
+ }
+
+ /**
+ * Conservative compile-time escape gate. A packed closure is bound to the owner and backed by a
+ * shared adapter that fails fast if a delegate is later set on it. To avoid that surprise for the
+ * clearest cases, decline (keep a real closure class for) a closure literal that visibly escapes
+ * the method it is written in: stored into a field/property/index (e.g. {@code attrs.optionValue =
+ * { ... }}), returned, appended with {@code <<}, or placed in a list/map literal. Transitive escapes
+ * (via a local that is later returned) are intentionally not chased here; the runtime guard remains
+ * the backstop for those.
+ */
+ private boolean escapesEnclosingMethod(final ClosureExpression expression) {
+ MethodNode m = controller.getMethodNode();
+ if (m == null || m.getCode() == null) return false;
+ return escapingClosuresByMethod
+ .computeIfAbsent(m, k -> collectEscapingClosures(k.getCode()))
+ .contains(expression);
+ }
+
+ private static Set collectEscapingClosures(final Statement code) {
+ Set escaping = Collections.newSetFromMap(new IdentityHashMap<>());
+ code.visit(new CodeVisitorSupport() {
+ private void mark(final Expression e) {
+ if (e instanceof ClosureExpression) escaping.add((ClosureExpression) e);
+ }
+ @Override public void visitReturnStatement(final ReturnStatement statement) {
+ mark(statement.getExpression());
+ super.visitReturnStatement(statement);
+ }
+ @Override public void visitBinaryExpression(final BinaryExpression be) {
+ int op = be.getOperation().getType();
+ if (op == Types.EQUAL && !isLocalTarget(be.getLeftExpression())) {
+ mark(be.getRightExpression()); // assigned to a field/property/index
+ } else if (op == Types.LEFT_SHIFT) {
+ mark(be.getRightExpression()); // appended, e.g. list << { ... }
+ }
+ super.visitBinaryExpression(be);
+ }
+ @Override public void visitListExpression(final ListExpression le) {
+ le.getExpressions().forEach(this::mark);
+ super.visitListExpression(le);
+ }
+ @Override public void visitMapEntryExpression(final MapEntryExpression me) {
+ mark(me.getValueExpression());
+ super.visitMapEntryExpression(me);
+ }
+ });
+ return escaping;
+ }
+
+ /** True when an assignment target is a plain local variable (not a field/property that escapes). */
+ private static boolean isLocalTarget(final Expression lhs) {
+ if (!(lhs instanceof VariableExpression)) return false; // property/field/index target
+ Variable v = ((VariableExpression) lhs).getAccessedVariable();
+ return !(v instanceof FieldNode) && !(v instanceof PropertyNode);
+ }
+
+ /**
+ * Conservative compile-time serialization gate, the same shape as the escape gate: a packed
+ * closure is not serializable, so decline (keep a real closure class for) a closure literal
+ * that is visibly serialization-bound — cast or coerced ({@code as}) to a type that is
+ * or implements {@code Serializable} (including serializable SAM coercions, whose proxy carries
+ * the closure), or passed directly as an argument to a {@code writeObject} call. Transitive
+ * routes (a local later serialized, a callee that serializes its argument) are intentionally
+ * not chased; the adapter's fail-fast {@code writeObject} remains the backstop for those.
+ * Declines are reported by {@code @PackedClosures(mode = WARN | STRICT)} like any other.
+ */
+ private boolean serializationBound(final ClosureExpression expression) {
+ MethodNode m = controller.getMethodNode();
+ if (m == null || m.getCode() == null) return false;
+ return serializationBoundByMethod
+ .computeIfAbsent(m, k -> collectSerializationBoundClosures(k.getCode()))
+ .contains(expression);
+ }
+
+ private static Set collectSerializationBoundClosures(final Statement code) {
+ Set bound = Collections.newSetFromMap(new IdentityHashMap<>());
+ // pass 1: locals directly assigned a closure literal, so pass 2 can follow the canonical
+ // one-hop shape `def c = { ... }; out.writeObject(c)` (a local reassigned a second literal
+ // maps to both -- conservative: either literal declines)
+ Map> literalsByLocal = new HashMap<>();
+ code.visit(new CodeVisitorSupport() {
+ @Override public void visitBinaryExpression(final BinaryExpression be) {
+ if (Types.isAssignment(be.getOperation().getType())
+ && be.getLeftExpression() instanceof VariableExpression
+ && isLocalTarget(be.getLeftExpression())
+ && be.getRightExpression() instanceof ClosureExpression) {
+ literalsByLocal.computeIfAbsent(((VariableExpression) be.getLeftExpression()).getName(),
+ k -> new ArrayList<>()).add((ClosureExpression) be.getRightExpression());
+ }
+ super.visitBinaryExpression(be);
+ }
+ });
+ code.visit(new CodeVisitorSupport() {
+ private void mark(final Expression e) {
+ if (e instanceof ClosureExpression) {
+ bound.add((ClosureExpression) e);
+ } else if (e instanceof VariableExpression) {
+ List held = literalsByLocal.get(((VariableExpression) e).getName());
+ if (held != null) bound.addAll(held);
+ }
+ }
+ @Override public void visitCastExpression(final CastExpression ce) {
+ ClassNode target = ce.getType();
+ if (ClassHelper.SERIALIZABLE_TYPE.equals(target) || target.implementsInterface(ClassHelper.SERIALIZABLE_TYPE)) {
+ mark(ce.getExpression());
+ }
+ super.visitCastExpression(ce);
+ }
+ @Override public void visitMethodCallExpression(final MethodCallExpression mce) {
+ if ("writeObject".equals(mce.getMethodAsString()) && mce.getArguments() instanceof TupleExpression) {
+ ((TupleExpression) mce.getArguments()).getExpressions().forEach(this::mark);
+ }
+ super.visitMethodCallExpression(mce);
+ }
+ });
+ return bound;
+ }
+
+ /**
+ * Spike packability gate: pack read-only closures (including nested and captures). Captured-variable
+ * writes are supported via Reference threading for a flat closure only (declined if the body also
+ * contains a nested closure, or uses an unsupported compound-assignment operator). Anything needing a
+ * real Closure — delegate/owner/thisObject/resolveStrategy or {@code super} — is declined.
+ */
+ private static boolean isPackable(final ClosureExpression expression) {
+ Statement code = expression.getCode();
+ if (code == null) return false;
+ // Default parameter values generate arity-overloaded doCall methods on a closure class;
+ // the single hoisted method cannot reproduce that dispatch, so decline.
+ if (expression.isParameterSpecified()) {
+ for (Parameter p : expression.getParameters()) {
+ if (p.hasInitialExpression()) return false;
+ }
+ }
+ boolean[] ok = {true};
+ code.visit(new CodeVisitorSupport() {
+ @Override public void visitClosureExpression(final ClosureExpression e) {
+ // A nested closure constructed inside the hoisted method gets the enclosing INSTANCE
+ // as its owner instead of the (packed) outer closure object. That is observationally
+ // equivalent for owner-chain resolution, but not for a nested body that names
+ // owner/delegate/thisObject explicitly -- so the outer packs only if every nested
+ // closure is itself packable.
+ if (!isPackable(e)) ok[0] = false;
+ }
+ @Override public void visitVariableExpression(final VariableExpression ve) {
+ if (ve.isSuperExpression() || FORBIDDEN_CLOSURE_NAMES.contains(ve.getName())) ok[0] = false;
+ }
+ @Override public void visitMethodCallExpression(final MethodCallExpression call) {
+ // implicit-this accessor/mutator forms of the same pseudo-properties, e.g. getDelegate()
+ if (call.isImplicitThis() && FORBIDDEN_CLOSURE_CALLS.contains(call.getMethodAsString())) ok[0] = false;
+ super.visitMethodCallExpression(call);
+ }
+ @Override public void visitConstructorCallExpression(final ConstructorCallExpression cce) {
+ // an anonymous inner class in the body is generated against the enclosing closure
+ // class (its outer instance); hoisting would hand it the wrong enclosing instance
+ if (cce.isUsingAnonymousInnerClass()) ok[0] = false;
+ super.visitConstructorCallExpression(cce);
+ }
+ });
+ // All write forms to captured variables are supported: the shared Reference is passed as a
+ // holder parameter, so the ASM generator emits the implicit get()/set() exactly as it does
+ // for a closure class -- no operator restrictions.
+ return ok[0];
+ }
+
+ private static Set capturedNames(final ClosureExpression expression) {
+ Set captured = new HashSet<>();
+ VariableScope vs = expression.getVariableScope();
+ if (vs != null) {
+ for (Iterator it = vs.getReferencedLocalVariablesIterator(); it.hasNext(); ) {
+ captured.add(it.next().getName());
+ }
+ }
+ return captured;
+ }
+
+ /**
+ * Captured variables that must be threaded as the shared {@code groovy.lang.Reference} (holder)
+ * rather than by value: those written (reassigned) anywhere in the enclosing method, and those a
+ * nested closure inside the body also captures. The nested case is a bytecode-shape
+ * requirement, not a mutation one: a nested closure that compiles as a class takes the shared
+ * Reference in its constructor (every captured local is Reference-boxed in the class world), so a
+ * by-value slot would fail verification there; a nested closure that itself packs simply
+ * dereferences the holder. The enclosing frame always has the Reference to pass — any local
+ * captured by a closure is boxed in its declaring frame.
+ */
+ private Set holderCaptureNames(final ClosureExpression expression) {
+ Set captured = capturedNames(expression);
+ Set holder = new HashSet<>(collectWrittenNames(expression.getCode()));
+ MethodNode m = controller.getMethodNode();
+ if (m != null && m.getCode() != null) {
+ holder.addAll(writtenNamesByMethod.computeIfAbsent(m, k -> collectWrittenNames(k.getCode())));
+ }
+ Statement code = expression.getCode();
+ if (code != null) {
+ code.visit(new CodeVisitorSupport() {
+ @Override public void visitClosureExpression(final ClosureExpression nested) {
+ VariableScope nvs = nested.getVariableScope();
+ if (nvs != null) {
+ for (Iterator it = nvs.getReferencedLocalVariablesIterator(); it.hasNext(); ) {
+ holder.add(it.next().getName());
+ }
+ }
+ super.visitClosureExpression(nested);
+ }
+ });
+ }
+ holder.retainAll(captured);
+ return holder;
+ }
+
+ /** Names assigned or incremented in the given code (declarations with initializers excluded). */
+ private static Set collectWrittenNames(final Statement code) {
+ Set written = new HashSet<>();
+ if (code == null) return written;
+ code.visit(new CodeVisitorSupport() { // descends into nested closures
+ @Override public void visitBinaryExpression(final BinaryExpression be) {
+ if (Types.isAssignment(be.getOperation().getType())
+ && !(be instanceof DeclarationExpression)
+ && be.getLeftExpression() instanceof VariableExpression) {
+ written.add(((VariableExpression) be.getLeftExpression()).getName());
+ }
+ super.visitBinaryExpression(be);
+ }
+ @Override public void visitPostfixExpression(final PostfixExpression pe) {
+ recordIncrement(pe.getExpression());
+ super.visitPostfixExpression(pe);
+ }
+ @Override public void visitPrefixExpression(final PrefixExpression pe) {
+ recordIncrement(pe.getExpression());
+ super.visitPrefixExpression(pe);
+ }
+ private void recordIncrement(final Expression operand) {
+ if (operand instanceof VariableExpression) {
+ written.add(((VariableExpression) operand).getName());
+ }
+ }
+ });
+ return written;
+ }
+
+ // NB: metaClass resolves against the closure object ITSELF (per-closure-class identity), which
+ // the shared adapter cannot reproduce, so it declines like the other closure pseudo-properties.
+ private static final Set FORBIDDEN_CLOSURE_NAMES =
+ new HashSet<>(java.util.Arrays.asList("owner", "delegate", "thisObject", "directive",
+ "resolveStrategy", "metaClass"));
+
+ /** Accessor/mutator forms of the same pseudo-properties, called with implicit this. */
+ private static final Set FORBIDDEN_CLOSURE_CALLS =
+ new HashSet<>(java.util.Arrays.asList("getOwner", "getDelegate", "getThisObject", "getDirective",
+ "getResolveStrategy", "setDelegate", "setDirective", "setResolveStrategy",
+ "getMaximumNumberOfParameters", "getParameterTypes", "getMetaClass", "setMetaClass",
+ "invokeMethod", "getProperty", "setProperty"));
+
+ /**
+ * Rebinds captured-variable references in the moved body to the hoisted method's parameters,
+ * matched by the accessed {@link Variable}'s identity rather than by name. Groovy forbids
+ * shadowing an in-scope local/parameter, so a name match would be correct today; keying on identity
+ * keeps it correct even if that scoping rule ever relaxed, and never rewrites an unrelated binding
+ * that happens to share a captured name.
+ */
+ private static void rebindCaptured(final Statement body, final Map capturedParams) {
+ body.visit(new CodeVisitorSupport() {
+ @Override public void visitVariableExpression(final VariableExpression ve) {
+ Parameter p = capturedParams.get(ve.getAccessedVariable());
+ if (p != null) {
+ ve.setAccessedVariable(p);
+ // plain read-only captures become by-value params; holder-threaded captures
+ // (written, or re-captured by a nested closure) stay closure-shared so the
+ // ASM generator routes them through the Reference
+ ve.setClosureSharedVariable(p.isClosureSharedVariable());
+ }
+ }
+ });
+ }
+
+ /**
+ * Emits a packed closure: hoists the body to a synthetic method on the enclosing class (captured
+ * variables become leading, by-value parameters) and constructs a shared {@code PackedClosure}
+ * adapter that dispatches to it — no inner class.
+ */
+ private void writePackedClosure(final ClosureExpression expression) {
+ ClassNode enclosing = controller.getClassNode();
+ MethodVisitor mv = controller.getMethodVisitor();
+ AsmClassGenerator acg = controller.getAcg();
+ OperandStack os = controller.getOperandStack();
+
+ List captured = new ArrayList<>();
+ Map capturedTypes = new HashMap<>();
+ Map capturedVars = new HashMap<>(); // name -> the original captured variable
+ VariableScope vs = expression.getVariableScope();
+ if (vs != null) {
+ for (Iterator it = vs.getReferencedLocalVariablesIterator(); it.hasNext(); ) {
+ Variable v = it.next();
+ captured.add(v.getName());
+ capturedTypes.put(v.getName(), erasedType(v.getOriginType()));
+ capturedVars.put(v.getName(), v);
+ }
+ }
+
+ // An EMPTY parameter array is the implicit-it literal ({ ... }); null is the explicit
+ // zero-parameter form ({ -> ... }), which must report arity 0 (GString's writer-vs-call
+ // branch and SAM matching key on it), exactly as its generated class would.
+ Parameter[] declared = expression.getParameters();
+ // ClosureExpression#getParameters() distinguishes two forms that must NOT be conflated:
+ // null => no parameter list was written: { -> body } -- an explicit ZERO-parameter
+ // closure (arity 0, no implicit "it").
+ // length == 0 => an empty list was written: { body } -- the implicit-parameter closure,
+ // which gets the synthetic "it" (fuzzy 0-or-1 arity).
+ // Merging them (declared == null || length == 0) would give { -> } a spurious "it" and
+ // break the arity a generated closure class presents to GString's writer-vs-call branch
+ // and to SAM-overload selection. The generated-class path below applies the same rule.
+ boolean implicitParam = (declared != null && declared.length == 0);
+ Parameter[] closureParams = implicitParam
+ ? new Parameter[]{new Parameter(ClassHelper.OBJECT_TYPE, "it")}
+ : (declared != null ? declared : Parameter.EMPTY_ARRAY);
+ int arity = closureParams.length;
+
+ Set holderThreaded = holderCaptureNames(expression);
+ Parameter[] methodParams = new Parameter[captured.size() + closureParams.length];
+ Map capturedByVar = new IdentityHashMap<>(); // original variable -> hoisted param
+ for (int i = 0; i < captured.size(); i++) {
+ String cn = captured.get(i);
+ boolean isWritten = holderThreaded.contains(cn);
+ Parameter p;
+ if (isWritten) {
+ // A written capture arrives as the SHARED groovy.lang.Reference itself: declare the
+ // parameter exactly like a closure-class constructor does (originType = value type,
+ // descriptor type = Reference, closure-shared + use-existing-reference), so
+ // CompileStack.init registers it as a holder and the ASM generator emits the implicit
+ // get()/set() on every read/write -- no AST rewriting, and the untouched body keeps
+ // the STC metadata that lets it compile statically.
+ ClassNode vt = capturedTypes.get(cn);
+ if (ClassHelper.isPrimitiveType(vt)) vt = ClassHelper.getWrapper(vt);
+ p = new Parameter(vt, cn);
+ p.setType(ClassHelper.makeReference());
+ p.setClosureSharedVariable(true);
+ p.putNodeMetaData(UseExistingReference.class, Boolean.TRUE);
+ // the static writer's type chooser must see the VALUE type: the holder load already
+ // dereferences, so resolving this variable as Reference would add a bogus checkcast
+ p.putNodeMetaData(StaticTypesMarker.INFERRED_TYPE, vt);
+ } else {
+ // A read-only capture is an Object-typed by-value parameter for a dynamic body; the
+ // static writer overrides readOnlyCaptureType to declare it by its (possibly
+ // flow-inferred) type so the dispatch table's checkcast lands the type the body's use
+ // sites were compiled against.
+ p = new Parameter(readOnlyCaptureType(cn, capturedTypes.get(cn), capturedVars.get(cn)), cn);
+ }
+ methodParams[i] = p;
+ capturedByVar.put(capturedVars.get(cn), p);
+ }
+ System.arraycopy(closureParams, 0, methodParams, captured.size(), closureParams.length);
+
+ Statement body = expression.getCode();
+ rebindCaptured(body, capturedByVar); // read-only -> plain param; written -> holder param
+
+ // In a static method there is no `this`: the owner is the enclosing class and the hoisted
+ // method must be static (loaded/dispatched against the class, not local slot 0).
+ boolean staticContext = controller.isStaticMethod();
+ List targets = packedTargets(enclosing);
+ int id = targets.size(); // ids are dense per class: the dispatcher emits a tableswitch over them
+ String name = PACKED_METHOD_PREFIX + id;
+ // PRIVATE, not public: the body is reached only through the class's $packedDispatch$ table
+ // (a tableswitch case invoking it directly via INVOKESPECIAL/INVOKESTATIC), and a private
+ // method is invoked exactly (no virtual dispatch), so an inherited packed closure never
+ // dispatches to a same-named hoisted method a subclass happens to declare (GROOVY-12151 review).
+ int mods = ACC_PRIVATE | ACC_SYNTHETIC | (staticContext ? ACC_STATIC : 0);
+ // Same return type a generated closure class would give doCall, so implicit return-value
+ // coercion (e.g. GString -> String for a Closure) happens identically. This uses
+ // whatever the type checker recorded (@CompileStatic AND @TypeChecked -- the dynamically
+ // compiled body coerces at return like any declared-return dynamic method), falling back
+ // to Object when no inference ran.
+ ClassNode hoistedReturnType = inferredClosureReturnType(expression);
+ MethodNode hoisted = new MethodNode(name, mods, hoistedReturnType,
+ methodParams, ClassNode.EMPTY_ARRAY, body);
+ // Added after the ReturnAdder phase, so run it ourselves: it wires implicit returns through
+ // every statement shape (trailing expressions, if/else branches, try/catch, ...).
+ new ReturnAdder().visitMethod(hoisted);
+ // Typed static dispatch (GEP-27 phase 4): the original body expressions were already visited
+ // by StaticCompilationVisitor as part of the enclosing method, so they carry the metadata the
+ // static writer needs (DIRECT_METHOD_CALL_TARGET, inferred types) -- including types that were
+ // only inferred (@ClosureParams, implicit it), which arrive for free via checkcasts. Written
+ // captures ride the holder machinery, so their bodies compile statically too.
+ markHoistedBody(hoisted); // base: mark dynamic; the static writer marks it for static compilation
+ addGeneratedMethod(enclosing, hoisted);
+ targets.add(hoisted); // claim the id; writePackedDispatcher renders the table at end of class
+ // GROOVY-12150 visibility: a packed closure has no generated class, so mark it with the hoisted
+ // method it became. The AST browser then renders it via the same path it uses for the generated
+ // closure class of an unpacked closure; the trailing () distinguishes a method from a class.
+ expression.putNodeMetaData(GENERATED_CLOSURE_CLASS, enclosing.getName() + "." + name + "()");
+
+ // Instantiate the fixed-arity family member matching the literal's declared parameter count
+ // (FixedN, with a varargs doCall, serves arities above four; the base class is abstract):
+ // the arity is then visible to class-level introspection — notably MetaClassHelper's
+ // SAM-overload disambiguation, which reflects the argument class's declared doCall —
+ // exactly as on a generated closure class. An implicit-parameter literal gets FixedIt
+ // (doCall() + doCall(Object)), matching the fuzzy "0 or 1" arity a generated class
+ // declares for it. A vararg-shaped literal (trailing array parameter, e.g. { ...z -> })
+ // also gets FixedN: its generated class would declare a VARARG doCall, whose selection
+ // flexibility (zero args collect to an empty array where a fixed one-param doCall would
+ // null-fill) only a varargs declaration reproduces.
+ // A vararg-shaped literal (trailing array parameter, e.g. { ...z -> }) needs the varargs
+ // doCall that only FixedIt/FixedN declare (its generated class declares a vararg doCall,
+ // whose zero-args-collect-to-empty-array selection a fixed one-param doCall cannot mimic).
+ // The family member is chosen at runtime by PackedClosure.create from (implicit, vararg,
+ // arity), so the emitted bytecode references only the factory, not the Fixed* classes.
+ boolean varargShape = !implicitParam && arity > 0 && closureParams[arity - 1].getType().isArray();
+ if (staticContext) {
+ new ClassExpression(enclosing).visit(acg); // owner = the enclosing class (static dispatch)
+ } else {
+ mv.visitVarInsn(ALOAD, 0);
+ os.push(ClassHelper.OBJECT_TYPE); // owner = this
+ }
+ // The class's shared dispatch table plus this body's compile-time id: dispatch is a plain
+ // interface call into a tableswitch (JIT-friendly, unlike a per-instance MethodHandle, which
+ // cannot be constant-folded), and the id binds the exact method so an inherited packed closure
+ // cannot misdispatch to a subclass's same-named method.
+ mv.visitMethodInsn(INVOKESTATIC, BytecodeHelper.getClassInternalName(enclosing), DISPATCHERS_GETTER, DISPATCHERS_GETTER_DESC, false);
+ os.push(ClassHelper.make(GeneratedDispatcher.Bundle.class));
+ BytecodeHelper.pushConstant(mv, id);
+ os.push(ClassHelper.int_TYPE);
+ mv.visitLdcInsn(name);
+ os.push(ClassHelper.STRING_TYPE); // method name (diagnostics only)
+ if (captured.isEmpty()) {
+ mv.visitInsn(ACONST_NULL);
+ os.push(ClassHelper.OBJECT_TYPE.makeArray());
+ } else {
+ // Build Object[] of captured values: written vars pass their shared Reference (so writes
+ // propagate), read-only vars pass their value.
+ BytecodeHelper.pushConstant(mv, captured.size());
+ os.push(ClassHelper.int_TYPE);
+ mv.visitTypeInsn(ANEWARRAY, "java/lang/Object");
+ os.replace(ClassHelper.OBJECT_TYPE.makeArray());
+ for (int i = 0; i < captured.size(); i++) {
+ String cn = captured.get(i);
+ // DUP + index are raw stack shuffling the operand-stack model need not track; only
+ // the value load goes through it (loadReference/acg both leave one entry), so a
+ // single remove(1) after AASTORE keeps the model consistent.
+ mv.visitInsn(DUP);
+ BytecodeHelper.pushConstant(mv, i);
+ if (holderThreaded.contains(cn)) {
+ loadReference(cn, controller); // shared Reference holder
+ } else {
+ new VariableExpression(cn).visit(acg); // value
+ os.box();
+ }
+ mv.visitInsn(AASTORE);
+ os.remove(1);
+ }
+ }
+ // The closure's declared parameter types, so getParameterTypes()-keyed caller behaviour
+ // (DGM arity decisions, vararg collection) matches a generated closure class exactly.
+ // A condy constant (resolved once per literal site, shared by every adapter created
+ // there — as a closure class shares its cached parameter-type array), NOT a fresh array:
+ // building one per creation measurably taxes creation-heavy code. The types travel as a
+ // method descriptor because primitive parameter types have no Class constant-pool form.
+ StringBuilder typesDescriptor = new StringBuilder("(");
+ for (int i = 0; i < arity; i++) {
+ typesDescriptor.append(BytecodeHelper.getTypeDescription(erasedType(closureParams[i].getType())));
+ }
+ typesDescriptor.append(")V");
+ mv.visitLdcInsn(new ConstantDynamic("paramTypes", "[Ljava/lang/Class;",
+ new Handle(H_INVOKESTATIC, DISPATCHER_TYPE, "paramTypes",
+ "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;Ljava/lang/String;)[Ljava/lang/Class;",
+ true), // the bootstrap is a static interface method
+ typesDescriptor.toString()));
+ os.push(ClassHelper.CLASS_Type.makeArray());
+ // strict guard only for the dynamic trust path; a PROVEN delegate-independent closure --
+ // statically (StaticTypesClosureWriter) or syntactically (SYNTACTIC_PACK, dynamic) --
+ // stores-and-ignores a caller-set delegate, like a @CompileStatic closure class.
+ boolean strict = packedClosureUsesDelegateGuard() && expression.getNodeMetaData(SYNTACTIC_PACK) == null;
+ mv.visitInsn(strict ? org.objectweb.asm.Opcodes.ICONST_1 : org.objectweb.asm.Opcodes.ICONST_0);
+ os.push(ClassHelper.boolean_TYPE);
+ mv.visitInsn(implicitParam ? org.objectweb.asm.Opcodes.ICONST_1 : org.objectweb.asm.Opcodes.ICONST_0);
+ os.push(ClassHelper.boolean_TYPE);
+ mv.visitInsn(varargShape ? org.objectweb.asm.Opcodes.ICONST_1 : org.objectweb.asm.Opcodes.ICONST_0);
+ os.push(ClassHelper.boolean_TYPE);
+ // one factory call, not a hard-wired NEW of the arity-specific subclass (GROOVY-12151 review):
+ // the Fixed* family is chosen inside create, keeping it off the emitted-bytecode surface.
+ mv.visitMethodInsn(INVOKESTATIC, "org/codehaus/groovy/runtime/PackedClosure", "create",
+ "(Ljava/lang/Object;L" + BUNDLE_TYPE + ";ILjava/lang/String;[Ljava/lang/Object;[Ljava/lang/Class;ZZZ)Lorg/codehaus/groovy/runtime/PackedClosure;", false);
+ os.replace(ClassHelper.CLOSURE_TYPE, 9); // owner, dispatchers, id, name, captured[], paramTypes[], strict, implicit, vararg -> Closure
+ }
+
+ /**
+ * Emits the class's packed-closure dispatch machinery, once per class at the end of class
+ * generation (so every hoisted body — including one a hoisted body itself hoisted — has claimed
+ * its id): the {@code $packedDispatch$(int, Object[])} table, whose case {@code k} casts the
+ * packed argument array ({@code [owner, captured..., args...]}) to target {@code k}'s declared
+ * parameter types and invokes it directly; the array-free per-arity tables
+ * ({@code $packedDispatch1$}/{@code $packedDispatch2$}) doing the same from plain parameter
+ * slots for the hot one/two-value shapes; and the {@code $getPackedDispatchers$()} accessor,
+ * whose single {@code invokedynamic} site links all three through
+ * {@link GeneratedDispatcher#bootstrap} into one constant
+ * {@code Bundle}, lazily on first adapter creation. A no-op for classes that packed nothing.
+ */
+ public void writePackedDispatcher() {
+ ClassNode enclosing = controller.getClassNode();
+ List targets = enclosing.getNodeMetaData(PACKED_TARGETS);
+ if (targets == null || targets.isEmpty()) return;
+ enclosing.removeNodeMetaData(PACKED_TARGETS);
+ String internal = BytecodeHelper.getClassInternalName(enclosing);
+ org.objectweb.asm.ClassVisitor cv = controller.getClassVisitor();
+
+ // the accessor: return INDY packedDispatchers()Bundle — 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
+ MethodVisitor mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCHERS_GETTER, DISPATCHERS_GETTER_DESC, null, null);
+ mv.visitCode();
+ Handle bootstrap = new Handle(
+ H_INVOKESTATIC, DISPATCHER_TYPE, "bootstrap",
+ "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;",
+ true); // the bootstrap is a static interface method
+ mv.visitInvokeDynamicInsn("packedDispatchers", DISPATCHERS_GETTER_DESC, bootstrap);
+ mv.visitInsn(ARETURN);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+
+ // 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
+ writeArityTable(cv, internal, enclosing, targets, 1, DISPATCH1_METHOD, DISPATCH1_DESC);
+ writeArityTable(cv, internal, enclosing, targets, 2, DISPATCH2_METHOD, DISPATCH2_DESC);
+
+ // The table. It must stay under the JIT's inlining budget (FreqInlineSize, 325 bytecode
+ // bytes) or a hot caller cannot fold a constant id through to a direct call of the target
+ // (and the packed argument array then escapes instead of being scalar-replaced). A switch
+ // case costs ~20 bytes, so past DISPATCH_CHUNK targets the table goes two-level: the entry
+ // method shifts the id down to a chunk index and forwards -- small enough to always inline
+ // -- and each chunk method switches over its slice of at most DISPATCH_CHUNK cases.
+ int n = targets.size();
+ if (n <= DISPATCH_CHUNK) {
+ mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCH_METHOD, DISPATCH_DESC, null, null);
+ mv.visitCode();
+ writeDispatchSwitch(mv, internal, enclosing, targets, 0, n);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ } else {
+ int chunks = (n + DISPATCH_CHUNK - 1) / DISPATCH_CHUNK;
+ mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCH_METHOD, DISPATCH_DESC, null, null);
+ mv.visitCode();
+ org.objectweb.asm.Label dflt = new org.objectweb.asm.Label();
+ org.objectweb.asm.Label[] labels = new org.objectweb.asm.Label[chunks];
+ for (int c = 0; c < chunks; c += 1) labels[c] = new org.objectweb.asm.Label();
+ mv.visitVarInsn(ILOAD, 0);
+ BytecodeHelper.pushConstant(mv, Integer.numberOfTrailingZeros(DISPATCH_CHUNK));
+ mv.visitInsn(ISHR);
+ mv.visitTableSwitchInsn(0, chunks - 1, dflt, labels);
+ for (int c = 0; c < chunks; c += 1) {
+ mv.visitLabel(labels[c]);
+ mv.visitVarInsn(ILOAD, 0);
+ mv.visitVarInsn(ALOAD, 1);
+ mv.visitMethodInsn(INVOKESTATIC, internal, DISPATCH_METHOD + c, DISPATCH_DESC, false);
+ mv.visitInsn(ARETURN);
+ }
+ mv.visitLabel(dflt);
+ writeDispatchDefault(mv, enclosing);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ for (int c = 0; c < chunks; c += 1) {
+ mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCH_METHOD + c, DISPATCH_DESC, null, null);
+ mv.visitCode();
+ writeDispatchSwitch(mv, internal, enclosing, targets, c * DISPATCH_CHUNK, Math.min(n, (c + 1) * DISPATCH_CHUNK));
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ }
+ }
+ }
+
+ /** A dispatch table slice: a tableswitch over ids {@code [lo, hi)} into direct target invocations. */
+ private static void writeDispatchSwitch(final MethodVisitor mv, final String internal, final ClassNode enclosing, final List targets, final int lo, final int hi) {
+ org.objectweb.asm.Label dflt = new org.objectweb.asm.Label();
+ org.objectweb.asm.Label[] labels = new org.objectweb.asm.Label[hi - lo];
+ for (int i = 0; i < hi - lo; i += 1) labels[i] = new org.objectweb.asm.Label();
+ mv.visitVarInsn(ILOAD, 0);
+ mv.visitTableSwitchInsn(lo, hi - 1, dflt, labels);
+ for (int k = lo; k < hi; k += 1) {
+ mv.visitLabel(labels[k - lo]);
+ MethodNode target = targets.get(k);
+ boolean staticTarget = target.isStatic();
+ if (!staticTarget) {
+ mv.visitVarInsn(ALOAD, 1);
+ mv.visitInsn(ICONST_0);
+ mv.visitInsn(AALOAD);
+ mv.visitTypeInsn(CHECKCAST, internal);
+ }
+ Parameter[] params = target.getParameters();
+ for (int i = 0; i < params.length; i += 1) {
+ mv.visitVarInsn(ALOAD, 1);
+ BytecodeHelper.pushConstant(mv, i + 1); // [0] is the receiver slot, params start at [1]
+ mv.visitInsn(AALOAD);
+ BytecodeHelper.doCast(mv, params[i].getType()); // checkcast, or unbox for a primitive
+ }
+ String desc = BytecodeHelper.getMethodDescriptor(target.getReturnType(), params);
+ // a hoisted body is private, so INVOKESPECIAL dispatches it exactly (never virtually)
+ mv.visitMethodInsn(staticTarget ? INVOKESTATIC : INVOKESPECIAL, internal, target.getName(), desc, false);
+ // hoisted return types are reference types by construction (see inferredClosureReturnType);
+ // box defensively so a future primitive-returning target cannot emit invalid bytecode
+ ClassNode returnType = target.getReturnType();
+ if (ClassHelper.isPrimitiveVoid(returnType)) {
+ mv.visitInsn(ACONST_NULL);
+ } else if (ClassHelper.isPrimitiveType(returnType)) {
+ BytecodeHelper.doCastToWrappedType(mv, returnType, ClassHelper.getWrapper(returnType));
+ }
+ mv.visitInsn(ARETURN);
+ }
+ mv.visitLabel(dflt);
+ writeDispatchDefault(mv, enclosing);
+ }
+
+ /**
+ * An array-free dispatch table for the targets whose captured-plus-argument count is exactly
+ * {@code paramCount}: each case loads the receiver and arguments from parameter slots — no
+ * packed array — and invokes the target directly. Membership is sparse over the id space, so
+ * cases switch with {@code lookupswitch}; ids of other arities land on the default (routing is
+ * the adapter's responsibility, so reaching it is a compiler bug). Past {@link #DISPATCH_CHUNK}
+ * members the table goes two-level under the same inline-budget discipline as the array table:
+ * the entry method shifts the id down to a chunk index and forwards, and each chunk method
+ * switches over its id-range's members (at most {@code DISPATCH_CHUNK}, since a range spans
+ * {@code DISPATCH_CHUNK} consecutive ids).
+ */
+ 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) {
+ List memberIds = new ArrayList<>();
+ for (int k = 0; k < targets.size(); k += 1) {
+ if (targets.get(k).getParameters().length == paramCount) memberIds.add(k);
+ }
+ MethodVisitor mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, tableMethod, tableDesc, null, null);
+ mv.visitCode();
+ if (memberIds.size() <= DISPATCH_CHUNK) {
+ writeAritySwitch(mv, internal, enclosing, targets, memberIds, paramCount);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ return;
+ }
+ int shift = Integer.numberOfTrailingZeros(DISPATCH_CHUNK);
+ int chunks = ((targets.size() - 1) >> shift) + 1;
+ List> byChunk = new ArrayList<>(chunks);
+ for (int c = 0; c < chunks; c += 1) byChunk.add(new ArrayList<>());
+ for (int memberId : memberIds) byChunk.get(memberId >> shift).add(memberId);
+ org.objectweb.asm.Label dflt = new org.objectweb.asm.Label();
+ org.objectweb.asm.Label[] labels = new org.objectweb.asm.Label[chunks];
+ for (int c = 0; c < chunks; c += 1) {
+ // an id-range with no members of this arity shares the default (compiler-bug) label
+ labels[c] = byChunk.get(c).isEmpty() ? dflt : new org.objectweb.asm.Label();
+ }
+ mv.visitVarInsn(ILOAD, 0);
+ BytecodeHelper.pushConstant(mv, shift);
+ mv.visitInsn(ISHR);
+ mv.visitTableSwitchInsn(0, chunks - 1, dflt, labels);
+ for (int c = 0; c < chunks; c += 1) {
+ if (byChunk.get(c).isEmpty()) continue;
+ mv.visitLabel(labels[c]);
+ mv.visitVarInsn(ILOAD, 0);
+ for (int p = 0; p <= paramCount; p += 1) {
+ mv.visitVarInsn(ALOAD, 1 + p); // the receiver, then the value parameters
+ }
+ mv.visitMethodInsn(INVOKESTATIC, internal, tableMethod + c, tableDesc, false);
+ mv.visitInsn(ARETURN);
+ }
+ mv.visitLabel(dflt);
+ writeDispatchDefault(mv, enclosing);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ for (int c = 0; c < chunks; c += 1) {
+ if (byChunk.get(c).isEmpty()) continue;
+ mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, tableMethod + c, tableDesc, null, null);
+ mv.visitCode();
+ writeAritySwitch(mv, internal, enclosing, targets, byChunk.get(c), paramCount);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ }
+ }
+
+ /** An arity-table slice: a lookupswitch over the given member ids into direct target invocations. */
+ private static void writeAritySwitch(final MethodVisitor mv, final String internal,
+ final ClassNode enclosing, final List targets, final List memberIds, final int paramCount) {
+ org.objectweb.asm.Label dflt = new org.objectweb.asm.Label();
+ int members = memberIds.size();
+ int[] keys = new int[members];
+ org.objectweb.asm.Label[] labels = new org.objectweb.asm.Label[members];
+ for (int i = 0; i < members; i += 1) {
+ keys[i] = memberIds.get(i); // ids are claimed in ascending order, as lookupswitch requires
+ labels[i] = new org.objectweb.asm.Label();
+ }
+ mv.visitVarInsn(ILOAD, 0);
+ mv.visitLookupSwitchInsn(dflt, keys, labels);
+ for (int i = 0; i < members; i += 1) {
+ mv.visitLabel(labels[i]);
+ MethodNode target = targets.get(keys[i]);
+ boolean staticTarget = target.isStatic();
+ if (!staticTarget) {
+ mv.visitVarInsn(ALOAD, 1); // the receiver parameter
+ mv.visitTypeInsn(CHECKCAST, internal);
+ }
+ Parameter[] params = target.getParameters();
+ for (int p = 0; p < paramCount; p += 1) {
+ mv.visitVarInsn(ALOAD, 2 + p); // value parameters follow (id, receiver)
+ BytecodeHelper.doCast(mv, params[p].getType()); // checkcast, or unbox for a primitive
+ }
+ String desc = BytecodeHelper.getMethodDescriptor(target.getReturnType(), params);
+ // a hoisted body is private, so INVOKESPECIAL dispatches it exactly (never virtually)
+ mv.visitMethodInsn(staticTarget ? INVOKESTATIC : INVOKESPECIAL, internal, target.getName(), desc, false);
+ ClassNode returnType = target.getReturnType();
+ if (ClassHelper.isPrimitiveVoid(returnType)) {
+ mv.visitInsn(ACONST_NULL);
+ } else if (ClassHelper.isPrimitiveType(returnType)) {
+ BytecodeHelper.doCastToWrappedType(mv, returnType, ClassHelper.getWrapper(returnType));
+ }
+ mv.visitInsn(ARETURN);
+ }
+ mv.visitLabel(dflt);
+ writeDispatchDefault(mv, enclosing);
+ }
+
+ private static void writeDispatchDefault(final MethodVisitor mv, final ClassNode enclosing) {
+ mv.visitTypeInsn(NEW, "org/codehaus/groovy/GroovyBugError");
+ mv.visitInsn(DUP);
+ mv.visitLdcInsn("Invalid packed closure dispatch id in " + enclosing.getName());
+ mv.visitMethodInsn(INVOKESPECIAL, "org/codehaus/groovy/GroovyBugError", "", "(Ljava/lang/String;)V", false);
+ mv.visitInsn(ATHROW);
+ }
+
/**
* Loads a closure-shared variable reference onto the operand stack, looking it up
* as a field, local variable, or outer closure field as appropriate.
diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/PackStrategy.java b/src/main/java/org/codehaus/groovy/classgen/asm/PackStrategy.java
new file mode 100644
index 00000000000..6550d19ba1e
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/classgen/asm/PackStrategy.java
@@ -0,0 +1,35 @@
+/*
+ * 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.classgen.asm;
+
+/**
+ * The compilation strategy the GEP-27 capability analysis selects for a closure literal: keep today's
+ * per-closure class, or pack the closure by hoisting its body to a method on the enclosing class behind
+ * a shared {@code PackedClosure} adapter. The GEP-27 lattice also foresees a metafactory strategy
+ * (functional-interface targets) and object elision, but those are separate, later work and are not
+ * represented here yet.
+ */
+public enum PackStrategy {
+
+ /** S0 — generate the per-closure class (today's behaviour); preserves all {@code Closure} capabilities. */
+ FULL_CLASS,
+
+ /** S1 — hoist the body to a method on the enclosing class behind a shared {@code PackedClosure} adapter. */
+ PACKED_ADAPTER
+}
diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesCallSiteWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesCallSiteWriter.java
index 292f5c787e9..45bd5e68e00 100644
--- a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesCallSiteWriter.java
+++ b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesCallSiteWriter.java
@@ -37,6 +37,7 @@
import org.codehaus.groovy.classgen.BytecodeExpression;
import org.codehaus.groovy.classgen.asm.BytecodeHelper;
import org.codehaus.groovy.classgen.asm.CallSiteWriter;
+import org.codehaus.groovy.classgen.asm.ClosureWriter;
import org.codehaus.groovy.classgen.asm.CompileStack;
import org.codehaus.groovy.classgen.asm.MethodCallerMultiAdapter;
import org.codehaus.groovy.classgen.asm.OperandStack;
@@ -380,7 +381,12 @@ && getField(receiverType, propertyName, FieldNode::isPublic) == null
}
boolean isScriptVariable = (receiverType.isScript() && receiver instanceof VariableExpression && ((VariableExpression) receiver).getAccessedVariable() == null);
- if (!isScriptVariable && controller.getClassNode().getOuterClass() == null) { // inner class still needs dynamic property sequence
+ // a packed closure's hoisted body keeps the closure-context contract: like the inner
+ // closure class it replaces, it falls through to the dynamic property sequence (e.g.
+ // another class's private field, reachable via the MOP) instead of a compile error
+ boolean inPackedBody = controller.getMethodNode() != null
+ && controller.getMethodNode().getName().startsWith(ClosureWriter.PACKED_METHOD_PREFIX);
+ if (!isScriptVariable && !inPackedBody && controller.getClassNode().getOuterClass() == null) { // inner class still needs dynamic property sequence
addPropertyAccessError(receiver, propertyName, receiverType);
}
diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java
index 1b42910b7a4..6631ebb5695 100644
--- a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java
+++ b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java
@@ -20,14 +20,25 @@
import org.codehaus.groovy.GroovyBugError;
import org.codehaus.groovy.ast.ClassCodeVisitorSupport;
+import org.apache.groovy.util.SystemUtil;
import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.CodeVisitorSupport;
import org.codehaus.groovy.ast.MethodNode;
import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.Variable;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.DynamicVariable;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.PropertyNode;
import org.codehaus.groovy.ast.expr.ArrayExpression;
import org.codehaus.groovy.ast.expr.ClosureExpression;
import org.codehaus.groovy.ast.expr.Expression;
import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.Statement;
import org.codehaus.groovy.classgen.asm.ClosureWriter;
import org.codehaus.groovy.classgen.asm.WriterController;
import org.codehaus.groovy.control.SourceUnit;
@@ -41,9 +52,14 @@
import static org.codehaus.groovy.ast.tools.GeneralUtils.args;
import static org.codehaus.groovy.ast.tools.GeneralUtils.callX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.constX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.isOrImplements;
import static org.codehaus.groovy.ast.tools.GeneralUtils.nullX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS;
import static org.codehaus.groovy.ast.tools.GeneralUtils.varX;
+import static org.codehaus.groovy.transform.stc.StaticTypesMarker.DIRECT_METHOD_CALL_TARGET;
+import static org.codehaus.groovy.transform.stc.StaticTypesMarker.DYNAMIC_RESOLUTION;
+import static org.codehaus.groovy.transform.stc.StaticTypesMarker.IMPLICIT_RECEIVER;
+import static org.codehaus.groovy.transform.stc.StaticTypesMarker.INFERRED_TYPE;
import static org.codehaus.groovy.transform.stc.StaticTypesMarker.LAMBDA_MARKERS;
/**
@@ -57,6 +73,142 @@ public StaticTypesClosureWriter(WriterController wc) {
super(wc);
}
+ // Under @CompileStatic the type checker's owner-vs-delegate resolution is authoritative, so the
+ // capability analysis can prove a closure delegate-independent and pack it automatically.
+ @Override
+ protected boolean isPackTriggered(final ClosureExpression expression, final boolean annotated) {
+ // Even the @PackedClosures opt-in needs the proof here: a delegate-resolved body compiles its
+ // IMPLICIT_RECEIVER expressions against Closure.getDelegate(), which a hoisted method cannot honour.
+ return (annotated || SystemUtil.getBooleanSafe(CompilerConfiguration.CLOSURE_PACKING))
+ && isDelegateIndependent(expression)
+ && !touchesMapOwnerProperties(expression);
+ }
+
+ @Override
+ protected String triggerDeclineReason(final ClosureExpression expression) {
+ if (!isDelegateIndependent(expression)) {
+ return "it resolves a name against a delegate (e.g. via @DelegatesTo or with{})";
+ }
+ if (touchesMapOwnerProperties(expression)) {
+ return "it accesses properties of a Map owner, whose closure-context resolution "
+ + "(map entry for members not visible to the closure class) a hoisted body cannot reproduce";
+ }
+ return null;
+ }
+
+ /**
+ * Inside a closure, {@code this.x} (and a bare field-bound {@code x}) on an owner that implements
+ * {@code Map} deliberately resolves through the property MOP: a member not visible to the closure
+ * class falls through to the map entry (the map-property contract pinned by
+ * {@code FieldsAndPropertiesSTCTest}). A hoisted body is a real method of the owner, where the
+ * same expression legally reaches the field directly — a different answer. Decline packing for
+ * closures in Map-implementing owners that touch {@code this}-properties or field-bound names.
+ */
+ private boolean touchesMapOwnerProperties(final ClosureExpression expression) {
+ ClassNode enclosing = controller.getClassNode();
+ if (!isOrImplements(enclosing, ClassHelper.MAP_TYPE)) return false;
+ Statement code = expression.getCode();
+ if (code == null) return false;
+ boolean[] touches = {false};
+ code.visit(new CodeVisitorSupport() {
+ @Override public void visitPropertyExpression(final PropertyExpression pexp) {
+ Expression obj = pexp.getObjectExpression();
+ if (obj instanceof VariableExpression && ((VariableExpression) obj).isThisExpression()) {
+ touches[0] = true;
+ }
+ super.visitPropertyExpression(pexp);
+ }
+ @Override public void visitVariableExpression(final VariableExpression ve) {
+ Object av = ve.getAccessedVariable();
+ if (av instanceof FieldNode
+ || av instanceof PropertyNode) {
+ touches[0] = true;
+ }
+ super.visitVariableExpression(ve);
+ }
+ });
+ return touches[0];
+ }
+
+ // The static writer types read-only captures (declared or flow-inferred) so the hoisted body
+ // compiles with static dispatch, and marks the body for static compilation -- the two seams the
+ // base (dynamic) emitter leaves as Object / dynamic.
+ @Override
+ protected ClassNode readOnlyCaptureType(final String name, final ClassNode declaredType, final Variable variable) {
+ if (variable instanceof ASTNode) {
+ Object inferred = ((ASTNode) variable).getNodeMetaData(INFERRED_TYPE);
+ if (inferred instanceof ClassNode) return erasedType((ClassNode) inferred);
+ }
+ return declaredType;
+ }
+
+ @Override
+ protected void markHoistedBody(final MethodNode hoisted) {
+ hoisted.putNodeMetaData(StaticCompilationMetadataKeys.STATIC_COMPILE_NODE, Boolean.TRUE);
+ }
+
+ @Override
+ protected boolean packedClosureUsesDelegateGuard() {
+ return false; // the type checker proved delegate-independence, so no runtime guard is needed
+ }
+
+ /**
+ * True when the type checker resolved every implicitly-received name in the closure (and its
+ * nested closures) against the owner/parameters rather than a delegate — the delegate-independence
+ * proof of the capability analysis. Under {@code @CompileStatic} STC records each name's resolution
+ * path as {@code IMPLICIT_RECEIVER}: a path ending in {@code "owner"} was resolved against the
+ * owner (safe), anything else (e.g. {@code "delegate"}, from {@code @DelegatesTo} or {@code with})
+ * went through a delegate — the same distinction STC itself makes. The marker can sit on a method
+ * call, a property expression, or a bare variable (e.g. {@code length} inside {@code with}), so
+ * all three are checked. If nothing resolved through a delegate, packing preserves resolution.
+ */
+ private boolean isDelegateIndependent(final ClosureExpression expression) {
+ Statement code = expression.getCode();
+ if (code == null) return false;
+ boolean[] delegateResolved = {false};
+ // Walk the whole closure tree, including nested closures: a delegate resolution anywhere in
+ // the body (e.g. an owner-passed closure whose inner closure resolves a name against the
+ // delegate) means the top closure carries a delegate chain that packing would break.
+ code.visit(new CodeVisitorSupport() {
+ @Override public void visitMethodCallExpression(final MethodCallExpression call) {
+ flag(call.getNodeMetaData(IMPLICIT_RECEIVER));
+ // an implicit-this call STC left to runtime resolution — DYNAMIC_RESOLUTION from a
+ // type-checking extension's makeDynamic (whose virtual MethodNode also lands in
+ // DIRECT_METHOD_CALL_TARGET, so that alone cannot be trusted), or no recorded
+ // resolution at all — goes through the closure's owner/delegate chain (mixed-mode
+ // builder DSLs), so independence is unproven, exactly as for a delegate-resolved name
+ if (call.isImplicitThis()
+ && (call.getNodeMetaData(DYNAMIC_RESOLUTION) != null
+ || (call.getNodeMetaData(IMPLICIT_RECEIVER) == null
+ && call.getNodeMetaData(DIRECT_METHOD_CALL_TARGET) == null))) {
+ delegateResolved[0] = true;
+ }
+ super.visitMethodCallExpression(call);
+ }
+ @Override public void visitPropertyExpression(final PropertyExpression pexp) {
+ flag(pexp.getNodeMetaData(IMPLICIT_RECEIVER));
+ if (pexp.isImplicitThis() && pexp.getNodeMetaData(DYNAMIC_RESOLUTION) != null) {
+ delegateResolved[0] = true; // extension-forced dynamic property (see above)
+ }
+ super.visitPropertyExpression(pexp);
+ }
+ @Override public void visitVariableExpression(final VariableExpression ve) {
+ flag(ve.getNodeMetaData(IMPLICIT_RECEIVER));
+ // a name STC left dynamic (no local/param binding, no recorded receiver path) will be
+ // resolved at runtime through the closure's owner/delegate chain -- not provably owner
+ if (ve.getAccessedVariable() instanceof DynamicVariable
+ && ve.getNodeMetaData(IMPLICIT_RECEIVER) == null) {
+ delegateResolved[0] = true;
+ }
+ super.visitVariableExpression(ve);
+ }
+ private void flag(final Object receiver) {
+ if (receiver instanceof String && !((String) receiver).endsWith("owner")) delegateResolved[0] = true;
+ }
+ });
+ return !delegateResolved[0];
+ }
+
/** {@inheritDoc} */
@Override
protected ClassNode createClosureClass(final ClosureExpression expression, final int mods) {
diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java
index 35ae0e8a7a3..13cd5a57cda 100644
--- a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java
+++ b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java
@@ -18,9 +18,10 @@
*/
package org.codehaus.groovy.classgen.asm.sc;
-import org.apache.groovy.util.SystemUtil;
import org.codehaus.groovy.GroovyBugError;
+import org.apache.groovy.util.SystemUtil;
import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.ast.ClassNode;
import org.codehaus.groovy.ast.CodeVisitorSupport;
import org.codehaus.groovy.ast.ConstructorNode;
@@ -450,7 +451,7 @@ private static boolean containsNestedFunction(final Statement code) {
* the property is read per lambda so it can be toggled without a JVM restart.
*/
private static boolean isLambdaHoistEnabled() {
- return SystemUtil.getBooleanSafe("groovy.target.lambda.hoist");
+ return SystemUtil.getBooleanSafe(CompilerConfiguration.LAMBDA_HOISTING);
}
private int lambdaImplCounter;
diff --git a/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java b/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java
index 95c2a98e196..2041eae9548 100644
--- a/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java
+++ b/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java
@@ -18,6 +18,7 @@
*/
package org.codehaus.groovy.control;
+import org.apache.groovy.lang.annotation.Incubating;
import org.apache.groovy.util.Maps;
import org.codehaus.groovy.GroovyBugError;
import org.codehaus.groovy.control.customizers.CompilationCustomizer;
@@ -67,6 +68,28 @@ public class CompilerConfiguration {
/** Joint Compilation Option for enabling generating stubs in memory. */
public static final String MEM_STUB = "memStub";
+ /**
+ * System-property name for the GEP-27 incubating option that automatically packs provably-safe
+ * {@code @CompileStatic} closures (hoisting their bodies to a shared adapter instead of a class).
+ * Incubating: this switch may change or be removed until the feature graduates.
+ */
+ @Incubating
+ public static final String CLOSURE_PACKING = "groovy.target.closure.pack";
+
+ /**
+ * System-property name for the GEP-27 incubating option that, on the {@link #CLOSURE_PACKING}
+ * path, reports (as compiler warnings) each closure that could not be packed and why.
+ */
+ @Incubating
+ public static final String CLOSURE_PACKING_REPORT = "groovy.target.closure.pack.report";
+
+ /**
+ * System-property name for the GEP-27 incubating option that hoists eligible SAM lambdas to a
+ * metafactory method instead of generating a per-lambda class.
+ */
+ @Incubating
+ public static final String LAMBDA_HOISTING = "groovy.target.lambda.hoist";
+
/** This ("1.4") is the value for targetBytecode to compile for a JDK 1.4. */
@Deprecated public static final String JDK4 = "1.4";
/** This ("1.5") is the value for targetBytecode to compile for a JDK 1.5. */
diff --git a/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java b/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java
new file mode 100644
index 00000000000..fd7c70e367f
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.codehaus.groovy.runtime;
+
+import java.lang.invoke.CallSite;
+import java.lang.invoke.ConstantCallSite;
+import java.lang.invoke.LambdaMetafactory;
+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}.
+ *
+ * The compiler emits per class: an array-shaped table ({@code $packedDispatch$(int, Object[])},
+ * covering every target) plus array-free per-arity tables ({@code $packedDispatch1$},
+ * {@code $packedDispatch2$}) for the hot shapes taking one or two values beyond the receiver —
+ * avoiding the packed argument array, which cannot be scalar-replaced when the dispatch call
+ * does not inline. Each table is a switch over the id whose case casts the arguments to the
+ * target's declared parameter types and invokes it directly, so the whole chain is ordinary,
+ * JIT-friendly bytecode. A single {@code invokedynamic} accessor per class links all three
+ * through {@link #bootstrap} into one constant {@link Bundle}, created lazily on first use.
+ *
+ * Adapters such as {@link PackedClosure} hold {@code (dispatchers, id)} and make a plain
+ * interface call per invocation. Unlike a {@code MethodHandle} held in an instance field —
+ * which the JIT cannot treat as a constant, so every call runs the (comparatively slow)
+ * method-handle invoker — this executes as a cheap indirect call, and inlines fully when the
+ * call site is monomorphic. The id space is not limited to packed closure bodies: any
+ * statically-known target the compiler wants to reach through a shared adapter (for example a
+ * statically-resolved method reference) can claim an id in the same tables.
+ *
+ * @since 6.0.0
+ */
+@FunctionalInterface
+public interface GeneratedDispatcher {
+
+ /** Name of the generated array-shaped dispatch table method. */
+ String TABLE_METHOD = "$packedDispatch$";
+ /** Name of the generated one-value dispatch table method. */
+ String TABLE1_METHOD = "$packedDispatch1$";
+ /** Name of the generated two-value dispatch table method. */
+ String TABLE2_METHOD = "$packedDispatch2$";
+
+ /**
+ * Invokes the dispatch target registered under {@code id} in the hosting class.
+ *
+ * @param id the compile-time-assigned index of the target in the hosting class's table
+ * @param args the packed argument array: {@code args[0]} is the receiver (the adapter's
+ * owner — ignored by static targets), followed by any captured values, then
+ * the call arguments, all in the target's declared parameter order
+ * @return the target's return value ({@code null} for a void target)
+ */
+ Object dispatch(int id, Object[] args);
+
+ /**
+ * Array-free companion for targets taking exactly one value beyond the receiver.
+ * A separate single-method interface so each table is adapted by one
+ * {@code LambdaMetafactory} linkage.
+ */
+ @FunctionalInterface
+ interface Arity1 {
+ /**
+ * Invokes the target registered under {@code id} whose captured-plus-argument
+ * count is exactly one.
+ *
+ * @param id the compile-time-assigned index of the target
+ * @param owner the receiver (ignored by static targets)
+ * @param a the single captured value or call argument
+ * @return the target's return value
+ */
+ Object dispatch1(int id, Object owner, Object a);
+ }
+
+ /**
+ * Array-free companion for targets taking exactly two values beyond the receiver.
+ * @see Arity1
+ */
+ @FunctionalInterface
+ interface Arity2 {
+ /**
+ * Invokes the target registered under {@code id} whose captured-plus-argument
+ * count is exactly two.
+ *
+ * @param id the compile-time-assigned index of the target
+ * @param owner the receiver (ignored by static targets)
+ * @param a the first captured value or call argument
+ * @param b the second captured value or call argument
+ * @return the target's return value
+ */
+ Object dispatch2(int id, Object owner, Object a, Object b);
+ }
+
+ /**
+ * The hosting class's three dispatch shapes, linked once by {@link #bootstrap} and shared
+ * by all of that class's adapters.
+ */
+ final class Bundle {
+ final GeneratedDispatcher dispatcher;
+ final Arity1 arity1;
+ final Arity2 arity2;
+
+ Bundle(final GeneratedDispatcher dispatcher, final Arity1 arity1, final Arity2 arity2) {
+ this.dispatcher = dispatcher;
+ this.arity1 = arity1;
+ this.arity2 = arity2;
+ }
+
+ /** The array-shaped dispatcher, covering every target. */
+ public GeneratedDispatcher dispatcher() { return dispatcher; }
+ /** The one-value dispatcher. */
+ public Arity1 arity1() { return arity1; }
+ /** The two-value dispatcher. */
+ public Arity2 arity2() { return arity2; }
+ }
+
+ /**
+ * 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.
+ *
+ * @param caller the hosting class's lookup (supplied by the JVM)
+ * @param name the invoked name (unused)
+ * @param type the accessor's type: {@code () -> Bundle}
+ * @return a constant call site producing the bundle
+ * @throws Throwable if the tables cannot be found or linked (a compiler bug)
+ */
+ /**
+ * Constant-dynamic bootstrap for a closure literal's declared parameter types: decodes a
+ * method descriptor (which, unlike {@code Class} constants, can carry primitive types) with
+ * the hosting class's loader into a {@code Class[]} resolved once per literal site and shared
+ * by every adapter created there — closure creation then loads one constant instead of
+ * allocating and filling an array. Sharing matches generated closure classes, whose
+ * parameter-type arrays are likewise cached per class.
+ *
+ * @param caller the hosting class's lookup (supplied by the JVM)
+ * @param name the constant's name (unused)
+ * @param type the constant's type: {@code Class[]}
+ * @param descriptor a method descriptor whose parameter types are the closure's declared
+ * parameter types (the return type is ignored)
+ * @return the declared parameter types
+ */
+ static Class>[] paramTypes(final MethodHandles.Lookup caller, final String name, final Class> type, final String descriptor) {
+ return MethodType.fromMethodDescriptorString(descriptor, caller.lookupClass().getClassLoader()).parameterArray();
+ }
+
+ static CallSite bootstrap(final MethodHandles.Lookup caller, final String name, final MethodType type) throws Throwable {
+ Class> host = caller.lookupClass();
+ MethodType arrayType = MethodType.methodType(Object.class, int.class, Object[].class);
+ MethodType oneType = MethodType.methodType(Object.class, int.class, Object.class, Object.class);
+ MethodType twoType = MethodType.methodType(Object.class, int.class, Object.class, Object.class, Object.class);
+ GeneratedDispatcher dispatcher = (GeneratedDispatcher) LambdaMetafactory.metafactory(
+ caller, "dispatch", MethodType.methodType(GeneratedDispatcher.class),
+ arrayType, caller.findStatic(host, TABLE_METHOD, arrayType), arrayType).getTarget().invokeExact();
+ Arity1 arity1 = (Arity1) LambdaMetafactory.metafactory(
+ caller, "dispatch1", MethodType.methodType(Arity1.class),
+ oneType, caller.findStatic(host, TABLE1_METHOD, oneType), oneType).getTarget().invokeExact();
+ Arity2 arity2 = (Arity2) LambdaMetafactory.metafactory(
+ caller, "dispatch2", MethodType.methodType(Arity2.class),
+ twoType, caller.findStatic(host, TABLE2_METHOD, twoType), twoType).getTarget().invokeExact();
+ return new ConstantCallSite(MethodHandles.constant(Bundle.class, new Bundle(dispatcher, arity1, arity2)));
+ }
+}
diff --git a/src/main/java/org/codehaus/groovy/runtime/PackedClosure.java b/src/main/java/org/codehaus/groovy/runtime/PackedClosure.java
new file mode 100644
index 00000000000..8879e25cec3
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/runtime/PackedClosure.java
@@ -0,0 +1,527 @@
+/*
+ * 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 groovy.lang.Closure;
+import org.codehaus.groovy.classgen.asm.util.TypeUtil;
+import org.codehaus.groovy.reflection.ParameterTypes;
+import org.codehaus.groovy.reflection.stdclasses.CachedSAMClass;
+import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation;
+
+/**
+ * The shared {@link Closure} adapter family for {@code @PackedClosures} compact closure
+ * compilation.
+ *
+ * Normally the Groovy compiler generates one inner class per closure literal
+ * (e.g. {@code Owner$_method_closure1}). Under {@code @PackedClosures} an eligible
+ * closure body is instead hoisted into a synthetic method on the enclosing ("owner")
+ * class, and the closure literal is replaced by an instance of the fixed-arity family
+ * member matching its declared parameter count ({@link Fixed0}..{@link Fixed4},
+ * {@link FixedIt} for implicit-parameter literals, {@link FixedN} for higher and vararg
+ * arities), which dispatches back to that method. This removes the per-closure generated
+ * class (and the deeply-nested {@code $_closure1$_closure2$_closure3} name explosion)
+ * while still yielding a real {@code groovy.lang.Closure} instance, so features that
+ * operate through {@code call()} (iteration, {@code curry}, {@code memoize},
+ * {@code trampoline}) continue to work — and the family member's declared {@code doCall}
+ * signature(s) give class-level introspection (SAM-overload selection, MOP method
+ * selection) exactly the view a generated closure class would present.
+ *
+ * Dispatch goes through the hosting class's {@link GeneratedDispatcher} tables: the adapter
+ * holds the class's shared dispatchers, the hoisted method's compile-time id, and its own
+ * dispatch receiver (captured at construction, so {@code dehydrate()}/{@code rehydrate()}
+ * leave the closure callable). A target taking one or two values beyond the receiver — the
+ * overwhelmingly common closure shapes — dispatches through the array-free per-arity tables,
+ * passing the receiver, captured values and arguments as plain parameters; anything else
+ * packs {@code [receiver, captured..., args...]} into one array for the general table.
+ * Either way the chain is ordinary, JIT-friendly bytecode — no reflection, and no
+ * per-instance {@code MethodHandle} (which the JIT cannot constant-fold, making its invoker
+ * path many times slower than a direct call). The id binds the exact hoisted method, so an
+ * inherited packed closure can never misdispatch to a same-named method a subclass happens
+ * to declare. Argument adaptation on every route replicates metaclass dispatch on a
+ * generated closure class: arity mismatches raise {@code MissingMethodException}, a single
+ * {@code List} destructures across a non-one-parameter signature, trailing-array parameters
+ * vararg-collect, and per-parameter coercion follows the metaclass compatibility rules
+ * (including {@code Closure}-to-SAM).
+ */
+public abstract class PackedClosure extends Closure