[
https://issues.apache.org/jira/browse/GROOVY-12227?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Paul King updated GROOVY-12227:
-------------------------------
Description:
Packed closures (GROOVY-12151, GEP-27) do not work under GraalVM native image.
A class compiled with {{groovy.target.closure.pack=true}} links its dispatch
tables on first closure creation, and that linkage fails inside a native image
— in two layers.
Without agent-recorded metadata, the bootstrap's method lookup fails:
{noformat}
java.lang.BootstrapMethodError: java.lang.NoSuchMethodException:
no such method: M.$packedDispatch$(int,Object[])Object/invokeStatic
{noformat}
With agent-recorded metadata the lookup succeeds, and the real wall appears —
the bootstrap spins hidden classes at run time, which a native image forbids:
{noformat}
Exception in thread "main" java.lang.BootstrapMethodError:
java.lang.InternalError:
com.oracle.svm.core.jdk.UnsupportedFeatureError: Classes cannot be defined at
runtime
by default when using ahead-of-time Native Image compilation. Tried to define
class:
M$$LambdadLbm0tKUrZ8kVMiSpulnyK
{noformat}
This matters because packing is otherwise a natural fit for native images: it
collapses per-closure inner classes into the hosting class (a 3-to-1 class
reduction in the repro below), which directly reduces image size and
reachability metadata, and its call site is a {{ConstantCallSite}} — no runtime
re-linking, unlike the mutable-call-site dispatch of regular indy.
h2. Reproducing
{code:java}
import groovy.transform.CompileStatic
import java.util.function.BiFunction
@CompileStatic
class M {
static void main(String[] args) {
BiFunction<Integer, Integer, Integer> lam = (Integer a, Integer b) -> a
+ b
BiFunction<Integer, Integer, Integer> coe = { Integer a, Integer b -> a
* b } as BiFunction<Integer, Integer, Integer>
var doubled = [1, 2, 3].collect { int n -> n * 2 }
println "lambda: ${lam.apply(20, 22)}"
println "coerced: ${coe.apply(6, 7)}"
println "collect: ${doubled}"
}
}
{code}
{noformat}
JAVA_OPTS="-Dgroovy.target.indy=false -Dgroovy.target.lambda.hoist=true
-Dgroovy.target.closure.pack=true" \
groovyc -cp groovy-callsite.jar M.groovy # emits a single class:
M.class
java -agentlib:native-image-agent=config-output-dir=cfg -cp
"groovy.jar:groovy-callsite.jar:." M
native-image --no-fallback -H:ConfigurationFileDirectories=cfg \
-H:IncludeResources='META-INF/dgminfo|META-INF/groovy/.*|META-INF/services/.*' \
-cp "groovy.jar:groovy-callsite.jar:." M
./m # fails as above
{noformat}
Reproduced with GraalVM CE 25.2.4 (native-image 25.0.4, the first release whose
Groovy substitution guard is fixed for Groovy 5+ — see oracle/graal#13096).
h2. Root cause
{{GeneratedDispatcher.bootstrap}} does two things a native image cannot support:
# {{caller.findStatic(host, "$packedDispatch$", ...)}} — a *runtime*
method-handle lookup, which under native image requires per-user-class
reflection metadata (unknowable to Groovy's own jar, so every user would need
an agent run).
# Three programmatic
{{LambdaMetafactory.metafactory(...).getTarget().invokeExact()}} calls — each
defines a hidden class at run time. Bytecode-level LMF invokedynamic is
pre-processed at image build time and works fine; *programmatic* metafactory
calls inside a bootstrap are runtime class definition, which is forbidden.
The surrounding design is already image-friendly: the result is wrapped in a
{{ConstantCallSite}} and never re-linked.
h2. Proposed fix (prototyped)
Two layered changes; JVM semantics unchanged.
*1. Constant bootstrap arguments (ClosureWriter).* Emit the three dispatch
tables as {{CONSTANT_MethodHandle}} bootstrap arguments, exactly as javac does
for LMF's {{implMethod}}:
{noformat}
invokedynamic packedDispatchers()Object
bsm = IndyInterface.packedDispatchers(Lookup, String, MethodType,
MethodHandle, MethodHandle,
MethodHandle)
args = [ MH(invokestatic host.$packedDispatch$), MH(...1$), MH(...2$) ]
{noformat}
Constant-pool method handles are resolved by the VM (and pre-resolved at image
build time), so linking needs no {{findStatic}} and no metadata. Verified: the
tracing agent records *zero* {{packedDispatch}} entries for the new bytecode.
The existing 3-arg bootstrap remains for class files emitted by earlier 6.0
snapshots.
*2. Method-handle bundle fallback (GeneratedDispatcher).* On a regular JVM the
{{LambdaMetafactory}} hidden-class adapters are kept — their interface call
inlines under the JIT, which is the documented rationale. Where classes cannot
be defined at run time, the tables are wrapped in method-handle-invoking
adapters instead: ordinary Java lambdas of {{GeneratedDispatcher}} itself,
whose bytecode-level LMF sites are AOT-compiled into the image, so no class
definition happens at run time. There is no JIT in such runtimes, so the
inlining rationale does not apply there.
Detection is
{{"runtime".equals(System.getProperty("org.graalvm.nativeimage.imagecode"))}},
evaluated *per link* rather than cached in a static — under native image the
class may be build-time initialized, where the property reports {{buildtime}},
and caching would bake the wrong answer into the image heap. A catch-based
fallback around the metafactory calls covers AOT runtimes the property probe
misses.
One subtlety: dispatch targets may throw checked exceptions the dispatcher
interfaces do not declare, and the hidden-class path propagates them
transparently. The wrapper path rethrows via {{UncheckedThrow}} to match, and
this is covered by a dedicated test.
h2. Opt-in / opt-out / permanent?
Permanent and automatic — no user-facing flag:
* JVM behaviour is byte-identical; the fallback engages only where the
hidden-class path *cannot work*. A flag would choose between "works" and
"crash".
* The gate already exists one level up: packing is itself opt-in
({{groovy.target.closure.pack}} / {{@PackedClosures}}) and experimental under
GEP-27.
* {{-Dgroovy.packed.dispatch.handles=true}} exists as a *diagnostic* knob only,
forcing the wrapper path on a JVM so CI can assert parity without a native
build.
h2. Verification
||check||result||
|{{PackedDispatcherHandleBundleTest}} (new: parity across all three dispatch
shapes + the GDK; undeclared checked-exception propagation)|2/2 pass|
|existing packed/closure suites ({{PackedClosuresTransformTest}},
{{ClosurePackCapabilityTest}}, {{PackedClosureBoundariesTest}},
{{PackedClosureMetaClassTest}}, {{PackedClosureDebugMetadataTest}},
{{ClosureAndInnerClassNodeStructureTest}}, {{ClosureCallMopGuardTest}})|57
tests green|
|repro, unpatched master, native|{{Classes cannot be defined at runtime}}|
|repro, patched, native|runs correctly: {{lambda: 42 / coerced: 42 / collect:
[2, 4, 6]}}, exit 0, no exceptions|
|agent metadata for the dispatcher|0 entries (was: per-class method
registrations)|
|footprint|single emitted class; 30 MB image; ~12 ms total run time|
The remaining agent configuration in the repro covers the general Groovy
runtime bootstrap (VMPluginFactory reflection, {{dgm$NNN}} classes,
{{META-INF/dgminfo}}) — app-independent, pre-existing, and out of scope here;
shipping that fixed set as reachability metadata in Groovy's own jar would be a
natural follow-up ticket.
> GeneratedDispatcher: avoid runtime class definition so packed closures work
> in native images
> ---------------------------------------------------------------------------------------------
>
> Key: GROOVY-12227
> URL: https://issues.apache.org/jira/browse/GROOVY-12227
> Project: Groovy
> Issue Type: Improvement
> Reporter: Paul King
> Assignee: Paul King
> Priority: Major
>
> Packed closures (GROOVY-12151, GEP-27) do not work under GraalVM native
> image. A class compiled with {{groovy.target.closure.pack=true}} links its
> dispatch tables on first closure creation, and that linkage fails inside a
> native image — in two layers.
> Without agent-recorded metadata, the bootstrap's method lookup fails:
> {noformat}
> java.lang.BootstrapMethodError: java.lang.NoSuchMethodException:
> no such method: M.$packedDispatch$(int,Object[])Object/invokeStatic
> {noformat}
> With agent-recorded metadata the lookup succeeds, and the real wall appears —
> the bootstrap spins hidden classes at run time, which a native image forbids:
> {noformat}
> Exception in thread "main" java.lang.BootstrapMethodError:
> java.lang.InternalError:
> com.oracle.svm.core.jdk.UnsupportedFeatureError: Classes cannot be defined at
> runtime
> by default when using ahead-of-time Native Image compilation. Tried to define
> class:
> M$$LambdadLbm0tKUrZ8kVMiSpulnyK
> {noformat}
> This matters because packing is otherwise a natural fit for native images: it
> collapses per-closure inner classes into the hosting class (a 3-to-1 class
> reduction in the repro below), which directly reduces image size and
> reachability metadata, and its call site is a {{ConstantCallSite}} — no
> runtime re-linking, unlike the mutable-call-site dispatch of regular indy.
> h2. Reproducing
> {code:java}
> import groovy.transform.CompileStatic
> import java.util.function.BiFunction
> @CompileStatic
> class M {
> static void main(String[] args) {
> BiFunction<Integer, Integer, Integer> lam = (Integer a, Integer b) ->
> a + b
> BiFunction<Integer, Integer, Integer> coe = { Integer a, Integer b ->
> a * b } as BiFunction<Integer, Integer, Integer>
> var doubled = [1, 2, 3].collect { int n -> n * 2 }
> println "lambda: ${lam.apply(20, 22)}"
> println "coerced: ${coe.apply(6, 7)}"
> println "collect: ${doubled}"
> }
> }
> {code}
> {noformat}
> JAVA_OPTS="-Dgroovy.target.indy=false -Dgroovy.target.lambda.hoist=true
> -Dgroovy.target.closure.pack=true" \
> groovyc -cp groovy-callsite.jar M.groovy # emits a single class:
> M.class
> java -agentlib:native-image-agent=config-output-dir=cfg -cp
> "groovy.jar:groovy-callsite.jar:." M
> native-image --no-fallback -H:ConfigurationFileDirectories=cfg \
>
> -H:IncludeResources='META-INF/dgminfo|META-INF/groovy/.*|META-INF/services/.*'
> \
> -cp "groovy.jar:groovy-callsite.jar:." M
> ./m # fails as above
> {noformat}
> Reproduced with GraalVM CE 25.2.4 (native-image 25.0.4, the first release
> whose Groovy substitution guard is fixed for Groovy 5+ — see
> oracle/graal#13096).
> h2. Root cause
> {{GeneratedDispatcher.bootstrap}} does two things a native image cannot
> support:
> # {{caller.findStatic(host, "$packedDispatch$", ...)}} — a *runtime*
> method-handle lookup, which under native image requires per-user-class
> reflection metadata (unknowable to Groovy's own jar, so every user would need
> an agent run).
> # Three programmatic
> {{LambdaMetafactory.metafactory(...).getTarget().invokeExact()}} calls — each
> defines a hidden class at run time. Bytecode-level LMF invokedynamic is
> pre-processed at image build time and works fine; *programmatic* metafactory
> calls inside a bootstrap are runtime class definition, which is forbidden.
> The surrounding design is already image-friendly: the result is wrapped in a
> {{ConstantCallSite}} and never re-linked.
> h2. Proposed fix (prototyped)
> Two layered changes; JVM semantics unchanged.
> *1. Constant bootstrap arguments (ClosureWriter).* Emit the three dispatch
> tables as {{CONSTANT_MethodHandle}} bootstrap arguments, exactly as javac
> does for LMF's {{implMethod}}:
> {noformat}
> invokedynamic packedDispatchers()Object
> bsm = IndyInterface.packedDispatchers(Lookup, String, MethodType,
> MethodHandle, MethodHandle,
> MethodHandle)
> args = [ MH(invokestatic host.$packedDispatch$), MH(...1$), MH(...2$) ]
> {noformat}
> Constant-pool method handles are resolved by the VM (and pre-resolved at
> image build time), so linking needs no {{findStatic}} and no metadata.
> Verified: the tracing agent records *zero* {{packedDispatch}} entries for the
> new bytecode. The existing 3-arg bootstrap remains for class files emitted by
> earlier 6.0 snapshots.
> *2. Method-handle bundle fallback (GeneratedDispatcher).* On a regular JVM
> the {{LambdaMetafactory}} hidden-class adapters are kept — their interface
> call inlines under the JIT, which is the documented rationale. Where classes
> cannot be defined at run time, the tables are wrapped in
> method-handle-invoking adapters instead: ordinary Java lambdas of
> {{GeneratedDispatcher}} itself, whose bytecode-level LMF sites are
> AOT-compiled into the image, so no class definition happens at run time.
> There is no JIT in such runtimes, so the inlining rationale does not apply
> there.
> Detection is
> {{"runtime".equals(System.getProperty("org.graalvm.nativeimage.imagecode"))}},
> evaluated *per link* rather than cached in a static — under native image the
> class may be build-time initialized, where the property reports
> {{buildtime}}, and caching would bake the wrong answer into the image heap. A
> catch-based fallback around the metafactory calls covers AOT runtimes the
> property probe misses.
> One subtlety: dispatch targets may throw checked exceptions the dispatcher
> interfaces do not declare, and the hidden-class path propagates them
> transparently. The wrapper path rethrows via {{UncheckedThrow}} to match, and
> this is covered by a dedicated test.
> h2. Opt-in / opt-out / permanent?
> Permanent and automatic — no user-facing flag:
> * JVM behaviour is byte-identical; the fallback engages only where the
> hidden-class path *cannot work*. A flag would choose between "works" and
> "crash".
> * The gate already exists one level up: packing is itself opt-in
> ({{groovy.target.closure.pack}} / {{@PackedClosures}}) and experimental under
> GEP-27.
> * {{-Dgroovy.packed.dispatch.handles=true}} exists as a *diagnostic* knob
> only, forcing the wrapper path on a JVM so CI can assert parity without a
> native build.
> h2. Verification
> ||check||result||
> |{{PackedDispatcherHandleBundleTest}} (new: parity across all three dispatch
> shapes + the GDK; undeclared checked-exception propagation)|2/2 pass|
> |existing packed/closure suites ({{PackedClosuresTransformTest}},
> {{ClosurePackCapabilityTest}}, {{PackedClosureBoundariesTest}},
> {{PackedClosureMetaClassTest}}, {{PackedClosureDebugMetadataTest}},
> {{ClosureAndInnerClassNodeStructureTest}}, {{ClosureCallMopGuardTest}})|57
> tests green|
> |repro, unpatched master, native|{{Classes cannot be defined at runtime}}|
> |repro, patched, native|runs correctly: {{lambda: 42 / coerced: 42 / collect:
> [2, 4, 6]}}, exit 0, no exceptions|
> |agent metadata for the dispatcher|0 entries (was: per-class method
> registrations)|
> |footprint|single emitted class; 30 MB image; ~12 ms total run time|
> The remaining agent configuration in the repro covers the general Groovy
> runtime bootstrap (VMPluginFactory reflection, {{dgm$NNN}} classes,
> {{META-INF/dgminfo}}) — app-independent, pre-existing, and out of scope here;
> shipping that fixed set as reachability metadata in Groovy's own jar would be
> a natural follow-up ticket.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)