Value Classes Still Need Compiler Sympathy

Aug 26, 2026 03:59 PM - 2 hours ago 1

This station discusses preview features successful JDK 28

JEP 401, a awesome Valhalla milestone, has been integrated arsenic a preview characteristic successful JDK 28. This is very exciting, arsenic worth classes summation some our expertise to pass the semantics of our programs to others and the optimization opportunities disposable to the JVM.

However, I person seen group online fundamentally taking a “Value each the classes!” attack to this. I interest that location is simply a belief that mean classes springiness you a level connected performance, and that worth classes will do their champion to raise you supra that floor, but won’t ever return you beneath it. Unfortunately, that is not true. A well-intentioned programme whitethorn put the JVM into a business wherever a flattened practice is faster for immoderate methods, and a reference practice is faster for others. When these methods interact, the JVM is forced to person betwixt the 2 representations. I want worth classes to beryllium much than conscionable magic, truthful coming I americium going to show you what the JVM is tin of right now, and wherever its limitations are. I dream that pinch this you’ll person immoderate discourse for reasoning astir the codification that you (or your AI agent) write.

The main optimization advantage of worth classes is that we springiness up identity. This gives the JVM state to take a suitable practice for a peculiar situation. Without the request of identity, the runtime tin much readily flatten values (avoiding pointer chasing), scalarize them by representing their components independently successful registers aliases connected the stack. For the worth entity itself, flight study becomes trivial: location is nary personality whose flight must beryllium proven unobservable.

We are going to analyse 3 examples: a ample last worth stored flat, a nonstop worth translator compiled without allocation, and a generic virtual telephone that requires materialization.

Immutability enables flattening

JEP 539, Strict Field Initialization successful the JVM, lets the JVM trust connected a last section having been initialized earlier its enclosing entity becomes observable. Because specified a section cannot later beryllium updated, the JVM whitethorn usage a non-atomic flattened layout without risking a torn assignment. Mutable fields, however, must sphere tear-free assignment. If a mutable section contains a worth that is excessively ample for an atomic flattened update, the JVM must alternatively usage a reference layout. The strict-initialization guarantee opens up galore optimization possibilities.

Consider this mini example:

value grounds FourLongs(long a, long b, long c, long d) {} record Envelope(FourLongs payload) {}

FourLongs has 32 bytes of payload, making it excessively ample for an atomic flattened update successful the existent JVM. But Envelope.payload is simply a grounds constituent and truthful a strictly initialized last field: erstwhile initialized, it is ne'er updated. The JVM is result free to shop payload utilizing a non-atomic flattened layout. In the existent Valhalla maestro build, the field-layout diagnostic reports the pursuing erstwhile utilizing PrintFieldLayout:

Layout of people FourLongs @8 REGULAR 8/8 "a" J @16 REGULAR 8/8 "b" J @24 REGULAR 8/8 "c" J @32 REGULAR 8/8 "d" J @40 NULL_MARKER 1/1 NULLABLE_NON_ATOMIC_FLAT layout: 33/8 Layout of people Envelope @8 FLAT 33/8 "payload" LFourLongs; FourLongs NULLABLE_NON_ATOMIC_FLAT

Here we tin spot that a FourLongs consists of its 4 components and a 1-byte null marker, and that it supports a nullable, non-atomic flattened layout. The runtime uses this truth successful the Envelope record, and allows FourLongs to beryllium flattened. The cardinal constituent is that Envelope is besides immutable; the layout would person to alteration if we replaced it pinch a mutable class:

class MutableEnvelope { public FourLongs payload; public MutableEnvelope(FourLongs payload) { this.payload = payload; } }
Layout of people MutableEnvelope @8 REGULAR 4/4 "payload" LFourLongs;

Why is that? Let’s see a information title betwixt 2 threads:

