Back to course

Records

19 minintermediateModule 4: Object ShapesContinues from jvm-annotations
19 min · Module 4: Object Shapes · Foundations

Why This Matters

A record is the first Java class shape that the compiler is required to add members to. Every other class feature so far has either changed how existing members are read or added a final keyword. A record takes a three-line declaration and produces a class file with nine members, seven of which exist nowhere in the source.

That is a design decision about where the language's contract lives. A class's fields are private and its accessors are hand-written, so the file records exactly what was written. A record's fields are private and its accessors are derived from the field list, so the file has to record the field list as a first-class thing — which is what the Record attribute is for.

The attribute is not metadata about a record. It is the declaration, and the accessor methods, the canonical constructor, toString, hashCode and equals are all consequences of it. Take the attribute away and the class stops being a record even though every method is still there. That is a different relationship between source and file than anything else in the format, and it is why the attribute exists rather than being inferred from the field list.

A Simple Model

The attribute is a count and a list of components, each of which is a name, a descriptor, and its own attribute list — the recursive shape from the escape hatch, applied to a member type that did not exist before Java 16:

Record, on the class: u2 components_count then that many record_component_info: u2 component_name_index a Utf8: the component's name u2 component_descriptor_index a Utf8: its type descriptor u2 component_attributes_count then that many attributes <- RECURSION, one level

And that is the entire structure. There is no list of generated methods in it, no flag saying "this is a record", and nothing describing the equals or toString contract — the component list is all of it, and everything else about a record follows from the component list plus the rules in the specification.

Which is a design worth pausing on, because the alternative was obvious. The format could have encoded a record as a class with a boolean flag and a field list, leaving the compiler to infer the rest. Instead it made the component list the declaration, and the components are declared in the attribute and as private final fields. They appear twice, and the duplication is what makes a record a record — drop the attribute and you have a class with the right methods that is not a record.

The Actual Detail

Three components, twenty bytes, and the raw attribute from Rec.class:

  Record: 20 bytes
    00 03                    THREE components
  |  +-- 6 bytes each: name, descriptor, attributes_count

  00 0e 00 0f 00 00        "x"    "I"                    0 attributes
  00 12 00 13 00 00        "name" "Ljava/lang/String;"   0 attributes
  00 16 00 17 00 00        "data" "[J"                   0 attributes

  arithmetic: 2 + 3 * 6 = 20.  Exactly the length.

Now the class those components describe, and the gap between the source and the member table:

  SOURCE                              FILE
  --------                           ----------
  record Rec(int x,                    public final class Rec
            String name,               extends java.lang.Record
            long[] data)               -- body elided --

                                       private final int x;
                                       private final String name;        <- 3 declared
                                       private final long[] data;

                                       public Rec(int, String, long[]);  <- 1 canonical ctor

                                       public final String toString();   <-
                                       public final int hashCode();      <- 3 from Object
                                       public final boolean equals(Object);

                                       public int x();                    <-
                                       public String name();              <- 3 accessors
                                       public long[] data();

Nine members from three source declarations. And the accessors are not marked synthetic — their flags are 0x0001, plain public, not ACC_SYNTHETIC. That is a deliberate and slightly surprising choice, and it has a concrete reason: a record's accessors are part of the public API surface that reflection and the language itself depend on. obj.x() in source compiles to invokevirtual x, and if the method were synthetic the compiler would need to know it exists by some other route. The accessor is real; only the constructor and the three Object overrides are marked differently.

The class flags are 0x0031 — ACC_PUBLIC | ACC_FINAL | ACC_SUPER. There is no ACC_RECORD. That is worth stating because it is a natural assumption and it is wrong: the format does not have a record flag, because the Record attribute is the marker. A reader detecting a record looks for the attribute, and a class that somehow acquired the methods without the attribute is not a record — it is a final class that happens to look like one, and the language will treat it as a normal class.

And the superclass is the other half of the trick.Rec extends java.lang.Record — every record's superclass is java.lang.Record, and that is enforced by the JVM, not by the compiler. A class file claiming to be a record with a different superclass is rejected at load. The reason is a protected constructor: java.lang.Record's only constructor is protected Record(), so no class other than Record itself or one of its subclasses can call it. A record is a subclass of Record and the only such subclass, which is what stops anyone else from being a record. The language expresses "this is a record" by inheritance plus one attribute, and the class hierarchy is what makes the attribute unforgeable.

The component that can carry an attribute

All three components here have attributes_count = 0, which is the common case. The recursion exists for one reason, and it is the same one the Signature attribute was introduced for: a generic component.

record Box<T>(T value, List<String> tags) with an empty body produces a component whose attributes_count is 1, holding a Signature, because the descriptor of List<String> is Ljava/util/List; and the type argument has to go somewhere. So a record component is a position that needed a type-signature, and the escape hatch's answer was to give components their own attribute list rather than widen the component structure. Had the format added a signature field to the component instead, the change would have been a structural edit; adding an attribute list is the additive change the mechanism was built to allow.

It also means a component can be annotated — @NonNull String name produces a RuntimeVisibleAnnotations on the component. That is a genuinely newer position (Java 16 for the component itself, 8 for type annotations that finally had somewhere to go) and it is a good demonstration that the mechanism scales: three of the five attribute positions were added after the format shipped, and none of them changed the file's shape.

