← Back to list

Java Deployment Without Illusions: From Bytecode to jpackage, GraalVM, and Evolutionary…

Java deployment is often explained in terms of tools.

Jsanca · 2026-04-10 01:43 · 0 claps · 7.0 min read
#java #javac #javap #graalvm #jpackage
Open on Medium ↗

Java Deployment Without Illusions: From Bytecode to jpackage, GraalVM, and Evolutionary Architecture

Java deployment is often explained in terms of tools.

Use Maven. Build a JAR. Put it in Docker. Maybe use Spring Boot. Maybe use GraalVM. Maybe package a desktop app.

That is all useful, but it skips the more interesting question:

What are we really deploying when we deploy a Java application?

Once you answer that, the landscape becomes much clearer. You stop seeing deployment as a pile of unrelated tools and start seeing it as a series of trade-offs between portability, startup time, operational simplicity, artifact size, and runtime dynamism.

Start from the beginning: Java does not run your source code

A Java application does not run the .java files you write.

javac compiles source code into .class files, and javap lets you inspect those class files by disassembling them into bytecode-level instructions. Oracle documents javap precisely as a tool that disassembles one or more class files.

That small detail changes how you think about deployment.

When you look at bytecode through javap, Java stops feeling abstract. A simple method turns into a sequence of operations: load this, push that, invoke this, return that. It is not traditional assembly, but it has the same spirit: small instructions executed by a runtime that understands them.

That matters because every deployment strategy in Java is really answering one question:

How much runtime do I want to carry with my application, and how much dynamism do I want to preserve?

The classic model: JAR plus external JVM

The traditional Java deployment model is straightforward.

You build a JAR, move it to another machine, and expect that machine to already have a compatible Java runtime installed.

This model is still perfectly valid. It is flexible, dynamic, and easy to understand. It also keeps the full behavior of the JVM: reflection, ServiceLoader, dynamic proxies, runtime class loading, and all the habits enterprise Java systems have accumulated for years.

For internal systems, legacy applications, and environments where you control the runtime, this approach remains practical.

Its weakness is operational consistency. Your application may be small, but the environment around it is not under your control unless you manage it carefully.

The modular packaging route: jlink and jpackage

Java 9 changed the discussion by modularizing the JDK.

That gave us jlink, which can create a custom runtime image containing only the modules an application needs, and jpackage, which can package the application together with that runtime as a native installer or application image. Oracle’s documentation states that if you do not provide a runtime image explicitly, jpackage can invoke jlink to generate one for the application.

This is one of the most practical deployment improvements Java has gained in years.

Instead of telling users, “Install Java first,” you can ship a self-contained application with a minimized runtime. For desktop software, internal tools, and distributable utilities, this is often the sweet spot: you keep the JVM and its dynamic nature, but you package it deliberately rather than hoping the target machine is ready.

That is a big difference.

A raw JAR assumes an environment.

A packaged application brings its own.

GraalVM changes the shape of the conversation

When you move from desktop applications to web services, cloud runtimes, and containers, a different set of concerns shows up: startup time, memory footprint, density, cold starts, and operational efficiency.

That is where GraalVM becomes interesting.

GraalVM officially presents two main ways to run Java applications: on the HotSpot JVM with the Graal just-in-time compiler, or as an ahead-of-time compiled native executable with Native Image. In addition, GraalVM is also a polyglot platform with support for languages such as JavaScript and Python.

That distinction matters, because people often use “GraalVM” as if it meant only “native executable.” It does not.

GraalVM on the JVM: better performance without giving up the JVM

The least disruptive GraalVM story is simple: run Java on the JVM, but with the Graal JIT compiler.

In this mode, you still have a real JVM. You keep the normal runtime flexibility Java developers are used to. Reflection still behaves like reflection. ServiceLoader still behaves like ServiceLoader. Systems that depend on dynamic discovery or runtime indirection still live in a natural environment.

GraalVM documents this as one of its core runtime modes for Java, and its site describes the Graal JIT compiler as a way to improve Java application performance on the JVM.

This is important because it means GraalVM is not only about sacrificing dynamism for startup speed. It can also be used as a high-performance JVM approach for systems that still need the full expressive power of the platform.

For many large enterprise systems, that may be the most realistic entry point.

Is there a middle ground?

Not as a separate official Java runtime mode.

The clean architectural picture is still mostly this: JVM mode or Native Image. The “middle ground” is better understood as a strategy rather than a third deployment model.

For example, Native Image can be made more practical through reachability metadata, tracing agents, framework support, and build optimizations. But that is still Native Image, not a different runtime category. GraalVM’s documentation describes Native Image as ahead-of-time compilation to a native executable, and its metadata system as a way to support dynamic features that static analysis cannot fully infer on its own.

So I prefer to keep the conceptual model honest:

  • JVM deployment with full runtime dynamism
  • Native deployment with more build-time certainty and less runtime flexibility

That framing is simpler and more accurate.

Native Image: faster startup by moving work to build time

