JEP Translated: Value Classes and Objects
Project Valhalla’s value classes let immutable objects drop identity, so == compares them by their fields and the JVM can flatten them like…

JEP Translated: Value Classes and Objects
Project Valhalla’s value classes let immutable objects drop identity, so == compares them by their fields and the JVM can flatten them like primitives.
If you work on an IAM system, you create immutable data by the truckload: a client IP address, a username, a geolocation, a risk score, a service identifier. None of it ever mutates after construction, and yet every single one is a full-blown object — allocated on the heap, stamped with a header, handed to you behind a pointer. You’re paying for object identity on data that has no use for it. Two 192.168.1.10 addresses are the same address. There is no "which one."
JEP 401 is Project Valhalla’s answer: a value modifier that lets a class opt out of identity entirely. Its instances become immutable and interchangeable, == starts comparing them by their fields, and the JVM gets the freedom to store them inline - no pointer, no header, no separate heap allocation. It's a preview feature, currently submitted and not yet tied to a specific JDK release, but the groundwork has been landing for a while and it's the most consequential thing happening to the language.
Part of the JEP Translated series, where I run Java JEPs through a Claude Code skill that turns the formal spec into something you’d actually read.
JEP 401: Value Classes and Objects (Preview)
TL;DR
You can mark a class value. Its instances lose object identity: all fields are implicitly final, the class is implicitly final, and you can't synchronize on it or rely on it being a distinct object. In exchange, == compares two value objects by their field values (the JEP calls this statewise equivalence), and the JVM is free to flatten and scalarize them - storing their fields directly inside arrays, other objects, and registers instead of behind a pointer. You get the abstraction of a class with the memory layout of a primitive.
Status: preview, currently submitted — not finalized, and not yet assigned to a JDK release. Some of the supporting pieces (flexible constructor bodies, deprecating the boxed-primitive constructors) already shipped in Java 25. Runs behind --enable-preview.
The thing nobody questions: every object has identity
Identity is the property that lets you tell two objects apart even when their state is identical right now. It matters for mutable data. A session object that starts empty and fills up over its lifetime needs identity — two sessions might look alike for a moment, but they’ll diverge, and you need to know which is which.
The trouble is Java forces that property on every object, including the ones that don’t want it. Immutable data is interchangeable by definition. Two GeoLocation values for the same latitude and longitude point at the same spot on earth, forever. There's no scenario where you'd need to know "which" one you're holding. Identity there isn't a feature - it's noise.
And it’s actively confusing noise. Everyone has hit this:
jshell> Integer i = 96, j = 96;
jshell> i == j
$3 ==> true
jshell> Integer x = 1996, y = 1996;
jshell> x == y
$6 ==> false
Same code, different answer, because Integer keeps a small cache that hands back the same object for 96 but not for 1996. That's identity leaking an incidental implementation choice into your == results. An IAM stack feels this at scale - every principal attribute map is stuffed with boxed Integer, Long, Boolean, Optional, and date values, every one of them a heap object carrying an identity nobody asked for.
The deeper problem: identity is a performance tax
The confusion is the part people notice. The cost is the part that actually drove this JEP.
Because every object must have identity, the JVM has to give each one its own spot in memory and refer to it by pointer. Picture the kind of thing an access-control check does all day — hold an array of allowed client addresses and scan it:
int[5]: IpAddress[5] (as identity objects):
+------------+ +--------------+
| 3232235786 | | ptr | ------------> +-----------+
| 3232235787 | | ptr | ------------> | IpAddress | 16-byte header
| 167772165 | | ptr | --> ... | bits=... | + one 32-bit int
| ... | | null | +-----------+
| ... | | ptr | --> ...
+------------+ +--------------+
The int array is one tight block the CPU can stream through. The IpAddress array is a row of pointers, each one sending you off to a separately allocated object - and that object holds a single 32-bit value behind a pointer plus a 16-byte header. You pay for it twice: in footprint (pointer plus header plus the int it's wrapping) and in speed, because walking the array means chasing pointers around the heap, and if those objects landed in different cache lines, every step is a cache miss.
This is why latency-sensitive code so often ditches objects and passes raw ints and Strings around instead. It works, but you throw away everything that makes the code safe - validation in the constructor, a real type the compiler checks, methods that can't be applied to the wrong thing - and you open the door to bugs like handing a raw IP int to a method that expected an epoch timestamp. Value classes are the way out: keep the type, lose the tax.
Declaring one
Put value in front of a class whose instances are immutable and interchangeable. Records are a natural fit, since they're already final with final fields. Here's the geolocation an adaptive-authentication policy might compare a login against:
jshell> value record GeoLocation(double latitude, double longitude) {}
jshell> GeoLocation a = new GeoLocation(40.7128, -74.0060)
jshell> GeoLocation b = new GeoLocation(40.7128, -74.0060)
jshell> Objects.hasIdentity(a)
$7 ==> false
jshell> a == b
$8 ==> true
Two separately constructed GeoLocation values for the same coordinates are ==. That's the whole idea - they're the same value, so the language now agrees they're indistinguishable.
You’re not limited to records, though, and that’s the point I’d underline. A record has to be transparent: its fields are exactly its constructor arguments. Plenty of immutable types aren’t — they store something more efficient internally than they expose. An IPv4 address is the classic case. You want a clean octet-based API, but internally it’s just 32 bits, so you’d never want four separate int fields or a String. That can't be a record, but it can be a value class:
value class IpAddress {
private int bits; // implicitly final - all four octets packed into one int
private IpAddress(int bits) { this.bits = bits; }
public IpAddress(int a, int b, int c, int d) {
this((a << 24) | (b << 16) | (c << 8) | d);
}
public int octet(int index) { return (bits >>> (24 - index * 8)) & 0xFF; }
public String toString() {
return "%d.%d.%d.%d".formatted(octet(0), octet(1), octet(2), octet(3));
}
}
One int on the inside, a dotted-quad API on the outside, no identity, no heap overhead. The value modifier makes the field final for you, makes the class final, and forbids overriding its methods.
The JDK has already done this to about 30 classes in java.* - the boxed primitives (Integer, Long, Double, Boolean...), the Optional family, and most of java.time (LocalDate, Duration, ZonedDateTime...). Which means a lot of the data flowing through a CAS principal is sitting on value classes the moment this lands. String is a notable holdout - it has identity dependencies baked into its API, so it stays an identity class.
== changes meaning, and equals can still disagree
For identity objects == works exactly as it did in 1.0: same object, same memory location. For value objects it tests statewise equivalence - same value class, primitive fields with the same bit patterns, and reference fields that are themselves ==. If two value references are ==, the JVM can swap one for the other and nothing can tell.
Most of the time == and equals line up for value objects. But not always, and the gap is worth understanding. A value class can treat instances as interchangeable (so equals returns true) even when their internal fields differ (so == returns false). Username handling is the perfect IAM example - directories are usually case-insensitive, so two differently-cased spellings are the same principal:
value class Username {
private String raw;
public Username(String s) { raw = s; }
public String toString() { return raw; }
public boolean equals(Object o) {
return o instanceof Username other && raw.equalsIgnoreCase(other.raw);
}
public int hashCode() { return raw.toLowerCase().hashCode(); }
}
jshell> Username u1 = new Username("JSmith");
jshell> Username u2 = new Username("jsmith");
jshell> u1.equals(u2)
$3 ==> true
jshell> u1 == u2
$4 ==> false
Both name the same user, so they’re equals, but their raw fields hold different strings, so they're not ==. The rule that falls out: compare value objects with equals, not ==, unless you specifically mean "identical field-for-field." Floating point has the same wrinkle, and risk-based authentication runs into it directly - two NaN encodings with different bit patterns aren't ==, even though a record's equals treats them as equal:
jshell> value record RiskScore(double value) {}
jshell> RiskScore r1 = new RiskScore(Double.longBitsToDouble(0x7ff8000000000000L))
r1 ==> RiskScore[value=NaN]
jshell> RiskScore r2 = new RiskScore(Double.longBitsToDouble(0x7ff8000000000001L))
r2 ==> RiskScore[value=NaN]
jshell> r1.equals(r2)
$13 ==> true
jshell> r1 == r2
$14 ==> false
One more sharp edge: == on value objects is a deep comparison and the depth is unbounded. If a value object's fields are themselves value objects, a single == traverses the whole structure - which can be slow, or even throw StackOverflowError on a deeply nested one. Constructors are constrained so the recursion can't loop forever, but deep nests are still something to keep in mind.
Safe construction: super() moves to the end
This is the subtle bit, and it’s genuinely new. Because a value object has no identity, no code outside the constructor is ever allowed to see it half-built — a “larval” object with some fields still unset would let you observe a supposedly-final field changing, which breaks the whole model.
Traditionally a constructor calls super(...) first, then sets its own fields. That ordering means a leaky superclass constructor could expose your object before its fields are initialized. So in a value class, the compiler flips it: by default all your constructor code runs in the early-construction phase and the super() call is inserted at the end. During that phase you can set fields but you can't use this - no calling instance methods - because the object isn't ready to be observed:
value class ServiceId {
String pattern;
int hash;
ServiceId(String p) {
pattern = p;
hash = computeHash(); // Error: invokes this.computeHash() before super()
}
private int computeHash() { return pattern.hashCode(); }
}
This builds directly on Flexible Constructor Bodies (JEP 513, finalized in Java 25), which is what made “statements before super()" legal in the first place. Value classes lean on it as the default.
What you can’t do
A few identity-flavored operations are simply gone for value objects, and the compiler or runtime will stop you. The one most likely to bite an existing codebase is locking:
jshell> synchronized (location) { location.notify(); }
| Error: required: a type with identity, found: GeoLocation
Try it through an Object reference and you get an IdentityException at runtime instead. You also can't extend a value class (it's implicitly final), can't override its methods, and can't mutate its fields. None of these are arbitrary - they're the things that only make sense when an object has a stable identity, and a value object deliberately doesn't.
Migrating an existing class
For a final or abstract class whose fields are all final, adding or removing value is a binary-compatible change - existing compiled callers keep working. The risk is behavioral, not binary. If a class had public constructors, some caller may have relied on new always producing an object distinct from every other under ==; make it a value class and that assumption silently breaks. And anyone synchronizing on instances will now fail, at compile time or with an IdentityException.
This is exactly the kind of thing a platform like CAS has to weigh before flipping an internal immutable type to a value class: it’s binary-compatible, but a downstream extension that locked on those instances, or leaned on == distinctness, would break. The JDK's own playbook here is the model - it deprecated the constructors of Integer, Float, and friends in Java 25 and steers you to factories like Integer.valueOf instead, precisely so the value-class migration doesn't yank == semantics out from under existing new Integer(...) code. Deprecate the identity-dependent entry points first, migrate the type later.
Where the speed actually comes from
Once identity is gone, the JVM has two moves, both of which delete the heap object:
- Flattening — when a value object is stored in an array element or another object’s field, the JVM encodes its fields directly into that slot instead of a pointer. That allow-list of
IpAddressfrom earlier stops being an array of pointers to boxed objects and becomes a flat block of (null-flag + int) units. The data lives inside the array, processable with no extra memory loads. - Scalarization — when a value object lives in a local variable or parameter, the JVM spreads its fields across separate locals or registers. No object in memory at all.
IpAddress[5] flattened:
+----------------+
| 1|0xC0A8010A | <- null-flag + 32-bit address, inline
| 1|0xC0A8010B |
| 1|0x0A000005 |
| 0|0x00000000 | <- was null
| 1|0xC0A8010A |
+----------------+
Either way there’s no separate heap allocation, no object header, nothing for the garbage collector to chase, and the data sits right next to whatever’s using it. That’s how an IpAddress[] ends up performing close to an int[]. Not every value class qualifies for every trick - a wide one packs poorly into a single 64-bit slot - but the door is open in a way it never was for identity objects.
Compatibility
It’s a preview feature, so you compile and run with --enable-preview and it can change before it finalizes. There's no source break for existing code - identity classes keep behaving exactly as they do today; nothing becomes a value class unless its author opts in. The migrations to watch are the ones described above: a library turning one of its types into a value class can shift == results and break synchronization for downstream code, which is why the JDK is deprecating identity-dependent entry points ahead of time rather than flipping types silently.
Quick reference
Format below is identity object (today) → value object (with value):
==checks same memory location →==checks statewise equivalence (same fields)- Two equal
IpAddressinstances are==only by luck of caching → equal value objects are always== - An
IpAddress[]is an array of pointers to boxed objects → array can store the packed int directly inline - Immutable data still heap-allocated behind a pointer → JVM can flatten/scalarize it away
- You can
synchronized (obj)on anything → synchronizing on a value object throwsIdentityException - Constructor calls
super(...)first → value-class constructor runssuper()last, nothisuntil then - Fields and class are whatever you declared → fields and class are implicitly final
Source: https://openjdk.org/jeps/401
Why this is the one to watch
Most JEPs add a feature. This one removes an assumption that’s been load-bearing since Java 1.0 — that every object has identity — and the ripple effects are large. It’s the foundation Project Valhalla has been building toward for years, and the reason it’s taken so long is exactly because that assumption is everywhere: in ==, in synchronization, in how the GC thinks, in the memory model.
What I find most interesting is that it’s not asking you to learn a new kind of type. A value object is still a class, with constructors and methods and encapsulation. It just behaves like an int where it counts — distinguished only by its value, free to be copied and inlined. For the kind of code I spend my time in, that’s a big deal: an IAM server is mostly a machine for moving small immutable facts around — identifiers, addresses, attributes, timestamps — and today every one of those facts is a heap object. Value classes let those types stay types while costing what a primitive costs.
It’s preview and unscheduled, so this isn’t a “rewrite your code this quarter” feature. But the supporting pieces are landing release by release — flexible constructor bodies in 25, the boxed-primitive constructor deprecations in 25 — and that’s the tell. When value classes do finalize, a big chunk of java.* will already be value classes underneath you, and your attribute-heavy, IP-checking, date-comparing code will quietly get faster without you touching a line. Worth understanding now so it's not a surprise later.
This article is part of All Things Software. Follow for deep dives into Java, Spring Boot, Apereo CAS, and the tools that make working with them less painful.
메타데이터
- post_id
- 49bfbbbc9fe8
- slug
- jep-translated-value-classes-and-objects-49bfbbbc9fe8
- url
- https://medium.com/all-things-software/jep-translated-value-classes-and-objects-49bfbbbc9fe8
- canonical_url
- https://medium.com/all-things-software/jep-translated-value-classes-and-objects-49bfbbbc9fe8
- author_url
- https://medium.com/@dima767
- status
- ok
- fetched_at
- 2026-06-24 23:31:39