A Real Example

What the attribute buys, told as the question it answers. A record is the format's clearest case of a declaration the machine reads directly rather than one the machine infers:

the record pattern, and what a reflection-based tool does with it BEFORE records: match an object's properties by name iterate accessors, compare names case-insensitively "getName" / "name" / "getname" all plausible each component needs its own hand-written code WITH records: the Record attribute IS the component list for each component: the field IS the component read the field directly the accessor is guaranteed to exist and to be the field's value and the pattern-matching switch (Java 21) needs this to be a FILE fact, not a language one: switch on obj: case Rec(int x, String name, long[] data) -- one binding per component, in order compiles to: read the Record attribute, get component N, call component N's accessor, bind to the local N

That last line is the point. Record deconstruction in switch and in instanceof is not something the compiler could implement by convention — the compiler generates a getfield per component, and it needs to know the component order, which is a fact about the class. So the format needs the component list to be a real, ordered, machine-readable thing rather than something inferred from the order of private final fields, which reflection cannot order reliably and which the class file does not otherwise guarantee.

The attribute's existence is what makes the pattern feature implementable at all, and the history runs the right way round: the language feature and the format feature arrived together, with the format designed for it. Compare the other two shapes in this module — a sealed type's permitted subclasses and an enum's constants — where the same design principle appears: each is a first-class list in the file, and the generated members are consequences of it.

And the cost of that choice, honestly. A record component is declared twice — as a private final field and in the attribute — so a rewriter that renames a component must change both, and one that adds or reorders a field has to rebuild the attribute. Compare a plain class, where the field list is the only declaration and there is nothing to keep in sync. The duplication buys the language a machine-readable ordered component list, and it costs a tool that edits class files an invariant to maintain. That is a real trade and it is the kind every format makes when it chooses what the machine will need to read directly.

One detail that catches people: the long[] data component's descriptor is [J, and long takes two local slots in a frame while its component occupies one field and one attribute entry. The two-slot rule is a property of the frame layout, not of the type, and a record component is a field. So a record R(long a) with an empty body has a component with descriptor J — a single field — while its canonical constructor's max_locals accounts for two slots. Same type, two different accounting rules, because they are accounting for different things.

Try It Yourself

$ javap -v -p out/Rec.class
$ python3 courses/jvm/assets/samples/class_decode.py out/Rec.class

Check Your Understanding

Without looking back: a class file has a private final field int x with descriptor I, a public method int x() with descriptor ()I, a public final toString(), a public final hashCode(), a public final equals(Object), and a public constructor (I)V — but no Record attribute. Does the language treat it as a record, and how would a tool tell?

Apply It

You are writing a bytecode library that generates a record class. It emits the three private final fields, the three accessors, toString, hashCode, equals and the canonical constructor. The class loads, all methods work, and pattern-matching switch against it fails to compile with "not a recordable class". Compilation with javac produces a working record, so the difference is in the file. You diff the two class files attribute by attribute. What is missing, and why does its absence not affect anything else about the class?

The general habit: when a format uses an attribute as a declaration rather than a description, the attribute is not redundant with the data it summarises. Generate the consequences and you have something that looks complete and is not.

Connect

Three attributes in a row that are all first-class lists, and that is a pattern rather than a coincidence. The Record components, the PermittedSubclasses indices, and the enum constants behind ACC_ENUM are each a list the machine reads directly, and in each case the compiler generates members as consequences. Compare a plain class, where the field list is the only declaration and the accessors are the programmer's problem — the JVM never has to know which fields are conceptually related, because nothing depends on it.

That is the same division this course has drawn twice before, in DWARF's DW_TAG_ namespace and in a PE's data directories. DWARF has a DW_TAG_structure_type whose children are the members, because a debugger needs to know the members as a group; a PE's import directory is a table rather than scattered descriptors, because the loader needs to walk it. When the machine must relate things, the format makes the relation a structure; when it must not care, the data stays flat. A record's components are a structure precisely because record deconstruction needs the relation to be readable.

The duplicate declaration — field and component — has a counterpart in every format that lets a machine infer structure. A COFF section header restates, per section, what the object header summarises, and an ELF section header does the same in a file whose primary structure is segments. Repetition is the price of letting two readers with different needs find what they need in different places, and here the two readers are the JVM (which needs the fields) and the compiler (which needs the ordered components).

And the java.lang.Record superclass is a different kind of device entirely, and it is worth noticing how the format uses inheritance for something it could have used an attribute for. The format needed "only records can be records" to be true, and it got that from a protected constructor rather than from a check — the same way a sealed class gets its exclusivity from the verifier checking the permitted list, and unlike it. A class hierarchy already has the property "a subclass had to call a superclass constructor"; making that constructor protected is a two-word change that gets the whole constraint. Using an existing mechanism rather than adding a check is the cheaper design whenever it works, and it is the reason there is no ACC_RECORD flag.

Next: sealed types, which is the shortest attribute in the format and the one place where the machine enforces something at load time on the basis of a list it reads from a class file.