GEP-27


Metadata
Number

GEP-27

Title

Compact Closure and Lambda Compilation

Version

1

Type

Feature

Status

Draft

Target

Groovy 6.0 (SAM lambdas merged; closure packing a stretch goal) and Groovy 7.0 (object elision, writer unification)

Comment

Reduces the per-closure/per-lambda generated-class explosion by hoisting bodies onto the enclosing class. The sound-by-construction SAM-lambda slice is merged for Groovy 6; closure packing (S1) is implemented and proposed as a Groovy 6 stretch goal (GROOVY-12151); object elision and writer unification remain for Groovy 7. Strictly additive; gated by a flag for back-out.

Leader

Paul King

Created

2026-07-09

Last modification

2026-07-16

Note
WARNING: Material on this page is still under development! We are currently working on Groovy 6.0. The sound-by-construction SAM-lambda part of this proposal is merged for Groovy 6.0, closure packing is implemented and proposed as a Groovy 6.0 stretch goal, and the remainder (object elision, writer unification) targets Groovy 7.0. The final version of this proposal may differ significantly from the current draft, but having this draft available allows us to gather early feedback, align design decisions in Groovy 6 as best we can, and iterate on the design. We welcome feedback and discussion, but please keep in mind that the details are not yet finalized.

Abstract

Every closure literal and (statically compiled) lambda in Groovy is compiled to its own generated class. A method with a handful of closures produces a handful of extra .class files; deeply nested closures produce classes whose names concatenate without bound (Outer$_method_closure1$_closure2$_closure3). This is invisible at the source level but real at the bytecode level: more class files to load, verify, JIT, and hold in metaspace.

This GEP proposes compiling eligible closures and lambdas more compactly by hoisting the body into a method on the enclosing class instead of generating a class per closure. This is exactly how the JVM compiles Java lambdas, and it unifies two today-separate code paths behind one primitive. The value expressed as an object is then chosen to fit what the callsite actually needs:

  • a SAM-targeted lambda becomes a LambdaMetafactory functional-interface instance over the hoisted method — no generated class at all, and a zero-allocation singleton when non-capturing (Java parity);

  • a closure becomes a shared Closure adapter (a small fixed-arity family) that dispatches to the hoisted method — no per-closure class, while remaining a real groovy.lang.Closure (so curry, memoize, trampoline, and iteration continue to work);

  • where a value neither escapes nor needs closure identity, the object can be elided entirely.

The proposal is deliberately phased. A small, sound-by-construction slice — eliminating the generated class for non-capturing and read-only-capturing SAM lambdas — is merged for Groovy 6.0. Closure packing (S1), including its soundness analysis, a per-class dispatch-table path that outruns the generated classes it replaces on capturing shapes, and full MOP transparency, is now implemented and proposed as a Groovy 6.0 stretch goal (GROOVY-12151); the remaining, more aggressive work — object elision and unifying the two writers — targets Groovy 7.0. The change is strictly additive, defaults can be flipped via a flag for back-out, and it is gated so that any tooling that reflects on generated-class names has an escape hatch.

Motivation

The class explosion

Groovy compiles each closure literal to a distinct generated inner class extending groovy.lang.Closure. That class carries a doCall method, a serialVersionUID, its own metaclass initialiser, a constructor taking (owner, thisObject, …​captured), and one Reference-boxed field per captured mutable local. Nesting concatenates names:

class Deep {
    def render(Map m) {
        m.each { k, v ->
            [1, 2].each { i ->
                [3, 4].each { j -> "$k$v$i$j".toString() }
            }
        }
    }
}

compiles to four classes — Deep, Deep$_render_closure1, Deep$_render_closure1$_closure2, and Deep$_render_closure1$_closure2$_closure3 — one per nesting level, names growing with depth.

The cost is not correctness but packaging: class-file bytes, classloading, bytecode verification, JIT profiling, and metaspace, all multiplied by the number of closures. For closure-heavy code — GSP page compilation, builders, DSL-shaped code — this is a measurable tax paid on code that reads as a one-liner.

Lambdas are heavier than Java’s

Even for a statically compiled lambda that targets a functional interface, Groovy generates a full groovy.lang.Closure subclass:

@CompileStatic
class L {
    Function<Integer,Integer> nonCap()      { (Integer x) -> x * 2 }        // -> L$_nonCap_lambda1 (extends Closure)
    Function<Integer,Integer> cap(int base) { (Integer x) -> x + base }     // -> L$_cap_lambda2   (extends Closure)
}

Both generated classes extend Closure and implement GeneratedLambda, each with its own metaclass machinery. Groovy already uses invokedynamic/LambdaMetafactory (so the runtime value is a JDK metafactory Function, and the non-capturing case is already a zero-allocation singleton) — but it still drags along a per-lambda class whose only live part is a static doCall. The equivalent Java lambda emits no class per lambda: the body is a private static synthetic method on the enclosing class and the metafactory bootstraps directly against it.

The primitive that closes this gap for lambdas — "put the body on the enclosing class" — is the same primitive that fixes the closure explosion. That shared primitive is the heart of this GEP.

Design principles

  • Compile the body once, choose the wrapper to fit. A single backend hoists a closure/lambda body to a method on the enclosing class. The object exposed at the callsite (metafactory SAM, shared Closure adapter, or nothing at all) is chosen by what the value must actually satisfy.

  • Never change observable behaviour. An optimised closure/lambda computes the same result, keeps its per-instance identity where it has one, and preserves the Closure API where that API is reachable. Anything a compact representation cannot provide is a reason to decline the optimisation for that closure, not to change its meaning.

  • Sound by syntax where possible, by analysis under static, by declaration otherwise. The one behaviour packing cannot reproduce is delegate resolution of a free name — an unqualified (implicit-this) call or a name the runtime resolves through the owner/delegate chain. A closure that contains no such name (only parameters, locals, captures, constants and explicit/parameter receivers) cannot be affected by any delegate, so it is delegate-independent by syntax alone, no types needed. For closures that do contain a free name, static compilation’s type checker can prove whether it resolves to the owner (safe) or a delegate (unsafe); dynamic compilation cannot prove that, so there the free-name subset is opt-in (declared via @PackedClosures, backed by a runtime guard). Static compilation additionally makes the hoisted body fast (typed dispatch).

  • Ship the safe corner first. The functional-interface (SAM) case is sound by construction — a SAM target cannot express the dangerous closure behaviours. It lands first and proves the shared backend before the broader closure work builds on it.

  • Additive and reversible. Nothing changes for un-optimised code. Each landing is gated by a flag so it can be disabled without recompiling application code.

Background: how closures and lambdas compile today

A closure { params → body }:

  1. becomes a generated InnerClassNode (ClosureWriter.getOrAddClosureClass) — one per literal;

  2. that class extends groovy.lang.Closure implements GeneratedClosure, with a doCall holding the body, captured mutable locals threaded as groovy.lang.Reference constructor arguments/fields, and standard Closure state (owner, thisObject, delegate, resolveStrategy, …​);

  3. at the callsite, new Outer$_m_closure1(this, this, ref…​) is instantiated.

