Back to course

Version History

20 minintermediateModule 5: Linking and VersioningContinues from jvm-modules
20 min · Module 5: Linking and Versioning · Foundations

Why This Matters

Everything in this course was added to a format that shipped in 1995 and has not changed shape since. Records, lambdas, sealed types, modules, nest access, generic signatures, annotations, string concatenation via invokedynamic — not one of them required a new file layout, a new section, or a new index.

That is a genuinely unusual outcome. Most binary formats that survive thirty years do so by adding structures, and the structures accumulate until the format is a sequence of version-gated branches. The class file avoided that, and the header concept already showed how: major_version is the Java release number, and a class compiled for an older release is exactly as readable as it was the day it shipped.

So the interesting question is not "what changed" but "how did it change thirty times without the format changing?" There are exactly two answers in the whole design, and this course has spent five modules on them: the attribute mechanism for things that describe, and the invokedynamic bootstrap for things that execute. Everything else follows from those two.

A Simple Model

The version is two numbers at a fixed offset, and the first module's finding about the minor version bears repeating because this concept depends on it:

offset 4: u2 minor_version 0, or 65535 (0xFFFF) for "preview" offset 6: u2 major_version 52 = Java 8, 55 = Java 11, 61 = Java 17, 65 = Java 21, 70 = Java 26 major = 45 + the Java feature release number

minor_version = 65535 means a preview build, and it is the only non-zero value a shipped class ever has. That is a feature flag occupying a field that could have held a sub-version, and it is the first of two decisions that explain the whole history: the version numbers things that would break existing readers, and nothing else.

Because that is the actual rule. A new major version is issued when a class file with the new features would be rejected by an old JVM — and with the attribute mechanism and invokedynamic in place, that is almost never. A class using records, sealed types, or nest attributes is rejected by a Java 8 JVM only because the version says 61, not because the structure is incomprehensible. Every feature in this module set the version number and nothing else.

The Actual Detail

One source file, compiled at four release levels, and the measurements. This is the whole argument in a table, and every number came from javac --release followed by javap:

  ONE source file: a lambda, a method reference, a
  functional interface, and a string concatenation.

  release  major  bytes  indy  boots  StringBuilder  NestMembers
  -------  -----  -----  ---  -----  -------------  -----------
      8      52    1608    2      2            10        no
     11      55    1806    4      4             0       yes
     17      61    1806    4      4             0       yes
     21      65    1806    4      4             0       yes

Two findings in that table, and the first is the sharp one.

Java 11 made the file 198 bytes larger. That is counter-intuitive enough to be worth explaining rather than stating. The increase is not the nest attributes — a six-entry NestMembers is 14 bytes. It is the string concatenation. At release 8 the concatenation was a StringBuilder chain in the bytecode; at release 11 it is two extra invokedynamic instructions, two extra bootstrap entries, and the associated pool entries, and the StringBuilder chain is gone entirely. The StringBuilder pattern was never cheaper at run time — the JIT had eliminated it for years — so moving it into the constant pool cost bytes and saved nothing, and bought uniformity.

And here is the StringBuilder chain that disappeared, which is worth reading because it is a whole algorithm written out in bytecode by a compiler rather than by a programmer:

  release 8, ten instructions for "n=" + n:

    15: new           #28    // class java/lang/StringBuilder
    18: dup
    19: invokespecial #30    // StringBuilder."<init>":()V
    22: ldc           #31    // String n=
    24: invokevirtual #33    // StringBuilder.append(Ljava/lang/String;)...
    27: iload_2
    28: invokevirtual #37    // StringBuilder.append(I)...
    31: invokevirtual #40    // StringBuilder.toString:()Ljava/lang/String;
    34: astore_3

  release 11, five bytes:

    ??: invokedynamic #?, 0  // makeConcatWithConstants:(
                            //   Ljava/lang/String;I)Ljava/lang/String;

Ten instructions, seven pool entries, and a StringBuilder that never needed to exist — replaced by five bytes and one Utf8 recipe. The interesting part is that the old form was also wasteful in a way that was visible in the file: new StringBuilder allocates on every concatenation, and a JIT had to recognise and remove it. The invokedynamic form removes the need for that optimisation, not by being faster but by never expressing the problem.

The synthetic lambda methods, unchanged across all four

