Your first native application
The counter from the previous page as one executable: no JDK on the target machine, the native library inside the file, and a window up in a tenth of a second.
A GraalVM native image is a closed world. It has to know every class, every resource and every foreign function before it runs, and it cannot bind a model by reflection. Goldberry was designed for that (ADR-0127), and the toolkit’s jars carry most of what an image needs. Four things are yours:
- Weave the model, so that assignments notify without reflection.
- Declare your own resources, by glob.
- Trace one run, for what depends on your application.
- Run
native-image.
The recipe below is the showcase’s own, which CI builds on all three platforms on every release tag (ADR-0337). Native image explains every flag and every trap in depth.
Before you start
- A GraalVM Community for JDK 25, with
GRAALVM_HOMEpointing at it. A stock JDK has nonative-image. - On Linux, a C toolchain and zlib’s development package:
sudo apt install build-essential zlib1g-dev # Debian, Ubuntu
sudo dnf install gcc glibc-devel zlib-devel libstdc++-static
Without zlib1g-dev the build spends a minute on analysis and fails at the
last step with cannot find -lz.
Weave the model. The weaver rewrites Counter.class so that every write
to a @Bind field calls a synthesized setter that notifies. It is one jar
with no dependencies and a main that takes a directory of classes. The task
needs the classes and everything they were compiled against, because it
regenerates stack-map frames (Model weaving).
configurations { goldberryWeaver }
dependencies { goldberryWeaver 'dev.goldberry:goldberry-weaver' }
def weaveModels = tasks.register('weaveModels', JavaExec) {
dependsOn tasks.compileJava
def classes = tasks.compileJava.flatMap { it.destinationDirectory }
classpath = files(configurations.goldberryWeaver, classes, sourceSets.main.compileClasspath)
mainClass = 'dev.goldberry.weaver.WeaverMain'
argumentProviders.add({ ['--models', classes.get().asFile.absolutePath] } as CommandLineArgumentProvider)
inputs.dir(classes)
outputs.file(layout.buildDirectory.file('tmp/weaveModels/stamp'))
outputs.upToDateWhen { false }
doLast { layout.buildDirectory.file('tmp/weaveModels/stamp').get().asFile.text = 'woven' }
}
tasks.named('jar') { mustRunAfter weaveModels }
The weaver’s version comes from the BOM, like every other artifact. The stamp file is deliberate: declaring the compiler’s own output directory as this task’s output makes Gradle recompile the whole module on every build (ADR-0398).
Declare your resources. The document and the stylesheet are read by name at run time, and a trace only records what one run happened to touch. Globs are finite (ADR-0160). The file goes in a directory the agent never writes to, so a new trace cannot overwrite it:
src/main/resources/META-INF/native-image/com.example/hello-manual/reachability-metadata.json
{
"resources": [
{ "module": "com.example.hello", "glob": "com/example/hello/*.kdl" },
{ "module": "com.example.hello", "glob": "com/example/hello/*.css" },
{ "glob": "dev/goldberry/natives/**" }
]
}
The last line is the native library itself. It lives in the classifier jar, which the image carries as a class-path resource and unpacks to a temporary file on first use (ADR-0159).
Trace one run. What the toolkit’s jars cannot declare is what depends on
your run: the service the widget catalogue is found through, the upcall stubs
the native code calls back into, the JDK’s text resources, and whatever your
logging configuration reflects over. GraalVM’s agent records them. Run the
woven application once, headless, for a hundred frames, and keep the output
under src/main/resources so it is reviewed in a diff
(ADR-0156):
tasks.register('nativeImageMetadata', Exec) {
dependsOn weaveModels, tasks.jar
def output = layout.projectDirectory.dir('src/main/resources/META-INF/native-image/com.example/hello')
doFirst {
def split = splitPaths()
def command = ["${System.getenv('GRAALVM_HOME')}/bin/java",
'--enable-native-access=dev.goldberry.natives',
"-agentlib:native-image-agent=config-output-dir=${output.asFile.absolutePath}",
'-Dgoldberry.backend.videoDriver=dummy']
if (System.getProperty('os.name').toLowerCase().contains('mac')) {
command += '-XstartOnFirstThread'
}
command += ['--module-path', split.modules, '-cp', split.natives,
'--module', 'com.example.hello/com.example.hello.Hello', '--frames=120']
commandLine command
}
}
splitPaths is a small helper, used by both tasks. The natives classifier
jars hold no classes and no module descriptor, so they go on the class path
rather than the module path, where four jars would derive one and the same
automatic module name:
def splitPaths = {
def jars = configurations.runtimeClasspath.files
def natives = jars.findAll { it.name.startsWith('goldberry-natives-') && it.name =~ /-(linux|macos|windows)-/ }
def modules = (jars - natives) + [tasks.jar.archiveFile.get().asFile]
[modules: modules*.absolutePath.join(File.pathSeparator), natives: natives*.absolutePath.join(File.pathSeparator)]
}
The agent writes reachability-metadata.json beside your manual one.
native-image reads every META-INF/native-image/** it finds, so the two
are merged for the tool and kept apart for you.
Build the image.
tasks.register('nativeImage', Exec) {
dependsOn weaveModels, tasks.jar
def target = layout.buildDirectory.file('native/hello')
doFirst {
def split = splitPaths()
commandLine "${System.getenv('GRAALVM_HOME')}/bin/native-image",
'--module-path', split.modules,
'-cp', split.natives,
'--module', 'com.example.hello/com.example.hello.Hello',
'-o', target.get().asFile.absolutePath,
'--enable-native-access=dev.goldberry.natives',
'-H:+ReportExceptionStackTraces'
}
}
./gradlew nativeImageMetadata nativeImage
./build/native/hello
The result is one file. No launcher, no lib/ directory, nothing to set.
What the toolkit’s jars bring
Nothing above names a Goldberry resource, a foreign function or a class
initialization policy, because the jars carry their own metadata and
native-image reads it from every jar on the path:
| Jar | Carries |
|---|---|
goldberry-natives | the descriptor of every bound C function, written because the function exists rather than because a run reached it (ADR-0339), and the class-initialization policy that makes a downcall handle a constant (ADR-0173) |
goldberry-core | the fonts, the icon table, the two themes |
goldberry-widgets | the catalogue’s stylesheets |
goldberry-html, goldberry-media | their stylesheets, when they are on the path |
Warning
The one thing that fails silently is speed. A downcall handle that is not a compile-time constant costs a factor of 450 per call, and the image builds, runs and paints correctly at forty times the frame cost. The policy that prevents it travels in the natives jar. If you pass your own
--initialize-at-run-timeor--initialize-at-build-timeflags, read Native image first.
What to expect
The showcase, measured from exec | |
|---|---|
| The file | 41 MiB on Linux, library included |
| The window | open at about 120 ms |
| The first frame | about 520 ms with the GPU module, 365 ms without |
| A headless frame | about 1.0 ms |
The counter is a much smaller application and starts no slower (ADR-0506).
Note
A screen the trace never reached contributes nothing to the metadata. After adding a screen, re-run
nativeImageMetadataand read the diff. The showcase’s own image once shipped without the light theme’s stylesheet for exactly this reason, which is why resources are declared by glob and not traced.