Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Write Once, Run Anywhere: Bytecode, Compatibility, and Practical Limits

Hello! Last lesson, you compiled Journey.java, ran the resulting class, and used javap to inspect its bytecode. You also saw that editing the source did not change the program until you compiled again. That separation between source and class file is the starting point for Java’s portability claim.

Today we’ll examine what “write once, run anywhere” actually requires: a shared class-file format, a compatible JVM and APIs, and rules for evolving libraries without unnecessarily breaking existing programs. We’ll also identify where the promise stops. You can take this in three roughly 15-minute sessions: the portability mechanism, a Java 21 command-line check, and compatibility in practice.


One binary format, many implementations

The original problem was not merely compiling for two processors. Java was designed for software that could be distributed to different machines without knowing their hardware and operating systems in advance. Compiling separately for every destination would undermine that distribution model.

Read the historical motivation and the JVM’s answer in the Java SE 21 Java Virtual Machine Specification:

Chapter 1. Introduction

Chapter 1 of the Java Virtual Machine Specification connects Java's original distribution problem to the class-file format that makes portable binaries possible.

In Section 1.1, "A Bit of History," read the design problem in the opening paragraph. Then, in Section 1.2, "The Java Virtual Machine," read the JVM description from its opening through the paragraph describing the class file. Focus on what is standardized and what an implementation is free to do differently.

The key boundary is this: javac emits JVM instructions and symbolic references, not instructions for a particular CPU. A class file can refer to a method by its class, name, and descriptor without embedding that method’s eventual memory address. On each host, an implementation of the JVM loads the class, checks that it meets class-file constraints, resolves the references it needs, and executes the instructions. It may interpret them or compile them into native instructions for that host.

Thus the same class file can be input to JVM implementations on different hardware and operating systems. The implementations need not use the same interpreter, compiler, memory layout, or garbage collector. They do need to honor the relevant JVM rules, while the program also needs the APIs and dependencies it uses. Your Journey.class is a modest example: its loop and method calls are expressed in JVM terms, not in the machine instructions of the computer on which you ran javac.

This also explains why the JVM is not synonymous with the Java language. As the specification notes, it consumes valid class files; another language can produce those files too. Portability attaches to a supported binary format and its execution contract, not to a requirement that every program was written in Java or that every host runs identical native code.


The release number is part of the contract

A portable class file is not automatically readable by every JVM ever released. Class files carry a version. A JVM must understand the format and features represented by the class files it loads; an older runtime cannot generally load class files produced for a newer release.

You can inspect that boundary with the tools you used last time. In the directory containing Journey.java, first check that javac is from Java 21, then run:

javac Journey.java
javap -verbose -cp . Journey

Near the beginning of the javap output, find major version: 65, the Java 21 class-file version. For this check, you do not need to examine the constant pool or every method attribute; those structures get their own treatment in the next module.

Now create a directory named out17 and compile the same source for Java 17:

mkdir out17
javac --release 17 -d out17 Journey.java
javap -verbose -cp out17 Journey
java -cp out17 Journey

This time, look for major version: 61. The Java 21 runtime should run that class. If you retained the one-character print change from last lesson, the displayed result will differ from the original Journey output; the version comparison does not depend on which version of that print statement you kept.

--release 17 does more than request an older version number. It tells javac to compile using Java 17 language rules and the Java SE APIs available for that release, as well as to emit a Java 17 class file. That matters because merely producing an older-looking class file would be insufficient if its code referenced an API absent from Java 17. Conversely, --release does not validate every third-party dependency, native library, configuration setting, or destination environment your application needs.

If you have only Java 21 installed, this is a one-way check: you have shown that Java 21 runs the Java 17-targeted class, not that you have tested it on a Java 17 runtime. Attempting to load the ordinary Java 21-targeted class on Java 17 would fail with an UnsupportedClassVersionError; do not confuse that format-version failure with a library’s binary-compatibility failure.


Compatibility has more than one meaning

Class-file versions answer whether a runtime can understand a binary’s format. They do not, by themselves, answer what happens when an API or library changes. The OpenJDK compatibility guide makes a useful three-way distinction. Read its definitions, then the selected examples rather than the whole page:

Kinds of Compatibility - Compatibility & Specification Review - OpenJDK Wiki

