Skip to content
HN On Hacker News ↗

Value Classes Still Need Compiler Sympathy

▲ 89 points 46 comments by lichtenberger 2w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this text is a mix of AI and human-written content.

22 %

AI likelihood · overall

Mixed
82% human-written 18% AI-generated
SEGMENTS · HUMAN 1 of 2
SEGMENTS · AI 1 of 2
WORD COUNT 772
PEAK AI % 76% · §2
Analyzed
Aug 26
backend: pangram/v3.3
Segments scanned
2 windows
avg 386 words each
Distribution
82 / 18%
human / AI fraction
Verdict
Mixed
Pangram v3.3

Article text · 772 words · 2 segments analyzed

Human AI-generated
§1 Human · 8%

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.

§2 AI · 76%

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.