A statically compiled lambda (params) → body targeting a functional interface (StaticTypesLambdaWriter):

  1. still generates a lambda InnerClassNode extending Closure with a doCall (made static when non-capturing);

  2. emits invokedynamic bootstrapped through LambdaMetafactory.metafactory, whose implementation-method handle points at that doCall;

  3. so the runtime value is a JDK-generated functional-interface instance, and the generated lambda class exists only to hold the implementation method.

A dynamically compiled lambda is compiled exactly like a closure (no metafactory).

Two facts make hoisting natural. First, methods can already be added to the enclosing class during code generation and get compiled — Groovy’s own serializable-lambda deserialisation helpers do this. Second, for statically compiled code the type checker has already run, so the inferred types needed to type a hoisted method and to prove an optimisation safe are available on the AST.

The design space

The combinations one could enumerate — dynamic/static, closure/lambda, annotated/not, capturing/not, needs-identity/not, SAM/not — collapse to a small number of axes that actually drive the compilation strategy.

The one that matters: required capability

The question that selects the representation is what must this value be able to do at its use sites? Crucially, this is a short list. A shared Closure adapter that dispatches to a hoisted method is still a real Closure, so it preserves almost everything: it is callable, it has per-instance identity, and curry/rcurry/ncurry, memoize*, trampoline, compose, and iteration all continue to work (they operate through call()).

A hoisted representation loses exactly three things relative to a normal generated closure:

  • Externally-set delegate/resolveStrategy. A hoisted body binds free names at compile time against the owner; a normal closure resolves free names against a delegate set at runtime. This is the one true soundness boundary (see The soundness boundary).

  • Serialization. A dispatch-table-backed adapter is not portably serializable the way a generated closure class is. (dehydrate()/rehydrate() themselves keep working — dispatch holds its own receiver, so a dehydrated packed closure stays callable — but the adapter cannot be written to an object stream.)

  • Per-literal class identity. Packed closures share a small fixed-arity adapter family (PackedClosure$Fixed0..4, $FixedIt for implicit-parameter literals, $FixedN for higher and vararg arities), so closure.getClass() distinguishes arity — enough for class-level introspection such as SAM-overload selection — but not individual literals, and class-level metaclass changes on a family member affect every packed closure of that arity (per-instance setMetaClass is fully honoured — see MOP transparency).

So "needs closure identity" is not a broad lattice; it is essentially the predicate "does externally-set delegate/resolveStrategy or serialization reach this value?"

The one that flips the difficulty: static vs dynamic

Compilation mode is not a strategy selector; it is an information and dispatch modifier that flips which half of the problem is hard:

Dynamic @CompileStatic

Mechanism

Trivial — uniform runtime dispatch, no type story

Harder — the hoisted body must be typed and the adapter must present the right type

Soundness

Split: a closure with no free name is provably safe by syntax; one with a free name cannot be checked without types and is trusted (@PackedClosures + runtime guard)

Feasible for all: types prove owner-vs-delegate resolution of every free name, plus escape and @DelegatesTo detection

Performance upside

Class count (and, for capturing closures, faster dispatch — see Performance)

Also real: the hoisted body can be statically dispatched, and SAM targets reach LambdaMetafactory

So the difficulty is not "dynamic vs static" but "free name vs not". The no-free-name subset — a large and common class (comparators, mappers, predicates over their own parameters, e.g. { x → x + 1 } or { a, b → a <⇒ b }) — is provably safe in either mode with no types. The free-name subset is where static compilation’s proof earns its keep, and where dynamic compilation must fall back to the trust declaration.

The rest are inputs and cost dials

  • Lambda vs closure syntax is a capability declaration, not a separate axis. Arrow syntax already routes a SAM-targeted expression to the lighter (metafactory) writer; it is the programmer saying "treat me as a function." A brace closure and an arrow lambda targeting the same functional interface compile differently today for exactly this reason.

  • The @PackedClosures annotation is the same kind of thing: a programmer assertion that a closure needs no external delegate, supplied where the compiler cannot prove it — i.e. under dynamic compilation, for a closure that resolves a free name (the case a delegate could affect; the no-free-name subset needs no assertion, being provably safe by syntax). It is named for what it packs — closures; a dynamic arrow-lambda is itself a closure, so the same paths apply to it.

  • SAM vs not is the target-type upper bound on capability: a SAM target permits the object-free metafactory path; a Closure/Object target caps at the adapter.

  • Capturing vs not is a cost dial (allocation vs singleton) within whatever strategy is chosen, not a selector.

The strategy lattice

Given the required capability, the compiler picks the lightest representation that satisfies it:

Strategy When Result

S0 full generated class

needs external delegate / serialization, or capability unprovable

today’s behaviour — one class per closure/lambda

S1 shared Closure adapter

closure-shaped, no external delegate/serialization

hoisted body + a fixed-arity adapter family; no per-closure class; curry/memoize preserved

S2 metafactory SAM

functional-interface target, no closure identity needed

hoisted body + LambdaMetafactory; no class; zero-alloc singleton when non-capturing; static-only

S3 elide / inline

value neither escapes nor needs an object (e.g. call-once)

no object at all

Capturing modulates each (non-capturing enables the singleton/static-method form); static mode enables typed dispatch of the hoisted body and the S2 path.

Architecture

One backend, one capability front-end, a wrapper choice:

closure / lambda body
        |
        v
 hoist to a method on the enclosing class      <-- shared backend
        |
   capability inference  (target type; arrow/@PackedClosures declarations;
        |                  static-only escape + @DelegatesTo + serialization analysis)
        v
   +----+----+----------------------+---------------------+
   |         |                      |                     |
  S3        S2                     S1                    S0
 elide   metafactory SAM     PackedClosure adapter   generated class
 (no obj) (no class)         (no per-closure class)   (unchanged)

The elegant consequence is that the closure writer and the lambda writer stop being two separate mechanisms. They share the hoist backend and differ only in which wrapper the capability front-end selects. The existing StaticTypesLambdaWriter becomes a special case of the general design.

The soundness boundary

The single behaviour a hoisted representation cannot reproduce is delegate resolution set by the caller. This is not hypothetical:

class Mk { Closure c() { return { -> who() } }; def who() { 'OWNER' } }
def c = new Mk().c()
c.delegate = [who: { -> 'DELEGATE' }]
c.resolveStrategy = Closure.DELEGATE_FIRST
c()    // a normal closure resolves who() against the delegate -> 'DELEGATE'
       // a hoisted body binds who() to the owner at compile time -> 'OWNER'