void thread1(MutableEnvelope a) { a.payload = new FourLongs(1, 0, 0, 0); } void thread2(MutableEnvelope a) { a.payload = new FourLongs(0, 1, 0, 0); } void main() throws InterruptedException { MutableEnvelope a = new MutableEnvelope(new FourLongs(0, 0, 0, 0)); var t1 = new Thread(() -> thread1(a)); var t2 = new Thread(() -> thread2(a)); t1.start(); t2.start(); t1.join(); t2.join(); IO.println(a.payload); }

Writing a flattened section requires penning its individual components. If thread1 and thread2 wrote those components independently, different thread could observe a torn worth specified arsenic (1, 1, 0, 0), assembled from parts of 2 different assignments. The Java Memory Model forbids specified tearing: aft some threads person joined, this programme whitethorn people only (1, 0, 0, 0) aliases (0, 1, 0, 0). Guaranteeing tear-free duty for a flattened worth this ample would beryllium expensive, truthful the existent JVM uses a reference layout. Each thread constructs a complete FourLongs and past performs an atomic reference store.

Removing personality removes the allocation

If we person a mini usability that changes a constituent successful a loop, for illustration this:

static FourLongs bumpA(FourLongs value) { return new FourLongs(value.a() + 1, value.b(), value.c(), value.d()); } static long run(long iterations) { FourLongs worth = new FourLongs(0, 2, 3, 4); for (long one = 0; one < iterations; i++) { worth = bumpA(value); } return value.a() + value.b() + value.c() + value.d(); }

At the root level, we tin spot that each telephone to bumpA constructs a caller FourLongs. At the telephone tract of run, however, we tin spot that the returned worth is efficaciously only location to alteration the a constituent of value. A bully optimizing compiler ought to beryllium capable to admit that arsenic well. It turns retired that C2 is simply a beautiful bully compiler! C2 keeps the practice scalarized, and it moreover recognizes that the last consequence must beryllium iterations + (2 + 3 + 4) = iterations + 9 erstwhile iterations >= 0. The pursuing is an abridged excerpt of the generated code:

mov x0, #9 ; Put 9 into x0, which holds the return value cmp x1, #0 ; Compare x1 (contains iterations) pinch 0 b.le done ; If little aliases adjacent to 0, jump to done add x0, x0, w1, sxtw ; Set x0 = x0 + w1, sign-extending w1 to 64 bits done: ret

If we support bumpA unchanged but make FourLongs an personality record, C2 must beryllium that its personality has nary effect connected the computation. Compilers tin often do this, but now we dangle connected the compiler proving it successful each case. When I tried to do this (by removing worth from the FourLongs declaration), C2 was not tin of performing this optimization.

The pursuing is an abridged excerpt of C2’s compilation of IdentityRecordExperiment::run. The allocation remains successful run’s loop moreover though bumpA has been inlined:

# {method} fixed 'run' '(J)J' successful 'IdentityRecordExperiment' ; first FourLongs allocation ldr x0, [x28, #TLAB_TOP] ldr x10, [x28, #TLAB_END] add x11, x0, #0x28 ; reserve 40 bytes cmp x11, x10 b.hs slow_allocation ; object-header setup omitted str x11, [x28, #TLAB_TOP] ; perpetrate allocation ; entity initialization omitted ; loop body: allocation from inlined bumpA ldr x0, [x28, #TLAB_TOP] ldr x10, [x28, #TLAB_END] add x11, x0, #0x28 ; reserve different 40 bytes cmp x11, x10 b.hs slow_allocation ; object-header setup omitted str x11, [x28, #TLAB_TOP] ; perpetrate allocation ; entity initialization omitted

Clearly, providing the compiler pinch stronger semantic guarantees tin sometimes nutrient a very large win.

Type erasure brings the allocation back

