p.enthalabs

Value Classes Still Need Compiler Sympathy

_This post discusses preview features in JDK 28_

JEP 401, a major Valhalla milestone, has been integrated as a preview feature in JDK 28. This is very exciting, as value classes increase both our ability to communicate the semantics of our programs to others and the optimization opportunities available to the JVM.

However, I have seen people online essentially taking a “Value all the classes!” approach to this. I worry that there is a belief that ordinary classes give you a floor on performance, and that value classes will do their best to raise you above that floor, but won’t ever take you below it. Unfortunately, that is not true. A well-intentioned program may put the JVM into a situation where a flattened representation is faster for some methods, and a reference representation is faster for others. When these methods interact, the JVM is forced to convert between the two representations. I want value classes to be more than just magic, so today I am going to show you what the JVM is capable of **right now**, and where its limitations are. I hope that with this you’ll have some context for reasoning about the code that you (or your AI agent) write.

The main optimization advantage of value classes is that we give up identity. This gives the JVM freedom to choose a suitable representation for a particular situation. Without the requirement of identity, the runtime can more readily flatten values (avoiding pointer chasing), scalarize them by representing their components independently in registers or on the stack. For the value object itself, escape analysis becomes trivial: there is no identity whose escape must be proven unobservable.

We are going to examine three examples: a large final value stored flat, a direct value transformation compiled without allocation, and a generic virtual call that requires materialization.

Immutability enables flattening

JEP 539, Strict Field Initialization in the JVM, lets the JVM rely on a final field having been initialized before its enclosing object becomes observable. Because such a field cannot later be updated, the JVM may use a non-atomic flattened layout without risking a torn assignment. Mutable fields, however, must preserve tear-free assignment. If a mutable field contains a value that is too large for an atomic flattened update, the JVM must instead use a reference layout. The strict-initialization guarantee opens up many optimization possibilities.

Consider this small example:

``` value record FourLongs(long a, long b, long c, long d) {}

record Envelope(FourLongs payload) {} ```

`FourLongs` has 32 bytes of payload, making it too large for an atomic flattened update in the current JVM. But `Envelope.payload` is a record component and therefore a strictly initialized final field: once initialized, it is never updated. The JVM is consequently free to store `payload` using a non-atomic flattened layout. In the current Valhalla master build, the field-layout diagnostic reports the following when using `PrintFieldLayout`:

``` Layout of class 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 class Envelope @8 FLAT 33/8 "payload" LFourLongs; FourLongs NULLABLE_NON_ATOMIC_FLAT ```

Here we can see that a `FourLongs` consists of its four components and a 1-byte null marker, and that it supports a nullable, non-atomic flattened layout. The runtime uses this fact in the `Envelope` record, and allows `FourLongs` to be flattened. The key point is that `Envelope` is also immutable; the layout would have to change if we replaced it with a mutable class:

``` class MutableEnvelope { public FourLongs payload; public MutableEnvelope(FourLongs payload) { this.payload = payload; } } ```

``` Layout of class MutableEnvelope @8 REGULAR 4/4 "payload" LFourLongs; ```

Why is that? Let’s consider a data race between two 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 field requires writing its individual components. If thread1 and thread2 wrote those components independently, another thread could observe a torn value such as `(1, 1, 0, 0)`, assembled from parts of two different assignments. The Java Memory Model forbids such tearing: after both threads have joined, this program may print only `(1, 0, 0, 0)` or `(0, 1, 0, 0)`. Guaranteeing tear-free assignment for a flattened value this large would be expensive, so the current JVM uses a reference layout. Each thread constructs a complete FourLongs and then performs an atomic reference store.

Removing identity removes the allocation

If we have a small function that changes a component in a loop, like this:

``` static FourLongs bumpA(FourLongs value) { return new FourLongs(value.a() + 1, value.b(), value.c(), value.d()); }

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

At the source level, we can see that every call to `bumpA` constructs a new `FourLongs`. At the call site of `run`, however, we can see that the returned value is effectively only there to change the `a` component of `value`. A good optimizing compiler ought to be able to recognize that as well. It turns out that C2 is a pretty good compiler! C2 keeps the representation scalarized, and it even recognizes that the final result must be `iterations + (2 + 3 + 4) = iterations + 9` when `iterations >= 0`. The following 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) with 0 b.le done ; If less or equal to 0, jump to done add x0, x0, w1, sxtw ; Set x0 = x0 + w1, sign-extending w1 to 64 bits done: ret ```

If we keep `bumpA` unchanged but make `FourLongs` an identity record, C2 must prove that its identity has no impact on the computation. Compilers can often do this, but now we depend on the compiler proving it in each case. When I tried to do this (by removing `value` from the `FourLongs` declaration), C2 was not capable of performing this optimization.

The following is an abridged excerpt of C2’s compilation of `IdentityRecordExperiment::run`. The allocation remains in `run`’s loop even though `bumpA` has been inlined:

```

{method} static 'run' '(J)J' in 'IdentityRecordExperiment'

; initial 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] ; commit allocation ; object initialization omitted

; loop body: allocation from inlined bumpA ldr x0, [x28, #TLAB_TOP] ldr x10, [x28, #TLAB_END] add x11, x0, #0x28 ; reserve another 40 bytes cmp x11, x10 b.hs slow_allocation ; object-header setup omitted str x11, [x28, #TLAB_TOP] ; commit allocation ; object initialization omitted ```

