Build a small JDK-only test runner from an annotation and reflection, test package-private code with –patch-module, then ship a Java service as a modular JAR, a jlink runtime image and a jpackage installer.
A service that passes a smoke check isn’t finished. It needs tests that say exactly what broke, and a way onto a machine that may not have Java installed. The JDK has no test framework, but it has everything a small one needs. For shipping, it has jar, jdeps, jlink and jpackage.
This final part of the series adds a test harness to the tasks service from the part on HTTP, then packages that module three ways. Every program below was run on Java 25, and its output is pasted from the run. To run one of the single-file programs yourself, save it as Main.java and run java Main.java. The terminal sessions come from the project, and its check script rebuilds all of it with JDK tools only.
What a test runner does
A test runner finds the test methods, runs each one, and counts what passed and what failed. JUnit does much more, but that core fits in one file. An annotation marks the tests, and reflection finds them:
@Retention(RetentionPolicy.RUNTIME)
@interface Test {}
static class CartTests {
int total(List<Integer> prices) {
return prices.stream().mapToInt(Integer::intValue).sum();
}
int withDiscount(int total, int percent) {
return total * ((100 - percent) / 100);
}
void check(boolean ok, String message) {
if (!ok) {
throw new AssertionError(message);
}
}
@Test
void emptyCartCostsNothing() {
check(total(List.of()) == 0, "an empty cart should cost 0");
}
@Test
void addsUpPrices() {
check(total(List.of(250, 199)) == 449, "250 + 199 should be 449");
}
@Test
void takesTenPercentOff() {
int price = withDiscount(449, 10);
check(price == 404, "expected 404 but was " + price);
}
}
void main() throws ReflectiveOperationException {
List<Method> tests = Arrays.stream(CartTests.class.getDeclaredMethods())
.filter(m -> m.isAnnotationPresent(Test.class))
.sorted(Comparator.comparing(Method::getName))
.toList();
int passed = 0;
int failed = 0;
for (Method test : tests) {
var instance = new CartTests();
try {
test.invoke(instance);
passed++;
IO.println("ok " + test.getName());
} catch (InvocationTargetException e) {
failed++;
IO.println("FAIL " + test.getName() + ": " + e.getCause().getMessage());
}
}
IO.println(passed + " passed, " + failed + " failed");
}
It prints:
ok addsUpPrices
ok emptyCartCostsNothing
FAIL takesTenPercentOff: expected 404 but was 0
2 passed, 1 failed
The failing test found a real bug: (100 - percent) / 100 is integer division, and 90 / 100 is 0. Here’s what each part of the runner does:
@interface Testdeclares an annotation. On its own it does nothing. It’s a label the runner looks for.getDeclaredMethods()returns every method in the class, andisAnnotationPresentkeeps only the labelled ones.total,withDiscountandcheckhave no label, so they don’t run.new CartTests()runs once per test, so no test can leave state behind for the next one.invokewraps whatever the test throws in anInvocationTargetException.getCause()is the real failure.
The tests are sorted by name before they run. The Javadoc for getDeclaredMethods says its results “are not sorted and are not in any particular order”, so a runner that used that order could print differently on another JVM. Sorting makes the output the same on every run.
No imports appear, because a compact source file imports all of java.base, and that includes java.lang.annotation and java.lang.reflect.
Without @Retention(RUNTIME), the runner finds nothing
An annotation lasts only as long as its retention policy says. The default policy, CLASS, writes the annotation into the class file but doesn’t load it at run time, so reflection can’t see it:
@interface Forgotten {}
@Retention(RetentionPolicy.RUNTIME)
@interface Kept {}
static class Checks {
@Forgotten
void first() {
}
@Kept
void second() {
}
}
void main() throws NoSuchMethodException {
for (String name : List.of("first", "second")) {
Method method = Checks.class.getDeclaredMethod(name);
IO.println(name + ": " + Arrays.toString(method.getAnnotations()));
}
}
It prints:
first: []
second: [@Main.Kept()]
Leave out the @Retention line in the first program and its runner finds zero tests, prints 0 passed, 0 failed, and looks like a success. That’s why the project’s runner treats “no tests found” as a failure.
The name Main.Kept is a compact source file showing through. Everything declared in the file is nested inside a class called Main that you never wrote.
The project’s harness: an annotation, not a list of lambdas
The project’s tests live in a new test folder next to src, in the same packages as the code they test:
19-tasks-service/
├── run-checks.sh
├── checks/
│ └── SmokeCheck.java
├── src/
│ └── com.example.tasks/ ...
└── test/
└── com/example/tasks/
├── testing/
│ ├── Assert.java
│ ├── Test.java
│ └── TestRunner.java
├── store/
│ └── InMemoryTaskStoreTest.java
└── http/
├── JsonTest.java
└── TaskServerTest.java
The simpler design is a list of named lambdas, such as Map.entry("ids start at 1", () -> ...). It needs no reflection, and no retention policy can hide a test. But every new test means editing the list, and a test someone forgets to add never runs and never fails. With an annotation, writing the method is the registration. JUnit made the same choice.
The annotation is the one from the single-file runner, with a @Target so it only goes on methods:
package com.example.tasks.testing;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Marks a method as a test. The method takes no arguments and returns nothing. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
TestRunner takes test class names as arguments. For each class, it does what the single-file version did:
private void runClass(Class<?> testClass) throws ReflectiveOperationException {
IO.println(testClass.getName());
Constructor<?> constructor = testClass.getDeclaredConstructor();
constructor.setAccessible(true);
for (Method method : testMethods(testClass)) {
// A new instance per test, so no test sees another test's fields.
Object instance = constructor.newInstance();
try {
method.invoke(instance);
passed++;
IO.println(" ok " + method.getName());
} catch (InvocationTargetException e) {
failed++;
IO.println(" FAIL " + method.getName() + ": " + describe(e.getCause()));
}
}
}
/** The @Test methods, sorted by name: getDeclaredMethods has no fixed order. */
private static List<Method> testMethods(Class<?> testClass) {
var methods = Arrays.stream(testClass.getDeclaredMethods())
.filter(m -> m.isAnnotationPresent(Test.class))
.sorted(Comparator.comparing(Method::getName))
.toList();
for (Method m : methods) {
if (m.getParameterCount() != 0 || Modifier.isStatic(m.getModifiers())) {
throw new IllegalStateException(m + ": a test takes no arguments and isn't static");
}
m.setAccessible(true);
}
return methods;
}
setAccessible(true) lets the runner call test methods and constructors that aren’t public. That’s allowed because the runner and the tests are in the same module, which the next section sets up. After the last class, main prints the totals and calls System.exit(1) if anything failed or nothing ran.
Assert holds the checks. assertEquals compares with Objects.equals and puts both values in its message. assertThrows returns the exception, so a test can check its message too:
/** Runs code, and returns what it threw if that is an instance of type. */
public static <T extends Throwable> T assertThrows(Class<T> type, Code code) {
try {
code.run();
} catch (Throwable thrown) {
if (type.isInstance(thrown)) {
return type.cast(thrown);
}
throw new AssertionError("expected " + type.getSimpleName() + " but "
+ thrown.getClass().getSimpleName() + " was thrown", thrown);
}
throw new AssertionError("expected " + type.getSimpleName() + " but nothing was thrown");
}
Testing package-private code with --patch-module
Json in the service is package-private, so only code in com.example.tasks.http, inside the module, can call it. The tests could skip it and check JSON only through HTTP responses. But then a bug in the escaping code shows up as a wrong response body, three layers away from the cause. So the tests call Json directly.
Compiling a test in that package, but outside the module, fails. Here’s the first of 14 errors:
$ javac -p out --add-modules com.example.tasks -d out/test test/com/example/tasks/http/JsonTest.java test/com/example/tasks/testing/*.java
test/com/example/tasks/http/JsonTest.java:1: error: package exists in another module: com.example.tasks
package com.example.tasks.http;
^
A package belongs to exactly one module, and com.example.tasks.http already belongs to com.example.tasks. --patch-module adds the test folder to that module, for one compile and one run. The compiled module and the JAR you ship don’t change.
$ javac -Xlint:all -Werror --release 25 -d out --module-source-path src -m com.example.tasks
$ javac -Xlint:all -Werror --release 25 -d out/test -p out \
--patch-module com.example.tasks=test \
--add-modules java.net.http --add-reads com.example.tasks=java.net.http \
$(find test -name '*.java')
$ java -p out --patch-module com.example.tasks=out/test \
--add-modules java.net.http --add-reads com.example.tasks=java.net.http \
-m com.example.tasks/com.example.tasks.testing.TestRunner \
com.example.tasks.store.InMemoryTaskStoreTest \
com.example.tasks.http.JsonTest \
com.example.tasks.http.TaskServerTest 2> test-errors.log
com.example.tasks.store.InMemoryTaskStoreTest
ok concurrentCreatesGetDistinctIds
ok deleteRemovesOnlyThatTask
ok idsAreNotReusedAfterDelete
ok idsStartAtOneAndGoUp
ok listIsSortedById
ok titleMustNotBeNull
com.example.tasks.http.JsonTest
ok escapesQuotesBackslashesAndControlCharacters
ok readsEscapes
ok readsStringsAndBooleans
ok rejectsARepeatedField
ok rejectsMalformedJsonWithItsPosition
ok rejectsNumbers
ok whatItWritesItCanReadBack
ok writesATask
ok writesAnEmptyListAsBrackets
com.example.tasks.http.TaskServerTest
ok brokenStoreIs500WithoutDetails
ok createThenFollowLocation
ok wrongMethodIs405WithAllow
18 passed, 0 failed
Each flag has one job:
--patch-module com.example.tasks=testcompiles the test sources as part of the module. At run time,=out/testadds their classes.--add-reads com.example.tasks=java.net.httplets the module readjava.net.http, which the HTTP tests need forHttpClient. The module’s ownrequiresdoesn’t list it. Without this flag, javac reportspackage java.net.http is not visible, “but module com.example.tasks does not read it”.--add-modules java.net.httpadds that module to the build and to the run at all. With neither flag, the run failed withNoClassDefFoundError: java/net/http/HttpClient.
test-errors.log got two stack traces: the service logging the 500 responses one test causes on purpose.
Store tests: making a race happen on purpose
Most store tests are a few lines each: ids start at 1, a deleted id isn’t reused, and a null title throws. The concurrency test needs more care, because 100 threads that start one after another may never overlap:
@Test
void concurrentCreatesGetDistinctIds() throws InterruptedException {
int threads = 100;
var start = new CountDownLatch(1);
Set<Long> ids = ConcurrentHashMap.newKeySet();
var workers = new ArrayList<Thread>();
for (int i = 0; i < threads; i++) {
workers.add(Thread.ofPlatform().start(() -> {
try {
start.await(); // every thread waits here until countDown below
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
ids.add(store.create("task", false).id());
}));
}
start.countDown(); // release all 100 threads at once
for (Thread worker : workers) {
worker.join();
}
assertEquals(threads, ids.size());
assertEquals(threads, store.list().size());
assertEquals(100L, store.list().getLast().id());
}
The CountDownLatch is a starting gate. Each thread blocks in start.await(), and the single countDown() releases all 100 together, so their calls to create overlap as much as the machine allows.
A test that can’t fail proves nothing, so I broke the store on purpose. I replaced lastId.incrementAndGet() with a separate read and write: lastId.get() + 1, then lastId.set(...). The test failed in 20 runs out of 20, with between 68 and 99 distinct ids instead of 100.
The same trick caught a useless test. My first listIsSortedById created five tasks, deleted one, and checked the order. I deleted the .sorted(...) line from list(), and the test still passed. A ConcurrentHashMap puts a small Long key in the bucket numbered by its value, so ids that fit in the table come out in order, sorted or not. The test now keeps only ids 14 to 17, so the map stays at 16 buckets, where ids 16 and 17 land in buckets 0 and 1, ahead of 14 and 15. Without the sort, it fails with expected <[14, 15, 16, 17]> but was <[16, 17, 14, 15]>.
Json tests, and a failure on purpose
The Json tests sit in com.example.tasks.http, so they call package-private methods as if they were public:
/** Json is package-private, so this test lives in the same package, inside the module. */
class JsonTest {
@Test
void writesATask() {
assertEquals("{\"id\":7,\"title\":\"Buy milk\",\"done\":true}",
Json.task(new Task(7, "Buy milk", true)));
}
@Test
void writesAnEmptyListAsBrackets() {
assertEquals("[]", Json.tasks(List.of()));
}
@Test
void escapesQuotesBackslashesAndControlCharacters() {
assertEquals("\"say \\\"hi\\\" \\\\ tab\\t bell\\u0007 Zoë\"",
Json.string("say \"hi\" \\ tab\t bell\u0007 Zoë"));
}
Other tests parse strings, escapes and booleans, check the position in malformed JSON at character 9, and turn titles into JSON and back again. To see what a failure looks like, I deleted the line in Json.string that writes a tab as \t, and ran the same javac and java commands again. Here’s the output, trimmed to the lines that changed:
com.example.tasks.http.JsonTest
FAIL escapesQuotesBackslashesAndControlCharacters: expected <"say \"hi\" \\ tab\t bell\u0007 Zoë"> but was <"say \"hi\" \\ tab\u0009 bell\u0007 Zoë">
...
17 passed, 1 failed
The runner exited with status 1. Without that line, a tab falls through to the general control-character branch and comes out as \u0009. That’s still valid JSON, so whatItWritesItCanReadBack passed. Only the test that compares the exact output noticed the change.
HTTP tests against a real server, with a broken store
The HTTP tests start the real server on port 0, as the smoke check does, and call it with HttpClient. The interesting one tests the 500 path, which InMemoryTaskStore never takes. TaskStore is an interface, so the test passes in a store that fails:
/** A store that fails on every call, the way a store with a dead database would. */
private static final class BrokenStore implements TaskStore {
@Override
public List<Task> list() {
throw new IllegalStateException("connection to db.internal:5432 refused");
}
@Override
public Optional<Task> get(long id) {
return Optional.of(list().getFirst());
}
@Override
public Task create(String title, boolean done) {
return list().getFirst();
}
@Override
public boolean delete(long id) {
return !list().isEmpty();
}
}
@Test
void brokenStoreIs500WithoutDetails() throws Exception {
try (var server = TaskServer.start(new BrokenStore(), 0)) {
for (String method : List.of("GET", "DELETE")) {
var response = send(server, method, "/tasks/1", null);
assertEquals(500, response.statusCode());
assertEquals("{\"error\":\"internal server error\"}\n", response.body());
}
}
}
The exception message names a database host and port, and the response body doesn’t. The full error goes to the log, which is what landed in test-errors.log. try with resources stops the server even when an assertion throws.
What JUnit adds
A hand-written harness shows what a test runner does, and it stops there. Real Java projects use JUnit, and its Jupiter API has been the standard since JUnit 5. Here’s what it gives you that the harness doesn’t:
- Discovery: it scans packages, folders and JARs for tests. There’s no list of class names to keep up to date.
- Lifecycle:
@BeforeEach,@AfterEach,@BeforeAlland@TempDirhandle setup and cleanup, and extensions add more. - Parameterised tests:
@ParameterizedTestwith@ValueSourceor@CsvSourceruns one method over many inputs and reports each one separately. - Better failures:
assertAllreports several failed checks at once, and there are timeouts,@Disabled, tags and reports that CI servers read. - Integration: Maven, Gradle and the major IDEs run JUnit tests, one at a time or all together, and show the results.
A modular JAR that knows its main class
A modular JAR built with --main-class records the class to start in its module descriptor, so java needs only the module’s name. The part on modules covers modular JARs in general. For the service, it looks like this:
$ jar --create --file tasks.jar --main-class com.example.tasks.Main -C out/com.example.tasks .
$ jar --describe-module --file tasks.jar | tail -n +2
exports com.example.tasks.http
exports com.example.tasks.store
requires java.base mandated
requires jdk.httpserver
contains com.example.tasks
main-class com.example.tasks.Main
$ java -p tasks.jar -m com.example.tasks
listening on port 8080
The first line of --describe-module, which holds the JAR’s full path, is cut. Pressing Ctrl+C printed stopping and stopped. The JAR holds 17 KB of your code, and it still needs a Java 25 runtime on the machine that runs it.
jdeps: which JDK modules does the code use?
jdeps reads compiled classes and reports what they depend on. --print-module-deps prints the JDK modules as a comma-separated list, ready to pass to jlink:
$ jdeps --print-module-deps tasks.jar
java.base,jdk.httpserver
$ jdeps --jdk-internals tasks.jar
The second command lists uses of JDK internal APIs, which can break on an upgrade. It printed nothing, because there are none.
For this modular service, jlink reads the requires lines itself. jdeps earns its keep with a plain JAR that has no module-info.java. There, --print-module-deps is the only way to learn which modules a trimmed runtime needs.
jlink: a Java runtime with only what the service needs
jlink builds a runtime image: a folder with its own bin/java and only the modules you name, plus the modules they require. This is the command for the tasks service:
$ jlink --add-modules com.example.tasks --module-path out \
--launcher tasks=com.example.tasks/com.example.tasks.Main \
--strip-debug --no-header-files --no-man-pages --output image
$ ls image/bin
java
jwebserver
keytool
tasks
$ image/bin/java --list-modules
com.example.tasks
java.base@25.0.4
jdk.httpserver@25.0.4
$ du -sh image /usr/lib/jvm/java-25-openjdk-amd64
55M image
331M /usr/lib/jvm/java-25-openjdk-amd64
$ image/bin/tasks
listening on port 8080
The sizes are from this machine, an Ubuntu 24.04 package of OpenJDK 25.0.4 on x86-64. Yours will differ. The options do this:
--add-modules com.example.tasksis the root.jlinkfollowsrequiresfrom there and findsjdk.httpserverandjava.base.--launcher tasks=module/classwritesbin/tasks. The class has to be named, because the compiled module inouthas no recorded main class. With--module-path tasks.jar,tasks=com.example.tasksis enough.--strip-debug,--no-header-filesand--no-man-pagesdrop debug information, C headers and manual pages. Without them, the image was 60M. Adding--compress zip-6brought it to 42M, at some cost to start-up time.
jwebserver and keytool come along because jdk.httpserver and java.base include them.
Explain it like I’m ten
Packing for a weekend at your grandma’s, you could take your whole wardrobe. Everything you might need would be there, but you’d need a truck.
Instead, you look at the plan for the trip and pack a small suitcase: two T-shirts, pyjamas, a toothbrush. The suitcase is light, and it has everything this trip needs.
The JDK is the wardrobe. jlink reads your module’s list of what it needs and packs only that.
The precise version
The JDK is split into modules, and in this JDK each one also ships as a .jmod file in the jmods folder. jlink resolves the module graph from the root modules you pass, using the requires lines in each module-info.class. It then links the classes of every module in that graph into a single lib/modules file, and copies the native libraries, the JVM and the launchers those modules need. The image can’t load a module that isn’t in it: image/bin/java --list-modules shows three, and nothing else exists as far as that runtime knows.
Where the analogy breaks: you can pick clothes for a trip by guessing, but jlink doesn’t guess. It packs exactly what requires says. If code loads a class by name at run time, through reflection or a service lookup, and no requires line names its module, jlink leaves that module out, and the program fails when it gets there. A suitcase also works anywhere, but the image doesn’t: it holds Linux x86-64 native code linked against glibc, so it runs only on a matching system.
Disk use measured with du -sh on this machine, drawn to scale. The JDK includes all 69 modules and the jmods files that only jlink reads. The image holds com.example.tasks, jdk.httpserver and java.base.
The jlink launcher doesn’t pass on SIGTERM
The launcher that jlink writes is a small shell script, and that matters when something stops the service with a signal:
$ cat image/bin/tasks
#!/bin/sh
JLINK_VM_OPTIONS=
DIR=`dirname $0`
$DIR/java $JLINK_VM_OPTIONS -m com.example.tasks/com.example.tasks.Main "$@"
The script starts java as a child process and waits. It doesn’t use exec to become java. When I sent SIGTERM to the script’s process, the shell exited with status 143, and java kept running with no parent, still listening on its port. The shutdown hook never ran. Sending SIGTERM to the java process instead printed stopping and stopped, as it should.
A service manager or a container sends SIGTERM to the process it started, so in those places, start bin/java -m com.example.tasks/com.example.tasks.Main yourself instead of using the launcher.
jpackage: an app folder or a .deb
jpackage wraps a runtime image and a launcher into something an operating system installs: a .deb or .rpm on Linux, an .msi or .exe on Windows, and a .dmg or .pkg on macOS. It builds packages only for the system it runs on. On this machine, both an app folder and a Debian package worked:
$ jpackage --type app-image --name tasks --module-path out \
--module com.example.tasks/com.example.tasks.Main --dest dist
$ ls dist/tasks/bin dist/tasks/lib
dist/tasks/bin:
tasks
dist/tasks/lib:
app
libapplauncher.so
runtime
tasks.png
$ jpackage --type deb --name tasks --app-version 1.0.0 --module-path out \
--module com.example.tasks/com.example.tasks.Main --dest dist
$ dpkg-deb --field dist/tasks_1.0.0_amd64.deb Package Version Depends Installed-Size
Package: tasks
Version: 1.0.0
Depends: libc6, libgcc-s1, libstdc++6, zlib1g
Installed-Size: 56077
$ ls -lh dist/*.deb | awk '{print $5, $9}'
14M dist/tasks_1.0.0_amd64.deb
$ jpackage --type rpm --name tasks --module-path out \
--module com.example.tasks/com.example.tasks.Main --dest dist
Error: Invalid or unsupported type: [rpm]
jpackage ran jlink itself, and the app folder came to 55M, the same as the image. Its bin/tasks is a native program, not a script. It runs the JVM inside its own process, and SIGTERM sent to it ran the shutdown hook. The .deb build used dpkg-deb and fakeroot, already on this machine. It would install under /opt/tasks, which needs root, so I didn’t install it. An .rpm needs rpmbuild, which this machine doesn’t have.
A container image from the jlink output
A jlink image copies into a container as one folder, with no JDK in the final image. This file is illustrative. I haven’t built or run it as part of this post:
FROM ubuntu:24.04 AS build
RUN apt-get update && apt-get install -y --no-install-recommends openjdk-25-jdk-headless
WORKDIR /src
COPY src src
RUN javac -d out --module-source-path src -m com.example.tasks \
&& jlink --add-modules com.example.tasks --module-path out \
--strip-debug --no-header-files --no-man-pages --output /opt/tasks
FROM ubuntu:24.04
COPY --from=build /opt/tasks /opt/tasks
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/opt/tasks/bin/java", "-m", "com.example.tasks/com.example.tasks.Main"]
The final stage is also glibc-based, because the image’s native code needs glibc, so an Alpine base image wouldn’t run it. ENTRYPOINT starts java directly, for the SIGTERM reason above.
What run-checks.sh checks now
The project’s check script now builds and runs everything in this post, and removes it all at the end, including the image it builds in a temporary folder:
$ ./run-checks.sh
ok compiled com.example.tasks
ok smoke check: 45 checks passed
ok Main starts on port 0 and stops cleanly on SIGTERM
ok a bad port prints usage and exits 2
ok tests: 18 passed, 0 failed
ok java -p tasks.jar -m com.example.tasks starts and stops
ok jlink image with com.example.tasks,java.base,jdk.httpserver starts and stops
all checks passed
The JAR and image checks start the service on port 0 and stop it with SIGTERM, like the Main check. For the launcher, the script signals its child java process.
Where to go from here
This series stayed inside the JDK on purpose, so you could see each piece. Real projects add tools on top of it:
- Maven or Gradle to declare dependencies, fetch them and run the build, instead of a shell script.
- JUnit in place of the harness here, with the same kind of test methods.
- Jackson to read and write JSON, in place of the hand-written
Jsonclass. - Spring Boot, Helidon or Micronaut for routing, configuration, JSON binding and metrics in a larger service.
- JDK Flight Recorder and JDK Mission Control to record and inspect what a running JVM does, from garbage collection to slow locks.
What to remember
- A test runner is an annotation with
@Retention(RUNTIME), reflection to find the methods, a new instance per test, and a count. Sort the methods by name, because reflection’s order isn’t specified. - Treat zero tests found as a failure, and break the code on purpose to prove each test can fail.
- To test package-private code in a module, compile and run the tests with
--patch-module. A test in the same package outside the module fails withpackage exists in another module. - Use a
CountDownLatchto release many threads at once when a test needs them to overlap. jar --main-classrecords the main class in a modular JAR, sojava -p tasks.jar -m com.example.tasksis enough.jdeps --print-module-depslists the JDK modules code uses.jlinkbuilds a runtime with only the modules you need: 55M here, against 331M for the JDK. Its launcher is a script that doesn’t pass onSIGTERM.jpackageturns the runtime into an app folder or a native installer, for the operating system it runs on.
Test it until you trust it, then ship only what it needs.