GEP-27
|
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
LambdaMetafactoryfunctional-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
Closureadapter (a small fixed-arity family) that dispatches to the hoisted method — no per-closure class, while remaining a realgroovy.lang.Closure(socurry,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
Closureadapter, 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
ClosureAPI 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 }:
-
becomes a generated
InnerClassNode(ClosureWriter.getOrAddClosureClass) — one per literal; -
that class extends
groovy.lang.Closure implements GeneratedClosure, with adoCallholding the body, captured mutable locals threaded asgroovy.lang.Referenceconstructor arguments/fields, and standardClosurestate (owner,thisObject,delegate,resolveStrategy, …); -
at the callsite,
new Outer$_m_closure1(this, this, ref…)is instantiated.
A statically compiled lambda (params) → body targeting a functional interface
(StaticTypesLambdaWriter):
-
still generates a lambda
InnerClassNodeextendingClosurewith adoCall(madestaticwhen non-capturing); -
emits
invokedynamicbootstrapped throughLambdaMetafactory.metafactory, whose implementation-method handle points at thatdoCall; -
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 adelegateset 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,$FixedItfor implicit-parameter literals,$FixedNfor higher and vararg arities), soclosure.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-instancesetMetaClassis 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 ( |
Feasible for all: types prove owner-vs-delegate resolution of every free name, plus escape and |
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 |
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
@PackedClosuresannotation 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/Objecttarget 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-shaped, no external delegate/serialization |
hoisted body + a fixed-arity adapter family; no per-closure class; |
S2 metafactory SAM |
functional-interface target, no closure identity needed |
hoisted body + |
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
PackedClosurethrows — with an actionable message naming the hoisted method — when asked for a configuration it cannot honour:setDelegate(x)to anything other than the owner, orsetResolveStrategy(…)to any non-owner mode (DELEGATE_FIRST,DELEGATE_ONLY, orTO_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 confusingMissingMethodExceptionframes away). Almost all delegate-dependent usage funnels through these setters — explicit assignment,with/tap(which setdelegate
DELEGATE_FIRSTinternally), and builder DSLs — so the guard catches the realistic paths. Settingdelegateto 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@CompileStaticcase 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’smodeelement surfaces them:LENIENT(the default) stays silent,WARNemits a compiler warning per unpacked closure naming the source position and the reason, andSTRICTturns 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 realClosure). 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), soWARN/STRICTwill report "packed" on exactly the closure the runtime guard later has to catch. Only the annotation opts in; the automaticgroovy.target.closure.packpath 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 aDISABLEDmethod opts out of a packed class (andDISABLEDoverrides the flag too).
| Mechanism | When | Catches |
|---|---|---|
|
compile time |
visible declines — "did I pack what I intended?" |
|
runtime |
the invisible wrong assumption — "did anyone misuse what I packed?" |
|
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):
-
Real-
Closuresemantics? Referencesowner/delegate/thisObject/resolveStrategy/metaClass/super, has default parameter values, or contains an anonymous inner class → decline. -
Scope opted out? The most-specific
@PackedClosuresmode wins (method over class);DISABLEDbeats an enclosing opt-in and the flag → decline. -
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, andthis-properties ofMap-implementing owners). Otherwise → decline. -
Structural position: written directly in a method of the owner class; nested closures await owner-correct retargeting (Groovy 7) → decline.
-
Visible escape: stored to a field/property/index, returned, appended with
<<, or placed in a collection literal → decline. -
Visibly serialization-bound: cast/coerced to a
Serializabletype, or passed towriteObjectdirectly or via a literal-holding local → decline. -
Field initialiser: a class-level
def x = { … }(a field store outside any method’s visible code) → decline. -
Intersection cast: the generated class would need per-literal marker interfaces → decline.
-
Trait body: trait methods live in
$Trait$Helperwith a synthetic receiver → decline. -
Constructor special call: an argument to
this(…)/super(…), wherethisis 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 |
|---|---|---|
|
|
|
|
|
|
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
invokedynamicin 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. Oneinvokedynamicaccessor per class links the tables into a constant bundle lazily on first use, and each literal’s parameter-typeClass[]is a constant-dynamic resolved once per site. The whole chain is ordinary, JIT-friendly bytecode — no reflection, and no per-instanceMethodHandle(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-capturingcollecttrails 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 ofClosureMetaClassfor generated closures — giving reflection-freecall/doCalldispatch on the MOP route,respondsToanswers faithful to the instance’s declared parameter types, and the same category stance asClosureMetaClass. Each fixed-arity member carries theGeneratedClosuremarker and declares exactly thedoCallsignature(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,ClosureMetaMethodconstruction,MetaClassImplmethod selection under a replaced metaclass — behaves exactly as with generated classes; the general dispatch entry is deliberately not nameddoCall, so no varargs signature exists for MOP selection to mistake a singleObject[]argument for an argument list. Argument adaptation replicates metaclass dispatch bit for bit (pinned empirically against classed closures): arity mismatches raiseMissingMethodException, a singleListdestructures across a non-one-parameter signature, trailing-array parameters vararg-collect, and per-parameter coercion follows the metaclass compatibility matrix (BigDecimal→doubleyes,String→Integerno,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) |
|
off (opt-in) |
Closure packing under |
|
off (opt-in); default-on is Groovy 7 |
Closure packing under dynamic compilation, free-name closures |
|
off unless annotated |
Closure packing under dynamic compilation, no-free-name subset |
|
off (opt-in) |
Decline reporting on the flag path |
|
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
|
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_lambda1classes exist — by name, byClass.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/GeneratedLambdaare 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 adoCallon 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:
-
LineNumberTablewith 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
LocalVariableTablewith 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 olddoCallframe, because the frame now names the enclosing class. -
ACC_SYNTHETICon the impl method. The hoisted method isprivate 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 toprivate staticmethods 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 sharedReferenceor 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-ingroovy.target.lambda.hoistflag; 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 sharedPackedClosureruntime adapter), opt-in via@PackedClosures, hoists closures — captures, arbitrary-depth nesting, written captures viaReference, implicitit— under both compilation modes. It confirms the writer is the right home (no@CompileStaticboundary, unlike an AST-transform approach), establishes the soundness cliff empirically, and confirms what is preserved (curry/memoize/trampolineall keep working on the adapter). All three safety layers from Guarding the dynamic trust assertion are now implemented and validated:-
the runtime guard —
PackedClosurerejectssetDelegate/setResolveStrategyset 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-basedeach/collectclosures 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);DISABLEDopts 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, |
Groovy 6.0 (merged) |
3 |
Closure packing (S1) + capability/escape analysis + per-class dispatch tables + MOP transparency + |
Sound closure packing: automatic under |
Groovy 6.0 (stretch, GROOVY-12151) |
4 |
Owner-correct retargeting so self-contained nested closures hoist |
Grails domain |
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 |
Packing closures that use externally-set |
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 |
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 |
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 |
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
ClosureAPI (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
collect0.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
invokeMethodroute, so interception, categories andrespondsTobehave 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 owninvokedynamicbootstrap 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 asLambdaMetafactoryfor statically compiled lambdas), emitted regardless of theindysetting — 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
Closurecontract cheaply. The lattice picks the lightest sound option per closure. -
Lambda-style (metafactory) for closures too. Rejected as the general answer:
LambdaMetafactorytargets interfaces, andgroovy.lang.Closureis 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