One detail that cuts against a tidy narrative. The synthetic methods are lambda$main$0 and lambda$main$1 in all four releases — identical, byte for byte:

  private static java.lang.String lambda$main$0();
  private static int lambda$main$1(java.lang.String);

  the naming scheme:  lambda$enclosingMethod$counter
  (and lambda$new$N for a lambda in a constructor)

So the two lambdas cost the same generated code in 2008's compiler and in 2020's. The invokedynamic mechanism did not remove the synthetic method — it made it a parameter to a bootstrap rather than the target of a call, and the method is still there with the same name and the same body. What changed was who decides what to do with it. That is a good corrective to the usual framing: the lambda's implementation was never the expensive part, and it never was.

What each version actually added

The list, restricted to things this course has verified in files rather than things it has not:

  45  Java 1.1     invokedynamic and BootstrapMethods existed
                            in the format from the start and were UNUSED for
                            five years. The instruction was there waiting.
  49  Java 5       ACC_SYNTHETIC, ACC_BRIDGE, generic Signature attribute
  50  Java 6       StackMapTable
  51  Java 7       invokedynamic put to work for string concatenation
  52  Java 8       lambdas via LambdaMetafactory
  55  Java 11     NestHost, NestMembers; string concat moves off
                            StringBuilder; varargs-style stack map changes
  60  Java 16     records (Record attribute), ACC_RECORD on components
  61  Java 17     sealed types (PermittedSubclasses)
  65  Java 21     record patterns, pattern-matching switch
  66  Java 22     the classfile API (java.lang.classfile)
  70  Java 26     -- this course's own samples, compiled here

Every row after 45 is an attribute, a flag, or a use of an existing instruction. Not a single structural change. Compare the same thirty years in a PE, where the optional header grew by dozens of fields, or ELF, where three e_type values and two EI_OSABI values were added — all additive, and all in the version field, which is what a version field is for. The difference is that the PE and ELF additions are new fields in an existing structure, so an old reader walking the old structure is fine but an old reader walking the new structure is not, and the version is the only defence. The class file's additions are new attributes, and a skipped attribute is not merely tolerated but defined to be harmless.

A Real Example

The two mechanisms, and why the format needed only two. This is the synthesis of the whole course, and it is worth stating as a classification because it applies to any format that has to survive being extended:

the ONLY two ways this format has ever grown: 1. AN ATTRIBUTE -- for anything that DESCRIBES a name, a length, and contents the old reader skips. Used for: SourceFile, LineNumberTable, Signature, RuntimeVisibleAnnotations, Record, PermittedSubclasses, InnerClasses, NestHost, NestMembers, Module, BootstrapMethods 2. A BOOTSTRAP -- for anything that EXECUTES a bootstrap method, run once, computing a target the instruction then calls directly. Used for: lambdas, method references, string concatenation everything else is a FLAG or a POOL TAG: ACC_ENUM, ACC_SYNTHETIC, ACC_RECORD, ACC_MODULE, ACC_TRANSITIVE CONSTANT_InvokeDynamic, CONSTANT_MethodHandle, CONSTANT_MethodType CONSTANT_Module, CONSTANT_Package, CONSTANT_Dynamic

And the division of labour is not arbitrary — it is exactly the split the escape hatch concept drew between the closed instruction stream and the open attribute list. A format that executes code from a data file must be able to extend what it executes, and the only way to do that without a new opcode is to make the existing opcode's target a computed thing. That is what invokedynamic is, and it is why it was in the specification in 1995 and unused until 2006: the mechanism was designed in from the start, and the language had nothing to use it for yet.

Compare that with a format that had to add features and could not use either mechanism. DWARF is versioned by tag ranges, so a new tag cannot collide with an old one and an old reader skips a whole version — which works, but it means the producer and consumer must agree on a version, and a producer that emits DWARF 5 for a consumer that knows DWARF 4 gets nothing rather than most of the data. The class file's reader gets everything except the attributes it does not know, which is a strictly better degradation, and it is available because the extension point is a name and a length rather than a version boundary.

The honest cost, and there is one. Because every addition is invisible to old readers, there is no upper bound on how much a class file can accumulate — twenty-four distinct attribute names across this course's twenty-four samples, with SourceFile and InnerClasses repeated because the pool shares them. A large project accumulates a stack of attributes most tools ignore, and the file grows without any version negotiation. That is a real cost and it is the mirror of the benefit: a format where an old reader degrades gracefully also has no mechanism forcing a new reader to be written, and the "just update your JDK" answer is a real maintenance burden that the format's design does not solve for you.