Native Image is the most radical Java deployment option in common use today.

It compiles your Java application ahead of time into a platform-specific native executable. GraalVM documents Native Image as producing a native binary that includes only the code required at run time. It also emphasizes advantages such as fast startup and reduced resource usage in many scenarios.

The core architectural shift is this:

Native Image moves decisions from runtime to build time.

That is why startup improves so dramatically.

It is also why some dynamic features become harder.

GraalVM explicitly documents that Native Image relies on static analysis at build time, but cannot always predict all dynamic uses of reflection, resource access, proxies, or other late-bound features. That is why such elements often require reachability metadata.

This is not a flaw. It is the price of the model.

You gain startup speed and leaner runtime behavior by giving the build process much more certainty up front.

The CI/CD impact is real

This is the part people sometimes mention too lightly.

If Native Image pushes decisions into build time, then the build pipeline itself becomes heavier. GraalVM’s build configuration documentation includes dedicated memory configuration guidance and explains that the builder uses available memory aggressively, with defaults that can go as high as 32 GB unless limited.

That has practical consequences:

A build that used to take seconds may now take minutes.

A CI runner that was previously “good enough” may now need far more RAM.

Caching, runner sizing, and build parallelism suddenly become architectural concerns, not just DevOps details.

In other words, Native Image can make production runtime cheaper while making your build pipeline more expensive.

That trade-off is perfectly acceptable in some environments. In others, it is a deal-breaker.

Reachability metadata has improved the story

The good news is that Native Image is no longer as manually painful as it once was.

GraalVM now documents a formal reachability metadata model and provides a centralized Reachability Metadata Repository to help existing libraries work in native builds more easily. The purpose is exactly what many teams need: reducing the manual burden of configuring reflection and other dynamic elements for third-party dependencies.

That does not remove the closed-world trade-off.

But it does mean the ecosystem is more mature now than it was in the early “good luck with your JSON files” phase.

A brief note on GraalVM’s polyglot side

There is one more GraalVM capability worth mentioning, although it should remain a side note in this article.

GraalVM is also a polyglot platform. Its documentation shows that Java applications can embed guest-language code, including JavaScript and Python, through the Polyglot API. The official examples include evaluating JavaScript and Python functions directly from Java.

That is interesting because it expands what “deployment” can mean in a GraalVM-based system. In some architectures, a Java server can host dynamic language code without leaving the GraalVM ecosystem.

I would not go deeper here, because that is a different article. But it is worth mentioning as part of GraalVM’s broader vision.

Comparison table: four deployment approaches

Here is the practical summary.

[embed]

The exact numbers will vary by application, of course, but the direction of the trade-offs is stable: JARs maximize flexibility, jpackage maximizes self-containment for installed apps, GraalVM on the JVM preserves dynamism while improving the runtime engine, and Native Image prioritizes startup and deployment efficiency by shifting more responsibility into the build phase.

The architectural lesson: modular monolith first

I do not think GraalVM should be treated as a universal excuse to split everything into microservices.

That is usually the wrong order of thinking.

A healthier strategy is this:

First, modularize the monolith.

Create clear domain boundaries.

Expose only what should be shared.

Reduce accidental coupling.

Then decide whether any specific slice deserves extraction into its own service.

That gives you options.

Once a system is already partitioned by domain, one module can remain local, another can be packaged as a self-contained JVM application, and another can become a native cloud service if the operational profile justifies it.

That is evolutionary architecture in the good sense. Not fashion-driven architecture, but boundary-driven architecture.

And that is where Java’s deployment spectrum becomes genuinely powerful.

Final thought

The biggest mistake in Java deployment discussions is assuming there is a single “modern” answer.

There is not.

There is only a set of trade-offs.

A JAR is still valid.

jlink and jpackage are excellent for self-contained distribution.

GraalVM on the JVM is compelling when you want performance without surrendering runtime flexibility.

Native Image is powerful when startup speed, density, and cloud efficiency matter enough to justify heavier builds and stricter assumptions.

So the real question is not:

“Which deployment model is best?”

It is:

“How much runtime do I want to carry, and how much dynamism am I willing to trade for operational efficiency?”

Once you ask that honestly, Java deployment stops looking like a bag of tools and starts looking like what it really is: a set of architectural decisions.

JSanca

Platform Architect exploring the boundaries between simplicity and scalability


메타데이터
post_id
5b29e27d23ee
slug
java-deployment-without-illusions-from-bytecode-to-jpackage-graalvm-and-evolutionary-5b29e27d23ee
url
https://medium.com/@jsanca/java-deployment-without-illusions-from-bytecode-to-jpackage-graalvm-and-evolutionary-5b29e27d23ee
canonical_url
https://medium.com/@jsanca/java-deployment-without-illusions-from-bytecode-to-jpackage-graalvm-and-evolutionary-5b29e27d23ee
author_url
https://medium.com/@jsanca
status
ok
fetched_at
2026-07-13 06:23:13