Joe Darcy's OpenJDK compatibility guide separates source, binary, and behavioral compatibility. Read it to sharpen what you mean when you say an upgrade is "compatible."

At the start of the page, read the three definitions. In "Source Compatibility," find the Lib.foo example; read the overload comparison, stopping after the two javap-style call sites. In "Binary Compatibility," read the linkage discussion. Finally, in "Behavioral Compatibility," find the HashSet example and read the order example. Ask of each example: does the source compile, does the existing class file link, and does the observed result stay the same?

These are different questions, not increasingly strict names for the same guarantee:

Question Compatibility concern
Will existing source compile against the changed API? Source compatibility
Will an existing class file that linked before still link against it? Binary compatibility
Will running the program produce equivalent behavior under the relevant contract? Behavioral compatibility

For an experienced application team, the distinction between using an old client binary and recompiling its source is especially important. Suppose version 1 of a library has only format(double), and client source calls format(7). The compiler widens the integer and records a call to format(double) in the client class file. Version 2 adds format(int) without removing format(double).

The old client class file still requests format(double); adding the overload has not rewritten it. But if you recompile the same client source against version 2, overload resolution selects format(int) instead. Both compilations can succeed, and the library change can preserve linkage for the old binary, yet the newly compiled program may behave differently if the two methods implement different policies. The OpenJDK guide’s bytecode call sites make that otherwise invisible change concrete.

The reverse distinction matters too. A library can keep a method’s name and signature while changing its body to throw an exception. Existing clients can still link, so the change need not break binary compatibility, but it plainly changes behavior. And behavior has a contract boundary: if an API such as HashSet does not promise iteration order, observing a different order on another version or implementation is not, by itself, a violation of its specified behavior. Code that treats an observation as a guarantee creates a portability dependency of its own.


Where the promise meets real applications

A JVM-supported class file is only one part of an application. A production deployment also brings libraries, generated classes, native components, and expectations about its surroundings. For one concrete example of that boundary, watch this short portion of A Classfile API for the JDK from the Java channel:

A Classfile API for the JDK #JVMLS

This segment of "A Classfile API for the JDK" explains a practical upgrade problem: a framework's class-file-processing library may not understand classes from a newer JDK, even when the JVM itself can run them. Treat the proposed API in the talk as context for the problem, not as a Java 21 feature to use in this lesson.

Watch the framework dilemma. Focus on the example of bytecode-processing tools encountering a newer class-file version, and distinguish their ability to parse a class from the JVM's ability to load it.

That example illustrates why “the JVM supports this release” is not a complete deployment test. A framework that scans or transforms class files has its own format support to maintain. Similar limits arise if an application calls native code, launches an operating-system process, or expects a particular installed dependency. Bytecode removes the need to distribute CPU-specific instructions for ordinary Java code; it does not turn external components into portable class files.

There are also differences a correct JVM need not hide. File paths, available character sets, time zones, environment variables, permissions, memory limits, and timing can differ between hosts. Some of these are accessible through standard Java APIs precisely so applications can handle them, but the API cannot make the surrounding environments identical. A program may run everywhere and still produce different results if its result depends on an environment value—or on behavior its APIs never promised.

When assessing a real “runs anywhere” claim, use a layered diagnosis:

  1. Can the destination runtime read the class files? Check release targeting and class-file versions.
  2. Can the application link? Check that the required API members and compatible dependencies are present.
  3. Can its integrations operate? Check native components, processes, files, services, and permissions.
  4. Does it behave as intended? Test the specified result under the destination’s inputs and environment, rather than assuming identical observations imply a guarantee.

These checks make the slogan more useful, not less. They identify what Java standardized—and therefore what you no longer have to rebuild for each CPU—while making the remaining portability work visible.


Closing perspective

Bytecode supplies a common delivery format; a compatible JVM supplies its execution; compatibility rules help that format and its APIs evolve without needlessly stranding existing programs. None of those guarantees says that a newer class file runs on an older JVM, that linkage proves equivalent behavior, or that an application’s external environment is the same everywhere.

Next, we’ll step back from this technical boundary and build the module’s historical timeline: how Java moved from Oak and Java 1.0 through changes in governance and release cadence. That history helps explain why preserving old binaries and enabling new features have remained competing design pressures.

Can't find a good explanation? Sign up and we'll make it for you

Sign up