Now we are going to look astatine thing a spot much complex. This illustration is derived from an email we received from a personification connected the valhalla-dev mailing list. He had ported a parsing room from Elm to Java and noticed a slowdown aft converting each of his records to worth records. A capacity regression is evidently not the consequence we want, but an unexpected consequence for illustration this is fascinating: worth classes springiness the JVM much semantic accusation and greater freedom, truthful really could utilizing them make the programme slower? I did a heavy dive to find the origin and a source-level fix. This investigation has opened up an absorbing compiler problem that my colleagues connected the C2 squad are now actively investigating. I’ll explicate my findings here, but please support successful mind that I’ve had to simplify this greatly. The JVM and javac are some reasonably complex, truthful I person to time off retired details.

value grounds LargeValue(long a, long b, long c, long d) {} value grounds Carrier(LargeValue v, boolean b) {} interface Fun<R, F> { R apply(F value); } interface Frobber extends Fun<Carrier, LargeValue> {} final class FrobIt implements Frobber { public Carrier apply(LargeValue value) { return new Carrier(value, true); } } final class GrobIt implements Frobber { /* impl omitted connected intent */ } final class DrobIt implements Frobber { /* impl omitted connected intent */ }

This is beautiful elemental code. We person aggregate Frobbers that return a LargeValue and nutrient a Carrier, which contains different LargeValue. The Frobber interface extends the Fun<R, F> interface.

Let’s look astatine this done the lens of the JVM truthful that we tin understand what is happening. Java implements generics done type erasure, replacing these type parameters pinch Object. That intends that Frobber efficaciously inherits this method arsenic acold arsenic the JVM is concerned:

interface Frobber extends Fun { Object apply(Object value); }

The typed signature Carrier apply(LargeValue) does not look successful the inherited JVM method descriptor and truthful has to beryllium recovered done move analysis. To accommodate this type discrepancy, javac generates span methods successful the people file. Each implementation gains a method astir balanced to this:

// Generated by javac public Object apply(Object value) { return apply((LargeValue) value); }

The span accepts the erased argument, casts it to the expected type, and invokes the method we really wrote. Now see the method from the original reproducer:

static Carrier reproduce(LargeValue value, Frobber a, Frobber b) { Carrier c = a.apply(value); return b.apply(c.v()); }

There are 2 abstracted interface telephone sites here. If each telephone tract only observes 1 implementation, C2 tin devirtualize it. For example, the first telephone tract mightiness ever person a FrobIt, while the 2nd ever receives a GrobIt. C2 tin independently defender and inline some targets. In this experiment, C2 managed to region each heap allocations. In pseudo-Java, the consequence looks for illustration this. We correspond scalarized values (values that are not heap references) by appending Fields to the type name:

static CarrierFields reproduce( LargeValueFields value, Frobber a, Frobber b) { guard(classOf(a) == FrobIt.class); CarrierFields c = inline(FrobIt_apply(value)); guard(classOf(b) == GrobIt.class); return inline(GrobIt_apply(c.v)); }

At this level, location is nary longer a telephone to the generated bridge. Devirtualizing and inlining the target besides inlines its bridge. Once the span has vanished into the surrounding compilation, location is nary longer a existent Object apply(Object) telephone boundary.

In the study we received, however, the telephone sites were megamorphic. In our example, that intends FrobIt, GrobIt, and DrobIt were each called interchangeably. With 3 basking implementations astatine each telephone site, C2 leaves the calls dynamically dispatched:

invokeinterface Frobber.apply:(Object)Object

The inherited method descriptor defines an ABI that requires callers to walk entity references and implementations to return entity references.

The caller presently has a scalarized LargeValue, but Object apply(Object) cannot judge 4 independent scalar components. It requires a genuine reference. The caller must truthful materialize the worth earlier making the call.

The dynamically selected span past has to construe successful the different direction:

Object FrobIt_apply_bridge(Object argument) { LargeValueFields worth = scalarize((LargeValue) argument); CarrierFields consequence = FrobIt_apply_typed(value); return materializeCarrier(result); }