And one structural gap worth naming, since this concept is the natural place for it. The Module attribute is the one major feature that used neither mechanism — it is positional, with no name and no length delimiting its sections, and a sixth kind of directive would need a new major version. So the answer "the format never needed a new layout" is true for every feature in this course except one, and that one is instructive precisely because it is a departure. Nearly everything grew additively; the one feature big enough to need its own structure was the one that could not, and it is the only part of the format a new reader must be taught structurally rather than by name.

Try It Yourself

$ javac --release 8 -d out8 V.java
$ javac --release 11 -d out11 V.java
$ javap -v -p out8/V.class | head -5

Check Your Understanding

Without looking back: the same source compiled at --release 8 and --release 11 produces files of 1608 and 1806 bytes. The release 8 file has 10 StringBuilder references and 2 invokedynamic instructions; the release 11 file has 0 and 4. The release 11 file is 198 bytes larger. Where did the extra bytes go, and why is the answer not "the nest attributes"?

Apply It

You are writing a class file reader for a long-lived tool that has to analyse build output from projects using any JDK from 6 to 26. It parses the header, reads the pool, and walks members. It handles attributes by name and skips what it does not recognise. On a Java 8 project's output it works. On a Java 21 project's output it fails immediately with major version 65 not supported, having read nothing else. The file is structurally no different. What is the defect, and what is the strongest evidence in the situation that the version number is a gate rather than a description of the file's structure?

The general habit: distinguish a field that constrains a consumer from a field that describes a producer. A runtime is obliged to refuse a version it cannot run; a reader is not, and a version number is a ceiling, not a description.

Connect

Thirty years of the same problem, and this course has the whole range. ELF's EI_VERSION is 1 and always has been. A PE's MajorOperatingSystemVersion is a minimum-kernel requirement and the loader honours it. A COFF object's characteristics say what the linker may assume. DWARF versions partition the tag space. Every format answers the same question — how does a reader handle a file from a producer it does not know — and every one picks a different level of strictness, from ELF's permanent version 1 to DWARF's version-partitioned tag ranges to the class file's skip-the-attribute rule.

The class file's answer is the most permissive, and it is worth being honest about why that is not free. Because skipping is always safe, a reader that skips everything it does not know is correct on every file ever produced — which is a guarantee no other format in this collection makes. The price is that nothing forces anyone to adopt a new feature's semantics. An old reader will not reject a class using records; it will simply not know it is a record, and will report it as a class. Silent, correct, partial degradation is a better failure mode than a loud rejection, and it is also a harder one to notice. Compare DWARF, where a consumer that cannot read version 5 knows immediately that it has less than it needs, because the version told it so.

The two mechanisms are the format's whole story, and each is a general technique rather than a Java solution. Attribute-as-escape-hatch is a WebAssembly custom section with a different payload, and it is how DWARF and a PE's data directory both work. Bootstrap-as-deferred-linking is an ELF relocation generalised from writing an address to running a program, and it is why invokedynamic could carry lambdas and string concatenation without either asking the format to change. If you take one architectural idea from this course, it is that a format survives by keeping its executed vocabulary small and fixed while pushing everything new into a deferred layer — and the class file has been doing exactly that, deliberately or not, since 1995.

Which leaves the honest questions, and a course that skipped them would be worse for it. Is the attribute mechanism past its limit? There are now over a hundred defined attribute names, several of them nearly identical, and the naming has grown inconsistent — RuntimeVisibleAnnotations against Signature against Record, three conventions in one list. A consolidated attribute with a sub-kind field would be cleaner, and cannot be introduced without breaking every existing reader, which is the escape hatch working exactly as designed. And can a javap that prints Unknown for two pool tags be trusted on the ones it does know? Yes — this course's cross-check had all three readers agree on 25 files including the module one — but it is a reminder that a tool's labels lag its parsing, and that verifying a reader means comparing structure against something independent rather than against its own output.

That is the end of the course. From a magic number to a format that computes its own instructions, through a constant pool that is 69% of a hello-world, a string encoding that is not the one it is named after, two offset conventions in one attribute, a verifier's conclusions written into the file, and two extension mechanisms that let a thirty-year-old format absorb every language feature since. The version number never moved because the format never had to. The lesson is not about Java.