The danger has two halves, and they are worth separating. First, the closure must actually resolve a free name that a delegate could intercept — who() above is an unqualified call, so it can. A closure that resolves no free name (only its parameters, locals, captures and constants — { x → x + 1 }) has nothing for any delegate to intercept and is sound regardless. Second, for a closure that does resolve a free name, one must know whether that name binds to the owner (safe to hoist) or could reach a caller-set delegate — and, because the delegate is set by the caller after the closure escapes, an eligibility check that only looks inside the closure for delegate/owner references does not catch it. Under @CompileStatic both are tractable: the type checker resolves every free name (owner vs delegate), and closures handed to @DelegatesTo-annotated parameters are the detectable escape signal. Under dynamic compilation the free-name resolution cannot be proven, so a free-name closure there is opt-in (@PackedClosures + runtime guard) — but the no-free-name subset is provably safe by syntax and can be packed automatically even dynamically (see Automatic dynamic packing of the syntactic subset). Either way a conservative escape analysis keeps a visibly-escaping closure as a class, so the runtime guard’s surface stays small.

By contrast, everything else survives — verified: curry, memoize, and trampoline all continue to work on an adapter-backed closure, because the adapter is a real Closure.

Why lambdas are the safe corner. A functional-interface target cannot express delegate resolution — a Function has no delegate. And a SAM-typed lambda is, by its type, used as that SAM. So the entire soundness cliff is defined away for the SAM case, which is precisely why it can land first.

Guarding the dynamic trust assertion

Under @CompileStatic the escape analysis proves delegate-independence and declines the rest, so the boundary is enforced by construction. Under dynamic compilation @PackedClosures is a trust assertion the compiler cannot verify — so the design must protect a user who annotates in good faith and gets it wrong. Two mechanisms do this, and they deliberately cover different failure modes:

  • Runtime guard (the safety net). A PackedClosure throws — with an actionable message naming the hoisted method — when asked for a configuration it cannot honour: setDelegate(x) to anything other than the owner, or setResolveStrategy(…​) to any non-owner mode (DELEGATE_FIRST, DELEGATE_ONLY, or TO_SELF — which would resolve against the memberless shared adapter). This is fail-fast at the exact point the wrong assumption is exercised, rather than the silent alternative (free names quietly resolve against the owner, surfacing as a confusing MissingMethodException frames away). Almost all delegate-dependent usage funnels through these setters — explicit assignment, with/tap (which set delegate
    DELEGATE_FIRST internally), and builder DSLs — so the guard catches the realistic paths. Setting delegate to the owner stays a no-op, so benign/defensive framework code is unaffected. There is no runtime de-opt option: the hoisted body is already compiled to owner-bound bytecode, so throwing is the only honest response. In the @CompileStatic case the guard should never fire (those closures are proven and declined), but keeping it is cheap defense-in-depth and documents the contract uniformly.

  • @PackedClosures(mode = …​) (compile-time visibility, *not a safety net).* Packing declines are otherwise silent, so the annotation’s mode element surfaces them: LENIENT (the default) stays silent, WARN emits a compiler warning per unpacked closure naming the source position and the reason, and STRICT turns any decline in scope into a compile error. It surfaces the visible declines the compiler already knows about (a closure that resolves against a delegate, escapes its method, or needs a real Closure). It deliberately does not protect against the wrong-assumption case above — an externally-set delegate is invisible at the closure’s definition site (the very reason dynamic packing needs a trust assertion), so WARN/STRICT will report "packed" on exactly the closure the runtime guard later has to catch. Only the annotation opts in; the automatic groovy.target.closure.pack path is always lenient. A fourth value, DISABLED, is the opposite — a fine-grained opt-out that packs nothing in its scope; the most-specific declaration wins, so a DISABLED method opts out of a packed class (and DISABLED overrides the flag too).

Mechanism When Catches

@PackedClosures(mode = WARN or STRICT)

compile time

visible declines — "did I pack what I intended?"

PackedClosure throws on setDelegate/setResolveStrategy

runtime

the invisible wrong assumption — "did anyone misuse what I packed?"

PackedClosure fails fast on serialization (writeObject), naming the hoisted body and the opt-out

runtime

the invisible serialization route (a local later serialized, a callee that serializes its argument); visible routes decline at compile time instead

A conservative escape gate shrinks the runtime surface further and is implemented in the S1 slice: a closure literal that visibly escapes its method — stored into a field/property/index (the Grails static constraints = { … } shape), initialising a field (def action = { … } at class level, which compiles outside the enclosing method’s visible code and is checked against the class’s fields directly), returned, appended with <<, or placed in a collection literal — is declined at compile time and kept as a class. This moves the clearest delegate-risk cases from a runtime throw to a compile-time non-event; those declines are also what WARN/STRICT report. A sibling gate applies the same discipline to the serialization boundary: a literal that is visibly serialization-bound — cast or coerced to a Serializable type, passed directly to a writeObject call, or held in a local that is (the canonical def c = { … }; out.writeObject(c) shape, tracked one def-use hop within the method) — declines and keeps its class (so it serializes as before), with genuinely transitive routes (helper methods, constructor-mediated graphs) left to the adapter’s fail-fast writeObject, exactly as transitive escapes are left to the delegate guard. Because syntactic escape analysis cannot prove that, say, a custom each does not set a delegate, the runtime guard remains the essential backstop under dynamic compilation.

Automatic dynamic packing of the syntactic subset

The delegate concern only arises for a closure that resolves a free name. A closure whose body contains none — no implicit-this method call, no unqualified/dynamic variable, only its parameters, locals, captures, constants and explicit/parameter receivers — cannot be reached by any delegate, so it is delegate-independent by construction, with no type information at all. This is a large and everyday class: { x → x + 1 }, { it * 2 }, { a, b → a <⇒ b }, { it.name }, { x → x + captured }. Such closures are sound to pack under dynamic compilation too, without @PackedClosures and without a runtime guard (a caller-set delegate is a harmless no-op, exactly as on a normal closure), because there is nothing to prove — a purely syntactic check of the body decides it.

This is implemented: with the flag on and no annotation, the closures above auto-pack under dynamic compilation and behave identically (including a set delegate being ignored, not rejected), while a closure with a free name ({ owned() + x }, an implicit-this call, or { x + missing }, a dynamic variable) correctly stays a class. Because a dynamically-compiled lambda is itself compiled as a closure, the same syntactic path also body-hoists the equivalent dynamic lambda subset (e.g. (x) → x + 1) for free — it just does not reach the LambdaMetafactory singleton form, which remains @CompileStatic-only for the mechanical reason that the metafactory needs the target functional-interface type.

This makes the default-on story broader than "the @CompileStatic-proven set": a syntactic delegate-independence check widens automatic packing to the provably-safe dynamic subset as well. Free-name dynamic closures remain opt-in (@PackedClosures + guard), and nested closures still await owner-correct retargeting (Groovy 7) — but the trivial subset need not wait for either.

(A closure that is fully self-contained is also safe to pack even where it escapes — an escaping { x → x + 1 } cannot misbehave — but exploiting that for nested closures needs owner-correct retargeting, see Reference implementation.)

The packability decision procedure