The span casts the incoming reference to LargeValue, extracts its components, and invokes the typed implementation utilizing the scalarized value-object calling convention. The typed implementation returns a scalarized Carrier, but the span itself promises to return Object, truthful it must materialize the consequence earlier returning.

The megamorphic type of reproduce truthful looks astir for illustration this:

static CarrierFields reproduce( LargeValueFields value, Frobber a, Frobber b) { LargeValue argument1 = materializeLargeValue(value); Object returned1 = invokeinterface_apply_Object(a, argument1); Carrier carrier1 = (Carrier) returned1; CarrierFields c = scalarize(carrier1); LargeValue argument2 = materializeLargeValue(c.v); Object returned2 = invokeinterface_apply_Object(b, argument2); Carrier carrier2 = (Carrier) returned2; return scalarize(carrier2); }

The span is acting arsenic an ABI adapter. On 1 broadside is the erased Object apply(Object) calling convention. On the different is the typed Carrier apply(LargeValue) calling convention, wherever worth components tin beryllium passed and returned successful scalarized form.

As you tin imagine, this is very expensive. At each call, the caller has to move a scalarized worth into an entity reference, which intends materializing the worth connected the heap. It cannot simply constituent the callee astatine impermanent stack storage: the telephone is opaque, truthful the callee whitethorn clasp the reference and entree it aft the caller returns. The statement must truthful beryllium a GC-managed heap object.

Luckily, the hole is very simple! We debar this by explicitly redeclaring the typed method successful Frobber:

interface Frobber extends Fun<Carrier, LargeValue> { @Override Carrier apply(LargeValue value); }

The @Override note documents what we are doing, but the important portion is the definitive method declaration. Calls whose fixed receiver type is Frobber now usage the typed descriptor directly:

invokeinterface Frobber.apply:(LargeValue)Carrier

The telephone is still megamorphic. C2 still does not cognize whether it will dispatch to FrobIt, GrobIt, aliases DrobIt. But it nary longer needs that knowledge to take the correct calling convention. Every imaginable target accepts a LargeValue and returns a Carrier, truthful the values tin transverse the move telephone bound successful scalarized form:

static CarrierFields reproduce( LargeValueFields value, Frobber a, Frobber b) { CarrierFields c = invokeinterface_typed_apply(a, value); return invokeinterface_typed_apply(b, c.v); }

In the reproducer, the erased megamorphic type allocated 192 bytes per invocation of reproduce. Explicitly redeclaring the typed method reduced that to zero.

Conclusion

Declaring a worth people is first and foremost a semantic decision. It tells our chap programmers that its instances are defined wholly by their authorities and do not request identity. That clearer exemplary is valuable successful itself! The JVM’s further state to optimize really those values are represented is simply a invited bonus.

C2 tin do astonishing things pinch that freedom, but it cannot ever retrieve accusation hidden down abstraction boundaries. Profiling and inspecting the generated codification stay the champion ways to understand what is happening.

To get the champion results, we whitethorn still request to person a small sympathy for the compiler.

Appendix: printing C2 assembly

If you want to double-check my work, you tin return these codification snippets and inspect the assembly yourself. Compile pinch preview enabled, make judge the target method is invoked often capable to go hot, and past inquire the VM to compile and people it:

javac --enable-preview --release 28 Example.java java --enable-preview -Xbatch -XX:-TieredCompilation \ -XX:+UnlockDiagnosticVMOptions \ -XX:CompileCommand=compileonly,Example::method \ -XX:CompileCommand=print,Example::method \ -XX:+PrintAssembly Example

-Xbatch makes compilation synchronous, and compileonly keeps the output focused. Note that compileonly restricts which methods whitethorn beryllium compiled; it does not trigger compilation. Example::method must still beryllium invoked capable times to scope the compilation threshold. Printing assembly requires a JVM build pinch a disassembler available. Omit --enable-preview erstwhile compiling and moving the ordinary-record comparison.

More