Performance, invokedynamic & Native Images
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example marked as run was executed with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14, on macOS). Two topics could not be run on that machine and are labelled "not run here": building a GraalVM native image (no GraalVM was installed for this page) and running on Android (no device or emulator). Those parts are taken from the Groovy 6.0 release notes and the invokedynamic guide and are described, not demonstrated. Any timing on this page is a single local micro-measurement on one laptop: it shows the shape of a difference, not a benchmark, and it will change from run to run and from machine to machine.
The short version is that dynamic Groovy is fast enough for most programs, that the JIT-friendly dispatch path
(invokedynamic) is already what you get, and that the main lever you control is where you give the compiler types
(@CompileStatic). The sections below explain the mechanisms so that the few system properties that exist make sense.
How a dynamic call is dispatched
In dynamic Groovy x.greet(name) cannot be bound by the compiler: the method is chosen at run time from the runtime types
of the receiver and the arguments, and the choice can be changed by the metaclass (categories, ExpandoMetaClass, methodMissing).
Groovy has had two ways to compile such a call.
-
Classic call-site caching (Groovy 1.x to 3.x by default). Each call is compiled to a lookup in a per-class array of
CallSiteobjects, created lazily and reached through$getCallSiteArray(). The call site caches the last method selected and checks that it is still valid. -
invokedynamic (supported since Groovy 2.0 and the only generated form that is on by default since Groovy 4.0). Each call is compiled to an
invokedynamicinstruction whose bootstrap method (IndyInterface) links the site to aMethodHandlethe first time it runs, guarded by checks on the argument classes. The JVM can then inline through that handle like any other call.
Which of them the compiler emitted is visible in the bytecode. The program below compiles one tiny class twice, once with
the default and once with the groovy.target.indy system property set to false, and counts the instructions with
javap (this uses the JDK’s own tool).
class Greeter {
String greet(String name) { "Hello, ${name}".toString() }
def twice(x) { greet(x) + greet(x) }
}
groovyc -d out-indy Greeter.groovy
JAVA_OPTS="-Dgroovy.target.indy=false" groovyc -d out-classic Greeter.groovy
javap -c -p -cp out-indy Greeter | grep -c invokedynamic # 5
javap -c -p -cp out-indy Greeter | grep -c getCallSiteArray # 0
javap -c -p -cp out-classic Greeter | grep -c invokedynamic # 0
javap -c -p -cp out-classic Greeter | grep -c getCallSiteArray # 4
5
0
0
4
The same switch exists as a compiler option, which is what an embedder or a build tool sets. The script below compiles a
one-line class in memory with a CompilerConfiguration and looks for the names of the two runtimes in the class file
(a crude but real check).
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.CompilationUnit
def src = 'class Probe { def twice(x) { x + x } }'
['default', 'indy false'].each { label ->
def cfg = new CompilerConfiguration()
if (label == 'indy false') cfg.optimizationOptions.indy = false
def gcl = new GroovyClassLoader(this.class.classLoader, cfg)
def out = File.createTempDir('probe', '')
cfg.targetDirectory = out
def cu = new CompilationUnit(cfg, null, gcl)
cu.addSource('Probe.groovy', src)
cu.compile()
def text = new String(new File(out, 'Probe.class').bytes, 'ISO-8859-1')
println "${label.padRight(10)} -> uses invokedynamic bootstrap: ${text.contains('IndyInterface')}, " +
"uses call-site array: ${text.contains('CallSiteArray')}"
out.deleteDir()
}
default -> uses invokedynamic bootstrap: true, uses call-site array: false
indy false -> uses invokedynamic bootstrap: false, uses call-site array: true
Turning invokedynamic off only changes the bytecode generated for your code; the Groovy runtime jars themselves are always built with it on. The guide suggests it only for the rare workload where call-site caching happens to be faster, which is something to measure on your own program rather than assume.
The groovy-callsite module (6.0)
Before Groovy 6.0 the classes that implement classic call-site caching were part of the core jar. In 6.0 they are split out
into the separate groovy-callsite jar, and it is only needed when code compiled with invokedynamic disabled runs. Code
compiled with the default settings never loads it. That is verifiable directly: the first run below uses the indy-compiled
Greeter with only groovy-6.0.0.jar on the class path, and the later runs use the classic-compiled one with and without
the extra jar.
L=groovy-6.0.0/lib
java -cp out-indy:out-main:$L/groovy-6.0.0.jar Main
java -cp out-classic:out-main:$L/groovy-6.0.0.jar:$L/groovy-callsite-6.0.0.jar Main
java -cp out-classic:out-main:$L/groovy-6.0.0.jar Main
Hello, AdaHello, Ada
Hello, AdaHello, Ada
WARNING: The declared methods of Greeter cannot be read, so none of them will be available to Groovy
java.lang.NoClassDefFoundError: org/codehaus/groovy/runtime/callsite/CallSiteArray
(Main.groovy is the one-line script println new Greeter().twice('Ada'), compiled against out-indy; the third command
prints a longer stack trace, shortened here.) If an old library was compiled with groovy.target.indy=false and is
used from a 6.0 application, add org.apache.groovy:groovy-callsite to its dependencies; in an application that compiles its
own Groovy with the defaults you will not need it.
Scoped invalidation (6.0)
A linked invokedynamic call site is only correct as long as nothing changes the selection that was baked into it. Before
Groovy 6.0 a change to any metaclass retired the linked call sites of the whole JVM, so a framework or test suite that kept
adding and removing metaclasses (for instance with ExpandoMetaClass or a mocking helper) kept sending hot code back to the
slow path. Since 6.0 each metaclass owns its own SwitchPoint, and call sites guard on the one belonging to the
receiver’s metaclass, so churn on one class leaves the call sites on unrelated classes linked and inlined. Process-wide
retirement remains only for the events that cannot be attributed to a class, such as entering and leaving a category with
use { } or installing a non-stock MetaClass.
The groovy.indy.invalidation.stats property logs the counts at FINE level. The script lowers the root logger to FINE,
changes the metaclass of A only and prints the invalidations (the logger’s timestamp lines were removed from the output
below; the program prints its checksum last).
import java.util.logging.*
def root = Logger.getLogger('')
root.level = Level.FINE
root.handlers.each { it.level = Level.FINE }
class A { int f(int x) { x + 1 } }
class B { int g(int x) { x + 2 } }
def a = new A(), b = new B()
long s = 0
2000.times { s += a.f(it) + b.g(it) }
A.metaClass.f = { int x -> x + 100 } // only A's metaclass changes
2000.times { s += a.f(it) + b.g(it) }
println s
FINE: exact-class SwitchPoint invalidated for A; totalClass=1
FINE: exact-class SwitchPoint invalidated for A; totalClass=2
8008000
Both log lines name A: no line mentions B, whose call site was not retired. The semantics did not change (a call still
sees the new method straight away); only the amount of re-linking did. The release notes say the effect is largest for
frameworks such as Grails and for test suites that install and remove metaclasses, and that dynamic property writes now also
go through invokedynamic by default (-Dgroovy.indy.setproperty=false restores the old code generation).
When @CompileStatic helps
@CompileStatic removes dispatch for the calls it can resolve: it emits the same direct invokevirtual, invokestatic or
field access that javac would (see Static
Type Checking & @CompileStatic), which the JIT inlines without guards. It therefore helps most in numeric loops, tight
inner loops, parsers, serialisers and library code called millions of times. It helps little in glue code, scripts and DSL
builders, where the time goes to I/O or to the work that the called library does, and where the dynamic features are the
point.
The program below compares the same loop, once in untyped dynamic code and once in a @CompileStatic class with primitive
int and long. It is a micro-measurement: after a short warm-up each version is timed once, with System.nanoTime().
import groovy.transform.CompileStatic
class Dyn {
static long sum(n) {
def total = 0L
for (i in 0..<n) { total += i % 7 }
total
}
}
@CompileStatic
class Stat {
static long sum(int n) {
long total = 0L
for (int i = 0; i < n; i++) { total += i % 7 }
total
}
}
int n = 20_000_000
assert Dyn.sum(n) == Stat.sum(n) // same result either way
3.times { Dyn.sum(n); Stat.sum(n) } // warm-up
def time = { Closure c -> long t = System.nanoTime(); c(); (System.nanoTime() - t).intdiv(1_000_000) }
println "dynamic : ${time { Dyn.sum(n) }} ms"
println "@CompileStatic : ${time { Stat.sum(n) }} ms"
dynamic : 67 ms
@CompileStatic : 11 ms
This is one run on one machine. Other runs of the same script gave 46 and 157 ms for the dynamic version and 12 and 61 ms
for the static one, so only the ordering (static faster, by a clear margin in a primitive loop) is meaningful, not the
numbers. invokedynamic and the JIT make dynamic Groovy much faster than the call-site era, but boxing, dynamic operators and
metaclass checks still cost something that typed primitives avoid. Programs whose time is spent in I/O or in the JDK show no
such gap. Measure with a profiler, or with a real benchmark harness such as JMH, before restructuring code for speed; the
Groovy project itself tracks its performance with a JMH suite whose results it publishes.
Allocation-free GDK overloads (6.0)
Many GDK methods take a Closure, and calling one from Java or from dynamic code means creating a Closure object for
every call. Groovy 6.0 adds overloads that take the java.util.function types directly, so a lambda or a method reference
reaches the method with no wrapper. The script lists the overloads of four common methods that take a single functional
argument, by reflection, and then uses a statically compiled lambda and a method reference.
import groovy.transform.CompileStatic
import org.codehaus.groovy.runtime.DefaultGroovyMethods as DGM
['collect', 'each', 'findAll', 'inject'].each { name ->
def kinds = DGM.methods.findAll { it.name == name && it.parameterCount in [2, 3] && it.parameterTypes[0] == Iterable }
*.parameterTypes*.getAt(-1)*.simpleName.unique().sort()
println "$name -> $kinds"
}
@CompileStatic
List<Integer> staticUse(List<Integer> xs) {
def twice = (Integer x) -> x * 2 // a real java.util.function lambda
xs.collect(twice).findAll((Integer x) -> x > 2) // picks the Function and Predicate overloads
}
println staticUse([1, 2, 3])
println(["a", "bb", "ccc"].collect(String::length)) // method reference
collect -> [Closure, Function, Object]
each -> [Closure, Consumer]
findAll -> [Object, Predicate]
inject -> [BiFunction, BinaryOperator, Closure]
[4, 6]
[1, 2, 3]
The Function, Consumer, Predicate, BiFunction and BinaryOperator entries are the overloads described in the release
notes (each, collect, findAll, inject and others). The closure forms remain and are still what you want when you
need delegate, it, return semantics or the closure features covered in
Closures. The allocation saving matters on hot paths, and it is a property
of the call, not a measured gain on this page. A related 6.0 change makes compound assignment (a += b) call plusAssign
and similar methods when a class defines them, so an object can be mutated in place instead of replaced by a new one (see
Operator Overloading).
Fewer closure classes: lambda hoisting and closure packing (GEP-27, experimental)
By default the compiler generates a class for every closure, so a class with many closures produces many small class files. Groovy 6.0 has two experimental, opt-in options, both off by default, that reduce this under static compilation:
-
-Dgroovy.target.lambda.hoist=truehoists eligible statically compiled lambdas to a method of the enclosing class. -
-Dgroovy.target.closure.pack=true(or the@PackedClosuresannotation) under@CompileStaticpacks eligible closures into a shared adapter.-Dgroovy.target.closure.pack.report=truelists each closure that was declined and why.
The value stays a real groovy.lang.Closure, and a closure that is ineligible (for example, an escaping one) is left as before. The class below has four closures and one lambda. The commands compile it with each
setting and count the class files.
import groovy.transform.CompileStatic
import java.util.function.Function
@CompileStatic
class Pipeline {
static List<Integer> doubled(List<Integer> xs) {
xs.collect { Integer x -> x * 2 } // closure
}
static List<Integer> viaLambda(List<Integer> xs) {
xs.collect((Function<Integer, Integer>) { Integer x -> x + 1 }) // coerced closure
}
static List<Integer> lam(List<Integer> xs) {
Function<Integer, Integer> f = (Integer x) -> x * 3
xs.collect(f)
}
static int total(List<Integer> xs) {
int t = 0
xs.each { Integer x -> t += x }
t
}
static List<String> tags(List<Integer> xs, String p) {
xs.collect { Integer x -> p + x }
}
}
groovyc -d o-default Pipeline.groovy
JAVA_OPTS="-Dgroovy.target.lambda.hoist=true" groovyc -d o-hoist Pipeline.groovy
JAVA_OPTS="-Dgroovy.target.closure.pack=true" groovyc -d o-pack Pipeline.groovy
JAVA_OPTS="-Dgroovy.target.lambda.hoist=true -Dgroovy.target.closure.pack=true" groovyc -d o-both Pipeline.groovy
ls o-default o-hoist o-pack o-both # class files in each directory
default: 6 class files (Pipeline plus 5 generated closure and lambda classes)
hoist : 5 class files
pack : 2 class files (Pipeline plus one remaining lambda class)
both : 1 class file
A small script println`s the four methods' results ([2, 4, 6], `[3, 6, 9], 6 and [t1, t2]), and the output was
identical when loading the default, the packed and the combined build. The numbers here are file counts from one run of one
file, and the release notes record the same kind of result on larger code (fewer classes and a smaller jar) while
stating the honest limits: packing is off by default; its own benchmarks put capturing closures faster packed but the tightest
non-capturing collect shape slower; and the gain is mostly cold-start and jar size, with steady-state loops at parity. Nothing
on this page measured those speed claims, so treat them as the project’s own statements.
Other dispatch tuning
Two more 6.0 changes need no code. Once a method has been called often enough through the metaclass (the
groovy.cachedmethod.invoker.threshold property, default 1000, aligned with groovy.indy.optimize.threshold), the runtime
installs a generated invoker that calls the target directly instead of Method.invoke; -Dgroovy.cachedmethod.invoker.disable=true
keeps Method.invoke. And calling a closure from Java uses an arity-indexed cache, so Map iteration, eachWithIndex and
inject take the cached fast path. The internal thresholds named in the invokedynamic guide
(groovy.indy.optimize.threshold and groovy.indy.fallback.threshold) can be tuned too, but the guide gives no
recommended values; the defaults are the supported setting.
Tuning the Parrot parser
Since Groovy 3.0 the parser (nicknamed Parrot) is built on ANTLR 4. For build servers and long-lived processes that parse many source files, the documentation lists system properties that trade parse speed against memory:
| Property | Effect | Default |
|---|---|---|
|
Parse source files in parallel. |
|
|
Clear the shared DFA cache after this many parsed files. A negative value never clears it (needs a bigger heap). |
|
|
Upper bound on the DFA states kept; exceeding it drops the cache. A value of 0 or less removes the limit. Added in 6.0. |
|
|
Token count above which the parser skips the fast SLL mode and goes straight to full LL. |
|
|
Also clear the lexer’s DFA cache; the guide advises leaving it alone unless an |
|
The properties are passed as -D options to the JVM running the compiler. The commands below generate 300 small classes
and compile them with the defaults, with parallel parsing and with the SLL and cache-size properties set, then check that
every run produced the same 300 class files.
// genfiles.groovy: writes gen/Bean1.groovy ... gen/Bean300.groovy
def dir = new File('gen'); dir.mkdirs()
(1..300).each { n ->
new File(dir, "Bean${n}.groovy").text = """
class Bean${n} {
String name; int count
List<String> tags = []
def describe() { "\${name}:\${count}:\${tags.join(',')}" }
static Map<String,Integer> table() { [a: ${n}, b: ${n + 1}, c: ${n + 2}] }
}
"""
}
println dir.list().length
groovy genfiles.groovy
JAVA_OPTS="" groovyc -d o-gen gen/*.groovy
JAVA_OPTS="-Dgroovy.parallel.parse=true" groovyc -d o-gen gen/*.groovy
JAVA_OPTS="-Dgroovy.antlr4.sll.threshold=1000 -Dgroovy.antlr4.cache.size=40000" groovyc -d o-gen gen/*.groovy
300
classes=300
classes=300
classes=300
All three variants accepted the properties and compiled the same 300 classes. On this small batch the wall-clock times of the
runs (between about 0.8 and 1.5 seconds, including JVM start-up) did not order consistently, and no speed-up was observed
or is claimed; the properties are aimed at much larger code bases and at long-lived compilers (for example a Gradle daemon or
an IDE), where the cache bound mostly protects memory. The documentation notes that groovy.parallel.parse is
off by default.
GraalVM native images and the AOT link mode (6.0, incubating)
Not run here: no GraalVM or native-image was installed for this page, so nothing in this section was built or executed
and it is a summary of the Groovy 6.0 release notes.
A native image is compiled ahead of time, closed-world. That conflicts with two things the Groovy runtime does: defining
classes at run time and retargeting an already linked invokedynamic call site. Until Groovy 6.0 only fully statically
compiled code had much chance of working in a native image. The 6.0 release notes (marked incubating) describe how the
obstacles were removed piece by piece:
-
AOT link mode. A native image supports every
java.lang.invokebuilding block Groovy uses except retargeting a linked call site, so in AOT link mode each call site links once and permanently, and a metaclass change advances a global stamp that causes re-selection instead of invalidating aSwitchPoint(coarser than the scoped invalidation above, but never stale). The mode is detected automatically inside a native image and can be forced on a normal JVM with-Dgroovy.indy.aot.link=true. Existing indy-compiled jars become native-capable without recompilation, because the bootstrap method in their class files is unchanged. -
Reachability metadata ships in the jars under
META-INF/native-image/org.apache.groovy/groovy/, so building an image of a dynamic Groovy program no longer starts with a native-image agent run just to record what Groovy’s own runtime reflects over. The agent is still the way to record what depends on your application, its own classes and the JDK types it calls dynamically; run it with-Dgroovy.indy.aot.link=trueso the JVM links call sites as the image will. -
Packed closures (above) link through compiler-emitted
LambdaMetafactorycall sites, so they run in a native image with no extra metadata; argument-less enum constants are created by a direct constructor call; hidden-class definition soft-fails where runtime class definition is forbidden. -
GraalVM for JDK 25.0.4 or later is required, according to the release notes.
-
Class-initialisation policy. Recipes written for earlier versions often passed a blanket
--initialize-at-build-time. The release notes advise against carrying that over, because run-time initialisation is the default and what AOT link mode expects. -
Logging and caller locations. Inside a native image, dynamically dispatched calls leave frames of GraalVM’s method-handle interpreter visible, so loggers may report the runtime rather than the calling method. The notes say
java.util.loggingneeds the packages Groovy and GraalVM insert listed injdk.logger.packagesat image build time, Logback needs them in its framework packages, and Log4j2 has no equivalent hook; statically compiled code is unaffected. The invokedynamic guide has the exact settings.
The mode itself can be tried on an ordinary JVM, which is what the next script does. A single call site is linked, a
category then changes what that site must call, and afterwards the original method is selected again. The output is the same
with the default linking and with -Dgroovy.indy.aot.link=true; only the last line shows that the property was set.
class Pet { String name; String speak() { "${name} says hi" } }
class Shout { static String speak(Pet p) { "${p.name.toUpperCase()} SAYS HI" } }
def talk(p) { p.speak() } // one call site, linked on first call
def rex = new Pet(name: 'Rex')
println talk(rex)
use(Shout) { println talk(rex) } // a category changes selection for the same site
println talk(rex)
println "aot link mode forced: ${System.getProperty('groovy.indy.aot.link')}"
groovy aot.groovy
JAVA_OPTS="-Dgroovy.indy.aot.link=true" groovy aot.groovy
Rex says hi
REX SAYS HI
Rex says hi
aot link mode forced: null
Rex says hi
REX SAYS HI
Rex says hi
aot link mode forced: true
This shows that the forced mode keeps the semantics of dynamic dispatch; it says nothing about image size, start-up time or
throughput, which were not measured. A real native-image build is done with GraalVM’s native-image tool (or the Gradle and
Maven plug-ins of the GraalVM project) on the compiled classes. See the release notes for the exact flags before you rely on
them, since this feature is incubating.
Non-HotSpot runtimes and Android (6.0)
Not run here: no Android device or emulator was used.
Until 6.0 the Groovy runtime assumed it was running on a HotSpot JVM built from a recent JDK. The 6.0 release notes say that
version detection, accessibility rules, hidden classes, ScopedValue and the invokedynamic exception handlers now
degrade instead of failing, "which is what it takes to come up on Android’s ART". A property documents one such fallback:
groovy.hidden.classes.disable=true defines the runtime-generated helper classes through the classic defineClass path
instead of JEP 371 hidden classes. The notes do not publish an Android support matrix or a getting-started recipe, so
this page does not either; if you target Android, test your own Groovy version and check the project’s current guidance.
Statically compiled Groovy (@CompileStatic) needs far less of the dynamic machinery, and
Groovy & Other Languages covers the neighbouring languages.
A short checklist
-
Leave
invokedynamicon. Turn it off only with a measurement showing that your workload is faster without it, and then addgroovy-callsiteto the classpath of anything compiled that way. -
Put
@CompileStatic(with primitive types) on the hot, numeric or library-style code, and leave glue and DSL code dynamic. -
Prefer typed lambdas and method references with the GDK’s functional overloads on hot paths; keep closures where you need their features.
-
Treat closure packing and lambda hoisting as experiments: compile with and without them, run your tests, and compare.
-
For very large builds or daemons, test the Parrot parser properties on your own sources and judge by memory and time, not by the defaults of other projects.
-
For native images, start from Groovy 6.0 with the shipped metadata, check the GraalVM version requirement, and keep
--initialize-at-build-timeoff Groovy’s own packages. Programs that must be Java-fast to start and small to deploy may still prefer statically compiled Groovy or Java.
Where the book is outdated
Books from the Groovy 1.x and 2.x era describe call-site caching as the way Groovy dispatches calls, the separate
-indy jars and the opt-in indy compiler flag of Groovy 2.0 to 3.x, and Groovy performance as something to be recovered
with @CompileStatic and a few caching tricks. Since Groovy 4.0 invokedynamic is the default with a single set of jars and
no separate artifacts, and in 6.0 classic call-site caching is the optional groovy-callsite module. Those books cannot
know about scoped invalidation, the java.util.function overloads of the GDK, closure packing, the Parrot parser (Groovy 3.0
and later), the DFA cache properties, or Groovy running in native images and on non-HotSpot runtimes.