The complete boundary, as implemented (ClosureWriter#chooseStrategy; any decline keeps the closure a generated class exactly as today, and is what WARN/STRICT — or groovy.target.closure.pack.report=true on the flag path — reports with a reason):

  1. Real-Closure semantics? References owner/delegate/thisObject/resolveStrategy/ metaClass/super, has default parameter values, or contains an anonymous inner class → decline.

  2. Scope opted out? The most-specific @PackedClosures mode wins (method over class); DISABLED beats an enclosing opt-in and the flag → decline.

  3. Triggered and sound? One of: the annotation (dynamic trust + runtime guard); the flag plus the syntactic no-free-name proof (dynamic — any implicit-this call or property, field/property-bound or dynamic bare name, or explicit this-property declines); the flag or annotation plus the type checker’s delegate-independence proof (@CompileStatic — which also declines names a type-checking extension left to runtime resolution, and this-properties of Map-implementing owners). Otherwise → decline.

  4. Structural position: written directly in a method of the owner class; nested closures await owner-correct retargeting (Groovy 7) → decline.

  5. Visible escape: stored to a field/property/index, returned, appended with <<, or placed in a collection literal → decline.

  6. Visibly serialization-bound: cast/coerced to a Serializable type, or passed to writeObject directly or via a literal-holding local → decline.

  7. Field initialiser: a class-level def x = { … } (a field store outside any method’s visible code) → decline.

  8. Intersection cast: the generated class would need per-literal marker interfaces → decline.

  9. Trait body: trait methods live in $Trait$Helper with a synthetic receiver → decline.

  10. Constructor special call: an argument to this(…​)/super(…​), where this is not yet initialised → decline.

Everything that passes packs. The matrix is pinned as executable documentation by PackedClosureBoundariesTest (18 shapes × both compilation modes, plus the special-context shapes), and each gate has focused behavioural tests besides.

Groovy 6.0 deliverables

Groovy 6 lands in two parts: the merged sound-by-construction SAM-lambda slice (pieces 1–2), and closure packing (S1), implemented and proposed as a stretch goal (GROOVY-12151).

Merged: SAM-lambda class elimination

Both pieces below are merged (GROOVY-12143), behind the opt-in groovy.target.lambda.hoist flag; the mutated-capture and serializable cases correctly stay on the class-based path.

Piece 1 (merged): eliminate the class for non-capturing SAM lambdas

For a statically compiled, non-capturing, non-serializable lambda targeting a functional interface, do what Java does: emit the body as a private static synthetic method on the enclosing class and bootstrap LambdaMetafactory directly against it — generating no lambda class.

This is sound by construction: the target is a SAM (no delegate possible), the runtime value is already a metafactory instance (so the lambda class was never the value — it was a dead impl-holder), and non-capturing means no capture threading. Measured on a proof-of-concept:

Case Before After

L with nonCap, cap, runnable

L + 3 lambda classes

L + 1 (only the capturing one)

Handlers with 5 non-capturing + 1 capturing lambda

Handlers + 6 lambda classes

Handlers + 1 — 5 classes eliminated, 5 $lambda$ static methods on Handlers

The metafactory singleton is preserved (nonCap().is(nonCap()) == true), stack traces name a $lambda$…​ method on the enclosing class (Java-like), and behaviour is unchanged across Predicate/BiFunction, lambdas in static methods, lambdas calling static methods, and serializable non-capturing round-trips (which fall back to the class-based path). A lambda that reads an instance field (() → count) is correctly not hoisted and keeps working.

The change is localised to StaticTypesLambdaWriter (the existing doCall construction already does the correct non-capturing/instance-member analysis; the hoist re-homes the resulting static method onto the enclosing class). It requires no change to the invokedynamic/metafactory bootstrap other than the implementation method’s owner class, and does not require the class-visitor changes the closure work needs.

Piece 2 (merged): read-only-capturing SAM lambdas

A capturing SAM lambda today instantiates its lambda class to hold the captured values (Groovy even Reference-wraps them). The Java-style form uses a static implementation method taking the captured values as leading arguments, with the metafactory capturing them directly — which also removes the Closure allocation and the Reference wrapping. This is merged for the read-only capture case (the common one): captured values are passed as typed leading parameters. A lambda that mutates a capture still needs the shared Reference, so it correctly declines and keeps its class. This lands the bulk of the capturing benefit in Groovy 6 behind the same flag; only mutated-capture lambdas remain on the class-based path.

Stretch goal (implemented): closure packing (S1)

A shared Closure adapter family (PackedClosure, instantiated as the fixed-arity member matching the literal — Fixed0..Fixed4, FixedIt for implicit-parameter literals, FixedN for higher and vararg arities) that dispatches to a hoisted body, applied to closures rather than lambdas — now implemented in the code generator (GROOVY-12151) and proposed for Groovy 6.0 as a stretch goal. It covers the hard cases: no-capture and captured closures (by value; written captures via shared Reference, including =`/`+), arbitrary-depth nested closures, implicit it, under both @CompileStatic and dynamic compilation, correctly declining closures that use delegate/owner/super, or that carry default parameter values (multi-arity — see Excluded and deferred features). All three safety mechanisms from Guarding the dynamic trust assertion are implemented and validated: the mandatory PackedClosure runtime guard, the compile-time escape decline, and the @PackedClosures(mode = WARN | STRICT) decline diagnostics. The two headline capabilities are done:

  • Capability/escape analysis — packing is provably sound and therefore automatic under @CompileStatic (no annotation) via the type checker’s delegate-independence proof, falling back to S0 (a normal closure class) otherwise; under dynamic compilation it is opt-in via @PackedClosures, backed by the runtime guard.

  • Fast dispatch — two parts. The hoisted body is typed from the inferred types the type checker already computed and compiles statically (typed captures/params/return, zero invokedynamic in the body). And the shared adapter reaches that body through compiler-generated per-class dispatch tables: a general (int id, Object[]) table covering every hoisted target plus array-free per-arity tables for targets taking one or two values beyond the receiver (captures count as values — the overwhelmingly common shapes), each case casting to the declared parameter types and invoking the target directly. One invokedynamic accessor per class links the tables into a constant bundle lazily on first use, and each literal’s parameter-type Class[] is a constant-dynamic resolved once per site. The whole chain is ordinary, JIT-friendly bytecode — no reflection, and no per-instance MethodHandle (not constant-foldable, hence many times slower). Measured with JMH against the generated classes it replaces (same build, both compilations, re-measured after the fixed-arity family and dispatch-semantics hardening): capturing shapes run 1.4× or better packed, mono and megamorphic alike (individual runs have reached 1.9× against slower classed baselines), while the tightest non-capturing collect trails at 0.5–0.7× — a hypersensitive-inlining micro-shape whose day-to-day tuning band that is; on Grails-shaped in-situ workloads (domain pipelines, validation cycles) the blend nets +17–23% packed with identical outputs, so the adverse micro-shape dilutes out under real work.

  • MOP transparency — the direct dispatch path is semantically invisible: it is taken only when the instance’s metaclass is stock and no category is active (one latched-boolean check, the same conditions the classic call-site caches test), and anything MOP-relevant routes through invokeMethod. Interception via a per-instance metaclass is honoured identically on the dynamic and Java/GDK paths, matching generated closure classes. The adapter family has its own registered stock metaclass, PackedClosureMetaClass — the analog of ClosureMetaClass for generated closures — giving reflection-free call/doCall dispatch on the MOP route, respondsTo answers faithful to the instance’s declared parameter types, and the same category stance as ClosureMetaClass. Each fixed-arity member carries the GeneratedClosure marker and declares exactly the doCall signature(s) its generated-class counterpart would (including the fuzzy "0 or 1" pair for implicit-parameter literals), so class-level introspection — SAM-overload selection by closure arity, ClosureMetaMethod construction, MetaClassImpl method selection under a replaced metaclass — behaves exactly as with generated classes; the general dispatch entry is deliberately not named doCall, so no varargs signature exists for MOP selection to mistake a single Object[] argument for an argument list. Argument adaptation replicates metaclass dispatch bit for bit (pinned empirically against classed closures): arity mismatches raise MissingMethodException, a single List destructures across a non-one-parameter signature, trailing-array parameters vararg-collect, and per-parameter coercion follows the metaclass compatibility matrix (BigDecimaldouble yes, StringInteger no, Closure→SAM yes).

The API surface (@PackedClosures, PackMode) is marked @Incubating. What keeps this a stretch goal rather than a committed 6.0 deliverable is that it is opt-in only (the automatic path is not yet default-on) and the nested-closure, elision, and writer-unification work below is still open — all Groovy 7.0.

Groovy 7.0 deliverables

The remaining, higher-value work, plus the hardening that turns closure packing from an opt-in stretch goal into a default-on feature.

Owner-correct retargeting for nested closures

Today a self-contained nested closure (e.g. a Grails validator: { … } block inside constraints) is kept as a class, because hoisting it naively adds the body to the enclosing closure class with the wrong owner (see Grails artefact DSLs and nested closures). Hoisting it soundly requires retargeting the method onto the correct outermost owner, rebinding its free names to that owner, and emitting static-vs-instance dispatch to match. This is the single highest-value target — domain classes generate the most closure classes — and the reason it is a separate, later phase rather than a cheap gate change.

Object elision (S3)

For closures/lambdas that neither escape nor need an object — for example immediately-invoked bodies — elide the object entirely. This is the most aggressive step and depends on escape proof and, for iteration intrinsics, knowledge of the callee.

Unify the two writers

Fold the lambda and closure code paths behind the single hoist backend, so the SAM and Closure cases are one mechanism selecting different wrappers.

Convergence also runs the other way: generated closure classes (S0 — everything packing declines) can adopt the packed dispatch discipline without being packed. The compiler knows every doCall signature at emission time, so it can emit a call(Object…​) override on the closure class that arity-switches directly to the right doCall (with the MOP-transparency guard inlined). The Java/GDK path then becomes plain virtual dispatch — capability-clean under JPMS, no reflection — and the runtime’s reflective doCall cache in Closure.call demotes to serving only legacy jars and hand-written subclasses. That cache is a bridge, not a destination.

A related follow-up surfaced in review: MethodClosure (from .& method references) is the same shape — a Closure whose target is a known method that cannot honour a delegate — but it dispatches by name with runtime overload selection and silently ignores a caller-set delegate, whereas PackedClosure dispatches through a fixed table entry and rejects one. The two are worth contract-aligning (a shared "known-target, delegate-inert" marker and consistent delegate handling), and statically resolved method pointers could claim ids in the same per-class dispatch tables the packed adapter already uses — dispatching with the host class’s own bytecode rights, which also gives caller-sensitivity-correct semantics. Their dispatch models differ enough, though — a fixed table entry versus runtime overload selection — that fully merging them into one path is not a goal; this is tracked as a separate initiative.

Default-on hardening

Flip closure packing from experimental (opt-in groovy.target.closure.pack) to default-on. This is a policy decision gated on the remaining human-only tooling verification (see IDE and tooling considerations) — not on further mechanism work: the dispatch and MOP-transparency mechanisms are complete and verified (interception, categories, and introspection behave identically to generated closure classes on every path), and the remaining documented residuals are those listed under The one that matters: required capability.

Hardening under maximal packing (done)

The strongest evidence for the flip comes from running Groovy’s entire own test suite with the flag forced globally on — core, the test corpus, and everything the tests compile and evaluate at runtime — deliberately packing far beyond any real configuration to find where theory and MOP reality diverge. The campaign started at 39 failing test classes (~140 failures) and ended at one (a single context-dependent case that passes in every standalone reproduction). Everything in between was resolved by root-cause fixes that widened packed coverage rather than narrowing it: the fixed-arity adapter family (restoring class-level arity introspection for SAM-overload selection), argument-adaptation parity pinned empirically against classed closures, holder threading for captures re-captured by nested closures, flow-inferred capture typing, two escape- and serialization-gate extensions (field initialisers; one-hop locals into writeObject), the mixed-mode dynamic-island decline, and closure-context contracts re-homed with the body (dynamic property fallback for cross-class private access, Map-owner entry semantics, inferred return coercion). Tests that assert the pre-pack generated-class bytecode shape are conditionally skipped under the flag with named reasons (they run unchanged by default), and tests whose subject is closure-class serialization declare @PackedClosures(mode = DISABLED) fixtures — every behavioural test in the suite runs and passes packed. The methodology (forced-global flag, per-class JVM isolation, compile-time bytecode verification harnesses, and pinning classed behaviour empirically before replicating it) is repeatable for future slices.

Configuration and flags

Each landing is gated so it can be disabled without recompiling application code, following Groovy’s existing SystemUtil.getBooleanSafe(…​)/static final convention (as used for groovy.target.indy and similar). The gate wraps the single decision branch, so disabled means byte-for-byte today’s behaviour.

Concern Flag Default

SAM lambda hoisting (Groovy 6, merged)

groovy.target.lambda.hoist

off (opt-in)

Closure packing under @CompileStatic (Groovy 6 stretch, GROOVY-12151)

groovy.target.closure.pack (+ per-scope @PackedClosures)

off (opt-in); default-on is Groovy 7

Closure packing under dynamic compilation, free-name closures

@PackedClosures opt-in only

off unless annotated

Closure packing under dynamic compilation, no-free-name subset

groovy.target.closure.pack (syntactic proof)

off (opt-in)

Decline reporting on the flag path

groovy.target.closure.pack.report — every non-packed closure gets a compiler warning naming the gate that declined it

off

@PackedClosures carries a mode element (LENIENT | WARN | STRICT | DISABLED, default LENIENT) that reports closures it could not pack — silently, as a compiler warning, or as a compile error — or, with DISABLED, opts the scope out of packing entirely. The most-specific declaration wins (a DISABLED method beats a packed class), and DISABLED also overrides the flag; the WARN/STRICT diagnostics apply only to the annotation, never to the automatic groovy.target.closure.pack path. Every packed closure is backed by the PackedClosure runtime guard; see Guarding the dynamic trust assertion for what each does and does not catch.

The default direction is a policy decision independent of the mechanism: a feature can ship on with an opt-out flag (deliver the win immediately, keep a back-out valve), or off with an opt-in flag and be flipped a release later (conservative). The same one-line gate serves either way; only the boolean’s default changes.

Note

The flag also eases test migration. Groovy’s current tests assert the old bytecode shape (a doCall on a lambda class); with hoisting disabled those assertions stay valid, while new-structure tests run with it enabled — so the codegen change and the test rewrite need not land in the same commit.

If per-compilation (rather than JVM-wide) control is wanted, the same toggle can be promoted to a CompilerConfiguration option, mirroring how indy is both a system property and a config option. The system property is proposed as the initial back-out mechanism.

IDE and tooling considerations

Because this is a bytecode-shape change, behaviour is preserved but the generated structure a tool might reflect on changes. This surface needs an explicit compatibility pass with IDEs, debuggers, and other tooling before the defaults are turned on broadly:

  • Generated class names. Tools that assume Outer$_method_closure1 / Outer$_m_lambda1 classes exist — by name, by Class.forName, by scanning class files, or by pattern — will not find them for hoisted closures/lambdas. This includes coverage tools, decompilers, bytecode viewers, and any reflection keyed on the generated naming scheme.

  • Marker types. GeneratedClosure/GeneratedLambda are not present on a metafactory SAM value (they were never on the runtime value for SAM lambdas, but tooling that inspects the generated class is affected).

  • Debuggers and stepping. The body now lives in a $lambda$…​/$packed$…​ method on the enclosing class rather than a doCall on a nested class. Stack traces improve (they name the enclosing class), but "step into closure", breakpoints on a closure body, and lambda/closure display in variable views should be checked in IntelliJ IDEA and Eclipse, and against JDWP expectations.

  • Serialization. Serializable lambdas and visibly serialization-bound closures continue on the class-based path (and so serialize as before); a packed closure serialized via a transitive route fails fast with a message naming the closure and the opt-out. Any future extension to hoist the serializable cases must carry the $deserializeLambda$ machinery to the enclosing class (as Java does) and be validated for round-trip and versioning.

  • Stub generation / joint compilation and Groovydoc. Synthetic hoisted methods must remain invisible to public API views (they are private synthetic), and stub generation must not surface them.

Concretely, before enabling by default we should: build the reference IDE integrations against a snapshot; run the debugger step/breakpoint scenarios for both closures and lambdas; check the common coverage and decompiler tools; and document the flag as the back-out for any tool not yet updated. This tooling verification is a first-class deliverable of the GEP, not an afterthought.

Codegen-level verification (done for the SAM-lambda first step)

The prototype hoisted method carries the debug metadata a debugger relies on, verified both by inspecting javap -p -l output and by an automated test (LambdaHoistTest) that reads the class bytes with ASM and asserts the following on every hoisted $lambda$…​ method:

  • LineNumberTable with one entry per body line. A multi-line lambda body yields a line-number entry for each source statement, so line breakpoints, single-step, and "step into" land on the correct source lines — and stack frames report accurate line numbers.

  • Populated LocalVariableTable with the original names. The captured values (now leading parameters, e.g. base), the SAM parameters (e.g. x), and locals declared inside the body (e.g. a, b) all appear by name, so the debugger’s variables view is complete. This is on par with — arguably better than — the old doCall frame, because the frame now names the enclosing class.

  • ACC_SYNTHETIC on the impl method. The hoisted method is private static synthetic, so it is correctly excluded from public-API views (Groovydoc, IDE member lists, stub generation).

Because the hoisted body reuses the original AST statements, source positions propagate unchanged; there is no separate line-mapping step that could drift.

The packed-closure leg has the equivalent automated coverage (PackedClosureDebugMetadataTest): line-number entries per body line and named locals — including captured values (a written capture threaded as a shared Reference still appears under its original name), typed and implicit-it parameters, and body locals — on the $packed$closure$ methods under both compilation modes, plus ACC_SYNTHETIC on the hoisted bodies and on all generated dispatch machinery.

Remaining human-only verification (cannot be scripted)

The following must be exercised interactively in the reference IDEs and cannot be asserted from a build, so they remain open items before the flag defaults on:

  • IntelliJ IDEA and Eclipse: set a breakpoint inside a hoisted lambda body, confirm it binds and stops; "step into" a call whose target is a hoisted lambda; inspect captured values, parameters, and body locals in the variables view; confirm the frame names the enclosing class sensibly.

  • JDWP / remote-debug parity with the above against a stock JDK debugger.

  • Coverage tools (JaCoCo) and popular decompilers/bytecode viewers: confirm they attribute the hoisted method to the enclosing class without error and do not rely on the old nested-class name.

Reference implementation

Both legs are implemented in the code generator and exercised through this proposal’s development — the SAM-lambda leg merged, the closure-packing leg on a PR (GROOVY-12151) proposed as a Groovy 6 stretch goal:

  • Lambda first step (Groovy 6, merged). Implemented in StaticTypesLambdaWriter: both non-capturing and read-only-capturing SAM lambda classes are eliminated, with the impl hoisted to private static methods on the enclosing class (captured values become leading parameters) and the metafactory bootstrapped directly against them — the non-capturing case yielding a zero-alloc singleton. Behaviour and class-count reductions are verified as above, and the hoisted methods are confirmed to carry full debug metadata (see IDE and tooling considerations). Cases that need the shared Reference or closure identity — mutated captures, instance access, serializable and nested-function lambdas — correctly decline and keep their class. The whole first step is merged behind the opt-in groovy.target.lambda.hoist flag; the lambda tests that assert the old bytecode shape are skipped under the flag, with their migration to the new structure deferred to when the default flips.

  • Closure packing (Groovy 6 stretch goal, GROOVY-12151). A ClosureWriter-based implementation (with a shared PackedClosure runtime adapter), opt-in via @PackedClosures, hoists closures — captures, arbitrary-depth nesting, written captures via Reference, implicit it — under both compilation modes. It confirms the writer is the right home (no @CompileStatic boundary, unlike an AST-transform approach), establishes the soundness cliff empirically, and confirms what is preserved (curry/memoize/trampoline all keep working on the adapter). All three safety layers from Guarding the dynamic trust assertion are now implemented and validated:

    • the runtime guardPackedClosure rejects setDelegate/setResolveStrategy set to any non-owner configuration. Probing confirmed every delegate-redirect path (with/tap, rehydrate, builder DSLs, DGM) funnels through those two setters, so the guard turns what was a silent wrong answer (owner-resolved where a delegate was expected) into a fail-fast error at the point of misuse; owner-based each/collect closures stay byte-identical.

    • the compile-time escape decline — a closure literal that visibly escapes its method (stored into a field/property/index, returned, appended with <<, or placed in a collection literal) is kept as a class, moving the clearest delegate-risk cases from a runtime throw to a compile-time non-event.

    • the decline diagnostics@PackedClosures(mode = WARN | STRICT) surfaces closures that could not be packed (a compiler warning or error, with the reason and source position); DISABLED opts a scope out entirely. The automatic flag path stays lenient.

Also implemented and validated: the capability analysis (STC’s delegate-independence proof driving automatic packing under @CompileStatic, no annotation needed); the dispatch path — a statically-compiled, typed hoisted body reached through the per-class dispatch tables described under Groovy 6.0 deliverables, behind lean adapter lanes sized to the JIT’s inlining budgets — exercised across the core module and several others; and the MOP integration — the stock-metaclass/category guard shared with Closure.call’s own fast-path cache, and the registered `PackedClosureMetaClass (reflection-free MOP dispatch, instance-faithful respondsTo), with the interception matrix verified identical to generated closure classes on both the dynamic and Java/GDK paths. Measured with JMH against the generated classes it replaces, capturing shapes run 1.4–1.9× faster packed and Grails-shaped in-situ workloads net +17–23%, with byte-identical corpus transcripts. This implemented slice is proposed as a Groovy 6.0 stretch goal (GROOVY-12151). The remaining Groovy 7 work is owner-correct retargeting so self-contained nested closures can hoist, S3 object elision, unifying the closure and lambda writers, and hardening the automatic path from experimental (opt-in flag) to default-on.

Measured impact

Closure classes are dominated by scaffolding, not logic. On a class reproducing seven real FormTagLib closure patterns, packing eliminated all seven closure classes (8 class files → 1; 23.8 KB → 7.3 KB, ~69% fewer bytes); the hoisted method bodies added only 1.8 KB, so roughly 90% of a closure class’s bytes were Closure scaffolding — constructor, metaclass init, serialVersionUID, Reference fields — rather than the closure’s own code. Grails domain classes are the extreme case: a representative RemarkCriteria domain generated 24 closure classes totalling 80 KB — more bytecode than the entire rest of the class (1.19×).

Dispatch speed matters as much as class count, because a shared adapter is invoked through the Closure machinery rather than being its own class. A naive adapter (reflective invoke plus per-call argument adaptation) measures ~7–9× slower to call() than the generated closure class it replaces — smaller but slower — and a per-instance MethodHandle fares little better (the JIT cannot constant-fold it, so every call runs the method-handle invoker). The shipped design avoids both: the per-class dispatch tables execute as ordinary bytecode the JIT inlines through, the per-arity tables pass arguments as plain parameters (no array to allocate or escape), and constant-dynamic parameter-type arrays remove the last per-creation allocation. Measured with JMH on the same build, capturing closures — the shapes GEP-27 exists for — run 1.4–1.9× faster packed than as generated classes, holding under megamorphic dispatch; the tightest non-capturing collect trails at ~0.6×, the cost being the MOP-transparency guard (loop-invariant checks the JIT hoists when the chain inlines — the same checks the classic call-site caches make). On Grails-shaped in-situ workloads the blend nets +17–23% packed with identical outputs, so the adverse micro-shape is real but dilutes out under real work (further lane micro-tuning was assessed against those shapes and found to have no in-situ effect). Ratios are measured against a current baseline that includes the Closure.call fast-path improvements (GROOVY-12164/12165), i.e. against generated classes at their fastest.

Self-hosting: a whole Groovy module built with the flag

As an end-to-end check, Groovy’s own groovy-macro module was compiled with groovy.target.closure.pack on — no source change, since its hot files are already @CompileStatic, so packing is automatic — and its test suite run against the packed classes:

groovy-macro module Baseline Packed

Classes in the module

148

44 (−70%)

Closure classes

115

11 (104 packed, 90%)

Compiled bytecode

520 KB

228 KB (−56%)

Module test suite

66 / 66 pass against the packed classes

The 11 residual closure classes are exactly the expected declines: five are one nested-closure family (the Groovy 7 owner-retargeting case), two are in the single non-@CompileStatic file (a builder, so the automatic flag correctly skips it), and four are flat closures that visibly escape. The dominant source is ASTMatcher (113 → 9 closure classes), whose packed form load-verifies under -Xverify:all.

Two timings on this real module, both controlled (interleaved runs, medians): the cold test suite ran ~6% faster packed in 5/5 rounds — the class-count reduction showing up directly as less class loading and verification in a fresh JVM — and a steady-state hot loop of ASTMatcher.matches (a full AST walk per call) measured at parity (≈0.98×, inside the run-to-run noise). So on shipping code the pattern from the microbenchmarks holds: packing’s runtime is parity-to-favourable, and its real product is the class-count, bytecode and cold-start reduction. (This also demonstrates the internal-adoption path: once default-on, Groovy’s own @CompileStatic modules — AST, macro, GINQ — would each shed most of their closure classes from the distribution jars.)

Grails artefact DSLs and nested closures

The delegate DSLs Grails relies on — static constraints = { … }, static mapping, namedQueries, old-style def action = { … } — are field-assigned, which is exactly the escape gate’s decline signal, so they are kept as real closure classes and their delegate resolution is unchanged (verified end-to-end against a builder delegate). Because Grails discovers these blocks by their being fields, the whole family lands on the safe side by construction rather than by special-casing; and the Groovy 6 SAM-lambda work never touches them at all (they are `Closure`s, not lambdas).

The value left on the table in a domain class is the many self-contained nested closures — the validator: { val, obj → … } blocks inside constraints, which reference only their own parameters and owner statics, not the constraints delegate. A spike that relaxed the nested-closure decline for these confirmed the class-count prize (the shown RemarkCriteria subset dropped 6 → 2 closure classes) but proved the naive form unsound: the hoisted body was added to the enclosing closure class as an instance method, while a static constraints block’s owner resolves to the domain class, so dispatch attempted a non-existent static method (MissingMethodException), and where it did dispatch, owner-static references resolved against the wrong owner. Hoisting a nested closure therefore requires retargeting the method onto the correct outermost owner, rebinding its free names to that owner, and emitting static-vs-instance dispatch to match — the owner-correct capability work already scoped to Groovy 7. This makes domain classes the single highest-value target for that phase, and the reason it is Groovy 7 and not a cheap gate change.

A supporting change is worth noting: hoisting nested closures requires the class visitor to visit methods added during code generation to a fixpoint (a two-round visitMethods misses methods added while compiling a method that was itself added during compilation). That change is implemented in the packing slice — an identity-based fixpoint, also fixing an O(n²) scan against method-generating visitors — and is validated against the full suite.

Phased delivery

Phase What ships What works Target

1

Non-capturing SAM lambda class elimination + flag + test migration + IDE/tooling check

Java-parity lambda codegen for the common SAM case; back-out flag

Groovy 6.0 (merged)

2

Read-only-capturing SAM lambdas (metafactory-captured static impl)

Removes the class, Closure alloc, and Reference wrapping for read-only capturing SAM lambdas (mutated captures decline)

Groovy 6.0 (merged)

3

Closure packing (S1) + capability/escape analysis + per-class dispatch tables + MOP transparency + @PackedClosures

Sound closure packing: automatic under @CompileStatic, faster than the classes it replaces on capturing shapes, opt-in (with runtime guard + escape decline) under dynamic

Groovy 6.0 (stretch, GROOVY-12151)

4

Owner-correct retargeting so self-contained nested closures hoist

Grails domain validator blocks and other nested closures pack

Groovy 7.0

5

Object elision (S3) + unify the writers + default-on hardening

No object for non-escaping call-once bodies; one backend for closures and lambdas; packing default-on

Groovy 7.0

Phases 1–2 stand alone and deliver value with no dependency on the later phases. Phase 3 (the stretch goal) builds on the shared backend proven in phases 1–2; phases 4–5 build on phase 3.

Excluded and deferred features

Feature Status Rationale

Changing closure/lambda semantics

Not planned

This is a codegen/packaging optimisation only. Eligible closures compute identical results and keep their reachable Closure API; ineligible ones are compiled exactly as today.

Packing closures that use externally-set delegate/resolveStrategy

Not planned

These require a real per-closure representation; they are detected (statically) or declined and fall back to S0.

Serializable closure/lambda hoisting

Deferred

Serializable lambdas and visibly serialization-bound closures (cast/coerced to a Serializable type, passed directly to writeObject, held in a local that is, or initialising a field) are kept on the class-based path; a packed closure reached by a genuinely transitive serialization route fails fast at runtime with an actionable message. Hoisting the serializable cases would require carrying the deserialisation machinery to the enclosing class.

Packing default-parameter (multi-arity) closures

Deferred

A single hoisted method cannot reproduce the arity-overloaded dispatch a default parameter generates, so these decline to S0 today. A future slice would hoist one method per arity (reusing the existing default-fill codegen) and claim one dispatch-table id per arity — the per-arity tables the shipped adapter already uses, and the same primitive the MethodClosure alignment above would share.

Object elision (S3)

Deferred (Groovy 7)

The most aggressive step; needs escape proof and, for iteration, callee knowledge.

Dynamic-mode automatic packing of free-name closures

Not planned

A closure that resolves a free name cannot be proven delegate-safe without types; that subset stays opt-in via @PackedClosures (+ runtime guard) under dynamic compilation.

A user-visible switch between S1/S2/S3 per closure

Not planned

The compiler picks the lightest sound representation; users influence it only via target type, arrow syntax, and @PackedClosures — including opting a scope out entirely with @PackedClosures(mode = DISABLED), but never selecting which packed representation is used.

Compatibility and impact

Backwards compatibility

The feature is additive and reversible:

  • Un-optimised code is unchanged. Ineligible closures/lambdas (and everything when the flag is off) compile byte-for-byte as today.

  • Observable behaviour of optimised closures/lambdas is preserved, including per-instance identity and the reachable Closure API (curry/memoize/trampoline).

  • The one behavioural difference a compact representation cannot provide — caller-set delegate resolution — is exactly the case the analysis declines to optimise, falling back to a normal generated closure.

Binary compatibility

Generated-class names and the presence of per-closure/per-lambda classes are not part of any public API, but tooling may depend on them (see IDE and tooling considerations). The flag is the compatibility valve while tools are updated.

Performance

  • Fewer generated classes: less classloading, verification, JIT profiling, and metaspace.

  • Non-capturing SAM lambdas keep their zero-allocation metafactory singleton (the class removed is dead weight, not an allocation).

  • Closure packing dispatches through per-class tables that execute as ordinary, JIT-inlinable bytecode; the per-arity tables pass the receiver, captures and arguments as plain parameters, so the hot shapes allocate no argument array at all, and allocation sits at or below the closure-class baseline on capturing shapes.

  • Measured with JMH against the generated classes on the same build: capturing shapes 1.4× or better packed (holding under megamorphic dispatch); tight non-capturing collect 0.5–0.7× — the MOP-transparency guard’s loop-invariant checks, hoisted when the chain inlines, on a shape whose ratio swings with inline-budget details. On Grails-shaped in-situ workloads the blend nets +17–23% packed with identical outputs.

  • MOP transparency has the same cost model as the classic call-site caches: one latched-boolean and one category-counter read decide between direct dispatch and the full invokeMethod route, so interception, categories and respondsTo behave identically to generated closure classes.

  • Under strict JPMS (named modules, no --add-opens), packed dispatch works where reflective dispatch of closure classes does not: the hosting class’s own invokedynamic bootstrap captures its lookup — the capability model Java lambdas use — and handing the closure across a module boundary is the grant. This deliberately moves enforcement from invocation time to creation time.

  • Packing is independent of Groovy’s call-site strategy: its invokedynamic/constant-dynamic usage is JDK bootstrap machinery (the same category as LambdaMetafactory for statically compiled lambdas), emitted regardless of the indy setting — verified byte-identical class counts and behaviour under classic (indy=false) compilation, where the dynamic path flows through the classic call-site caches to the same dispatch tables.

Security

No new trust boundary. The optimisation is a local codegen transform; it introduces no new runtime input handling.

Alternatives considered

  • Do it as an AST transformation rather than in the code generator. Rejected for the closure case: a phase sweep shows there is no AST-transform window that both packs closures and survives @CompileStatic — early enough to control codegen means the type checker sees the rewritten (untyped) form and fails; late enough to be post-type-check means it is too late to suppress class emission. The code generator is the only place with both the inferred types and control over class emission.

  • Ship closure packing without the escape analysis. Rejected: without it, packing silently mis-resolves a closure whose delegate is set by the caller. Soundness is a prerequisite, not a follow-up.

  • One aggressive representation for everything. Rejected: no single representation satisfies the full Closure contract cheaply. The lattice picks the lightest sound option per closure.

  • Lambda-style (metafactory) for closures too. Rejected as the general answer: LambdaMetafactory targets interfaces, and groovy.lang.Closure is an abstract class with rich mutable state, so closures need the shared adapter (S1) rather than the metafactory (S2). S2 applies only where the target genuinely is a functional interface.

  • Leave lambdas as they are. Rejected: Groovy lambdas carry a per-lambda class that Java does not, and the fix is the same primitive the closure work needs. The SAM corner is the cheapest, safest place to introduce that primitive.

  • No flag. Rejected: a bytecode-shape change should carry a back-out for tooling that has not yet been updated. The optimisation’s all-or-nothing branch makes a single toggle clean.

References

  • GEP-26: GINQ SQL Backend — companion GEP in the same series

  • java.lang.invoke.LambdaMetafactory — the JVM lambda bootstrap this design reuses for the SAM case

  • JEP 126: Lambda Expressions — Java’s "body as a method on the enclosing class + metafactory" model, the prior art for the shared primitive

  • org.codehaus.groovy.classgen.asm.ClosureWriter, org.codehaus.groovy.classgen.asm.sc.StaticTypesLambdaWriter — the code-generation writers this GEP targets

  • groovy.lang.Closure — the closure contract whose narrow, reachable subset (curry/memoize/trampoline) the shared adapter preserves