Clearly, providing the compiler with stronger semantic guarantees can sometimes produce a very big win.

Type erasure brings the allocation back

Now we are going to look at something a bit more complex. This example is derived from an email we received from a user on the valhalla-dev mailing list. He had ported a parsing library from Elm to Java and noticed a slowdown after converting all of his records to value records. A performance regression is obviously not the result we want, but an unexpected result like this is fascinating: value classes give the JVM more semantic information and greater freedom, so how could using them make the program slower? I did a deep dive to find the cause and a source-level fix. This investigation has opened up an interesting compiler problem that my colleagues on the C2 team are now actively investigating. I’ll explain my findings here, but please keep in mind that I’ve had to simplify this greatly. The JVM and `javac` are both fairly complex, so I have to leave out details.

``` value record LargeValue(long a, long b, long c, long d) {} value record 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 on purpose */ } final class DrobIt implements Frobber { /* impl omitted on purpose */ } ```

This is pretty simple code. We have multiple `Frobber`s that take a `LargeValue` and produce a `Carrier`, which contains another `LargeValue`. The `Frobber` interface extends the `Fun<R, F>` interface.

Let’s look at this through the lens of the JVM so that we can understand what is happening. Java implements generics through type erasure, replacing these type parameters with `Object`. That means that `Frobber` effectively inherits this method as far as the JVM is concerned:

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

The typed signature `Carrier apply(LargeValue)` does not appear in the inherited JVM method descriptor and therefore has to be recovered through dynamic analysis. To accommodate this type discrepancy, `javac` generates bridge methods in the class file. Each implementation gains a method approximately equivalent to this:

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

The bridge accepts the erased argument, casts it to the expected type, and invokes the method we actually wrote. Now consider 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 two separate interface call sites here. If each call site only observes one implementation, C2 can devirtualize it. For example, the first call site might always receive a `FrobIt`, while the second always receives a `GrobIt`. C2 can independently guard and inline both targets. In this experiment, C2 managed to remove all heap allocations. In pseudo-Java, the result looks like this. We represent 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, there is no longer a call to the generated bridge. Devirtualizing and inlining the target also inlines its bridge. Once the bridge has disappeared into the surrounding compilation, there is no longer a real `Object apply(Object)` call boundary.

In the report we received, however, the call sites were megamorphic. In our example, that means `FrobIt`, `GrobIt`, and `DrobIt` were all called interchangeably. With three hot implementations at each call site, C2 leaves the calls dynamically dispatched:

``` invokeinterface Frobber.apply:(Object)Object ```

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

The caller currently has a scalarized `LargeValue`, but `Object apply(Object)` cannot accept four independent scalar components. It requires a genuine reference. The caller must therefore materialize the value before making the call.

The dynamically selected bridge then has to translate in the other direction:

``` Object FrobIt_apply_bridge(Object argument) { LargeValueFields value = scalarize((LargeValue) argument);

CarrierFields result = FrobIt_apply_typed(value);

return materializeCarrier(result); } ```

The bridge casts the incoming reference to `LargeValue`, extracts its components, and invokes the typed implementation using the scalarized value-object calling convention. The typed implementation returns a scalarized `Carrier`, but the bridge itself promises to return `Object`, so it must materialize the result before returning.

The megamorphic version of `reproduce` therefore looks approximately like 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 bridge is acting as an ABI adapter. On one side is the erased `Object apply(Object)` calling convention. On the other is the typed `Carrier apply(LargeValue)` calling convention, where value components can be passed and returned in scalarized form.

As you can imagine, this is very expensive. At each call, the caller has to turn a scalarized value into an object reference, which means materializing the value on the heap. It cannot simply point the callee at temporary stack storage: the call is opaque, so the callee may retain the reference and access it after the caller returns. The argument must therefore be a GC-managed heap object.

Luckily, the fix is very simple! We avoid this by explicitly redeclaring the typed method in `Frobber`:

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

The `@Override` annotation documents what we are doing, but the important part is the explicit method declaration. Calls whose static receiver type is `Frobber` now use the typed descriptor directly:

``` invokeinterface Frobber.apply:(LargeValue)Carrier ```

The call is still megamorphic. C2 still does not know whether it will dispatch to `FrobIt`, `GrobIt`, or `DrobIt`. But it no longer needs that knowledge to choose the correct calling convention. Every possible target accepts a `LargeValue` and returns a `Carrier`, so the values can cross the dynamic call boundary in 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 version allocated 192 bytes per invocation of `reproduce`. Explicitly redeclaring the typed method reduced that to zero.

Conclusion

Declaring a value class is first and foremost a semantic decision. It tells our fellow programmers that its instances are defined entirely by their state and do not need identity. That clearer model is valuable in itself! The JVM’s additional freedom to optimize how those values are represented is a welcome bonus.

C2 can do amazing things with that freedom, but it cannot always recover information hidden behind abstraction boundaries. Profiling and inspecting the generated code remain the best ways to understand what is happening.

To get the best results, we may still need to have a little sympathy for the compiler.

Appendix: printing C2 assembly

If you want to double-check my work, you can take these code snippets and inspect the assembly yourself. Compile with preview enabled, make sure the target method is invoked often enough to become hot, and then ask the VM to compile and print 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 may be compiled; it does not trigger compilation. `Example::method` must still be invoked enough times to reach the compilation threshold. Printing assembly requires a JVM build with a disassembler available. Omit `--enable-preview` when compiling and running the ordinary-record comparison.