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 Frobbers 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.