Value Classes Still Need Compiler Sympathy
Johan Sjölén
This post discusses the preview feature described by JEP 401, Value Classes and Objects.
One of the consequences of introducing value classes in Java and allowing programmers to give up identity is that they give the JVM freedom to choose a suitable representation for a particular situation. Without identity, the runtime can flatten values, scalarize them, or avoid materializing them at all.
That freedom does not remove the programmer’s responsibility. We still decide which abstraction boundaries the compiler must cross, and those boundaries may preserve or discard the representation information that the runtime needs. I want to show what the JVM can do today, and the limitations it has today, so that you have context for reasoning about the code you write.
This post examines three representation paths: a large final value stored flat, a direct value transformation compiled without allocation, and a generic virtual call that requires materialization.
These are showcases, not performance promises. Their purpose is to make the representation changes visible.
Showcase 1: a 32-byte final value
value record FourLongs(long a, long b, long c, long d) {}
record Envelope(FourLongs payload) {}
FourLongs has 32 bytes of payload. In the current Valhalla master build, the field-layout diagnostic reports:
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
JEP 401 explicitly permits larger flattened representations for final fields, such as record components. payload is initialized before publication and is not later replaced, so the runtime does not need to support a concurrent replacement of the entire composite value. It therefore does not need the same atomicity in the presence of data races that an ordinary mutable object requires.
If you change the storage location to a volatile field:
final class VolatileEnvelope {
volatile FourLongs payload;
}
The same layout diagnostic now reports a regular reference field:
Layout of class VolatileEnvelope
@8 REGULAR 4/4 "payload" LFourLongs;
The runtime must support reads and writes of that field according to the volatile access rules, so it does not select the nullable non-atomic flat layout used by the final record component.
Showcase 2: a direct value transformation
If we have a small function which 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 source level, we can see that every call to bumpA constructs a new FourLongs. However, in the callsite of run, 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.
Indeed, C2 can carry the components forward, change a, and keep the representation scalarized. On top of that, it recognizes that the final result must be iterations + (2 + 3 + 4) = iterations + 9. 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 with 0
b.le done ; Is less or equal to 0? Jump to done
add x0, x0, w1, sxtw ; Set x0 = x0 + w1 (w1 is 32-bit x1, sxtw indicates that we sign-extend it to 64-bits)
done:
ret
If we keep bumpA unchanged, but route its argument through a volatile field, like this, then the compiled code will change.
static volatile FourLongs published;
static long runWithVolatileBoundary(long iterations) {
FourLongs value = new FourLongs(0, 2, 3, 4);
for (long i = 0; i < iterations; i++) {
published = value;
value = bumpA(published);
}
return value.a() + value.b() + value.c() + value.d();
}
This is the same value transformation, but published must be represented as a volatile reference. Before the next call to bumpA, the runtime materializes the value.
The two call sites therefore use different representation paths for the same logical operation. The direct path keeps the value scalarized. The volatile path writes and reads a reference, which requires a materialized FourLongs object.
The volatile path includes both allocation and publication work in its C2 output. This is an abridged, annotated excerpt; the complete compiled method also contains prologue, guard, slow-path, and deoptimization code.
; TLAB = Thread Local Allocation Buffer
ldr x0, [x28, #TLAB_TOP]
ldr x10, [x28, #TLAB_END]
add x11, x0, #0x30 ; reserve 48 bytes
cmp x11, x10
b.hs slow_allocation
str x11, [x28, #TLAB_TOP]
str x10, [x0] ; initialize object header
stp x1, x2, [x0, #8] ; initialize value components
stp x4, x3, [x0, #0x18]
dmb ishst ; release barrier
stlr w11, [published] ; volatile reference store
Finally, compile the equivalent code without preview:
record IdentityFourLongs(long a, long b, long c, long d) {}
static IdentityFourLongs bumpA(IdentityFourLongs value) {
return new IdentityFourLongs(value.a() + 1, value.b(), value.c(), value.d());
}
C2 can, in principle, scalar-replace identity objects when it proves that their identities do not escape. That is the same family of optimization used for the direct value-record path.
The value-record declaration gives C2 an additional semantic fact: the result has no identity that must be preserved. C2 no longer has to establish that fact through escape analysis before using the scalarized representation. The value semantics therefore make this optimization easier to apply, even though the optimizer can sometimes reach a similar result for ordinary objects.
The following is an abridged excerpt of C2’s compilation of IdentityRecordExperiment::run. The allocation is in run’s loop, even though bumpA has been inlined:
# {method} static 'run' '(J)J' in 'IdentityRecordExperiment'
; initial IdentityFourLongs 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
str x11, [x28, #TLAB_TOP]
str x10, [x0] ; initialize object header
stp xzr, xzr, [x0, #8] ; initialize fields
; loop body: new IdentityFourLongs from 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
str x11, [x28, #TLAB_TOP]
str x10, [x0]
The full output associates this second allocation with IdentityRecordExperiment::bumpA, inlined at the call site inside run.
Showcase 3: an erased virtual boundary
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> {
// Uncommenting this declaration changes the calling shape:
// @Override
// Carrier apply(LargeValue value);
}
final class FrobIt implements Frobber {
public Carrier apply(LargeValue value) {
return new Carrier(value, true);
}
}
static Carrier reproduce(LargeValue value, Frobber a, Frobber b) {
Carrier c = a.apply(value);
return b.apply(c.v());
}
At the source level, reproduce passes a LargeValue into a Frobber and receives a Carrier. But the inherited generic method is erased: the virtual ABI is effectively Object apply(Object).
When C2 can inline a monomorphic implementation, it may still reconstruct the concrete types and recover the efficient path. When it cannot—because the call is megamorphic, separately compiled, cold, or otherwise not inlineable—it must honor the erased calling convention. An Object parameter is an object reference. A flattened LargeValue cannot cross that boundary as four arbitrary scalar lanes unless the caller and callee agree on a more specific ABI.
The essential compiled shape then looks like this:
Carrier callSecondParser(State flattenedInCarrier) {
oop<State> materialized = allocate_instance_from_flattened<State>(flattenedInCarrier);
return parserB.apply(materialized);
}
The allocation is not caused by the value itself. It is caused by changing representations to satisfy an erased virtual call.
The conversion has a recognisable machine-code shape: reserve object space from the thread-local allocation buffer, initialize an object header and the value fields, then publish a reference.
ldr x0, [x28, #TLAB_TOP]
ldr x10, [x28, #TLAB_END]
add x11, x0, #0x30 ; reserve space for materialized value
cmp x11, x10
b.hs slow_allocation
str x11, [x28, #TLAB_TOP]
str x10, [x0] ; object header
stp x1, x2, [x0, #8] ; value components
stp x4, x3, [x0, #0x18]
In a reduced showcase, cycling three implementations through one generic call site prevents C2 from inlining the virtual call. The allocation-free value path then becomes an object-materialization path. This follows from the ABI information available at that boundary.
Give the runtime the shape you mean
If an interface semantically accepts State and produces PStep, it is valuable to say so directly:
interface Parser<Context, Problem, Value>
extends Function1<State<Context>, PStep<Context, Problem, Value>> {
@Override
PStep<Context, Problem, Value> apply(State<Context> state);
}
This gives the JVM a value-specific method shape, rather than only an erased Object -> Object one. In the parser case, that can avoid materializing a flattened State solely to call the next parser.
Start with the semantics: make a class a value class only when identity is not part of its meaning. Then inspect the representation path. Generic and virtual boundaries can erase facts needed to keep a value flat or scalarized; typed declarations retain some of those facts in the method shape. Improving how the platform carries this information through generic code is ongoing work, but these examples make the current trade-offs concrete.
Appendix: printing C2 assembly
If you want to double check my work, then you can take the code snippets and look at the assembly yourself. Compile with preview enabled, then ask the VM to compile and print one method:
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. Assembly printing requires a JVM build with a disassembler available. Omit --enable-preview when compiling and running the ordinary-record comparison.