At some point in your Java career you wrote this and did a double take:

Integer i = 1, j = 1;
i == j        // true

Integer x = 1996, y = 1996;
x == y        // false

So you did what everyone does: you searched for it.

You found the answer fast, because the question has been on Stack Overflow since November 2009 with a couple hundred votes. The explanation is always the same: Integer x = 1996 doesn’t call new Integer(1996), it calls Integer.valueOf(1996). And valueOf keeps a cache of ready-made instances from -128 to 127. Inside that range you get the same object back every time. Outside it, a fresh object per call.

Fair enough. You filed away “compare wrappers with equals”, closed the tab, and moved on.

And it’s easy to dismiss this case. Boxing is your choice: use int, stop comparing wrappers with ==, and the problem evaporates. Interview trivia, lab curiosity.

But what happens when there’s no primitive to fall back on?

LocalDate d1 = LocalDate.of(1996, 1, 23);
LocalDate d2 = d1.plusYears(30);      // 2026-01-23
LocalDate d3 = d2.minusYears(30);     // 1996-01-23

d1.equals(d3)   // true
d1 == d3        // false

LocalDate has no literal syntax. It has no primitive version. If you need a date, you’re using an object.

And there’s no cache in this story to blame. d1 and d3 are the same date, same year, same month, same day. equals agrees. == doesn’t.

So “use equals” is the medicine, not the diagnosis. The question nobody answered is why == compares memory addresses in the first place.

It has an answer. On July 31st, 2026, a commit of 208 thousand lines landed in the OpenJDK and changed that answer for the first time since Java 1.0.

Identity is being able to tell two identical things apart

Before Java, think about two things in the physical world.

Nobody cares which twenty-dollar bill they got as change. If I swap yours for another one while you look away, nothing happened. Two twenties are interchangeable.

Now your car. You lend it to a neighbor and you want THAT car back, not an identical one.

The difference between those two cases is what we call identity: the ability to distinguish two things that hold exactly the same content.

And here’s the thing. Identity is only useful for things that change.

The car matters individually because it accumulates history: mileage, a dent, an empty tank. If two cars were frozen and never changed, it wouldn’t matter which one you got back. It becomes a twenty-dollar bill.

That gives you the one sentence everything else follows from: identity is a capability only mutable data uses.

You already know this by another name

If you do DDD, this distinction is nothing new. It’s Entity versus Value Object, an idea Ward Cunningham was already describing in 1994 and that Fowler catalogued before Evans put it at the center of modeling.

Entity is the car. Order #4712 is still the same order after changing status three times.

Value Object is the twenty-dollar bill. $50.00 is $50.00, and an EmailAddress is the string it carries.

Except in DDD this was always design discipline and nothing more. You wrote Money immutable, no setters, equals by hand, and documented that it was a value object. The team understood. The compiler didn’t. The JVM even less.

At runtime, your value object was an entity like every other one: its own address, a header, an identity, and == lying to you. You drew the distinction in the domain diagram and paid full price in memory.

That gap is what JEP 401 closes. For the first time value is a word the compiler reads, not a comment in the documentation.

Java assumed everything was a car

Here’s the decision Java made: every object has identity. No exceptions, since 1995.

It was a design choice, and it was reasonable at the time, because objects in Java were born mutable by default.

The problem is that it became a law of physics for the language. And laws of physics have consequences that fall out by gravity, whether you want them or not.

== compares addresses because the address is the identity. Two distinct objects have to live in distinct places, otherwise you can’t tell them apart.

Every object carries a header, and it exists because identity needs somewhere to live. That’s where lock state and the identity hash sit.

You don’t have to take my word for it. JOL, short for Java Object Layout, is an OpenJDK tool that reads the layout the JVM actually chose for an object, field by field, instead of estimating it. I ran it against a LocalDate on JDK 28:

Anatomy of a LocalDate object in memory, bands drawn to scale. On top, a tall dark band labelled header, 8 bytes, annotated lock state plus identity hash. Below it, three blue bands grouped by a brace labelled your data: y equals 1996 at 4 bytes, m equals 1 at 1 byte, and d equals 23 at 1 byte. Last, a hatched padding band at 2 bytes. At the bottom, the total: 16 bytes.

Sixteen bytes of object to carry six bytes of date. The header alone is bigger than the data, and there are still two bytes of padding on top.

And that’s already the slim version. On JDK 28 HotSpot enables compact object headers by default, folding the class pointer into the mark word. Run with -XX:-UseCompactObjectHeaders and the same LocalDate goes back to 24 bytes. The JVM was already fighting this cost from another angle, and even after shrinking the header it remains the largest piece of the object.

Hold on to that figure, because the header comes back at the end of this post. The lock lives in it, and that’s why synchronized is about to stop working.

An array of objects is an array of pointers. If every element needs its own address, the array can’t hold the data. It holds the path to it.

Compare an int[5] with a LocalDate[5], which is the example the JEP itself uses:

Memory layout comparison. On the left, an int array of five slots: one contiguous block holding the values 1996, 2006, 1996, 1 and 23, marked contiguous. On the right, a LocalDate array of five slots: a block of cells where each one holds an arrow pointing outward, the arrows crossing over to loose scattered objects, each with a dark header band on top and the fields y, m and d below. Marked pointers, scattered.

The int array is a single block. The LocalDate one doesn’t hold dates, it holds pointers, and each object ended up wherever the allocator found room, carrying its own header along. Walking that array is a sequence of memory jumps with a cache miss on each one.

Measured with JOL: 32 bytes against 80. Two and a half times the memory to represent the same thing.

All of it to carry an int and two bytes of useful information per date.

The Integer cache is a workaround for not paying identity

Now the surprise from the beginning makes sense.

Identity costs. Every new is an allocation, a header, an address, and one more object for the GC to visit later. Since boxing int happens constantly, somebody decided it was worth reusing the most common instances instead of creating a new object every time.

Hence the cache from -128 to 127.

The catch is that reusing an instance means reusing an identity. And identity is observable through ==.

In other words: the Integer gotcha is the model showing through. An allocation optimization leaked into the semantics of the language, and it could only leak because == talks about identity instead of value.

You weren’t confused. The model was strange. I’ve written before about why Java feels hard (in Portuguese), and a good part of the answer is exactly this: the language asks you to understand old decisions nobody tells you about.

But immutable data never needed it

LocalDate is immutable. Integer is immutable. So are Optional, Duration, BigDecimal, and the Money class you wrote last week.

None of them is a car. They’re all twenty-dollar bills.

You’ve never, in any code you’ve written, needed to know which instance of January 23rd, 1996 you were holding. The JVM had no way to know that, so it charged identity to everyone, at full price, as a guarantee.

That’s what JEP 401 fixes. It gives you a way out.

JEP 401 lets you opt out of identity

Commit cc278dbb implements two JEPs at once, both as preview in JDK 28: JEP 401 (Value Objects) and JEP 539 (Strict Field Initialization). That’s 208,011 lines added, 13,161 removed, 300 files, 64 co-authors and 14 reviewers. It’s the largest Project Valhalla delivery so far.

And the API for it’s one word:

jshell> value record Point(int x, int y) {}
|  created record Point

jshell> Point p = new Point(17, 3)
p ==> Point[x=17, y=3]

jshell> Objects.hasIdentity(p)
$3 ==> false

jshell> new Point(17, 3) == p
$4 ==> true

The value modifier gives you three things for free. Fields become final, the class becomes final, and == starts comparing field by field.

The terms look alike and mean different things:

  • value class is what you declare, with the modifier
  • value object is an instance of it, the object without identity
  • Value Objects is the name of the feature, and Project Valhalla is the umbrella

The platform has already migrated 30 classes:

Package Classes
java.lang Integer, Long, Float, Double, Byte, Short, Character, Boolean, Number, Record
java.util Optional, OptionalInt, OptionalLong, OptionalDouble
java.time LocalDate, LocalTime, LocalDateTime, ZonedDateTime, OffsetTime, OffsetDateTime, Duration, Instant, Period, Year, YearMonth, MonthDay
java.time.chrono MinguoDate, HijrahDate, JapaneseDate, ThaiBuddhistDate

Which answers the surprise from the opening. Real output, run on a build with JEP 401 enabled:

Integer 1996 == 1996      : true
d1 == d3                  : true
Objects.hasIdentity(d1)   : false
Objects.hasIdentity("abcd"): true

Keep an eye on that last line. It comes back at the end.

Writing your own

If your data is already a record, it’s one word:

value record Point(int x, int y) {}

A record is transparent: its fields are exactly the constructor components. When you store state one way and expose it another, say money as a long of cents, you need a plain value class:

value class EURCurrency {
    private long cs;  // implicitly final
    public EURCurrency(long e, int c) { cs = e * 100 + c; }
    public long euros() { return cs / 100; }
    public int cents() { return (int) cs % 100; }
}

The value modifier closes doors, and the compiler is direct about which ones:

error: cannot assign a value to final variable x
error: cannot inherit from final V
error: The concrete class Base is not allowed to be a super class
       of the value class E either directly or indirectly

Fields become final, the class becomes final, and inheriting from a class with identity would mean inheriting the identity along with it. You still get hierarchy: you can implement interfaces, and you can extend an abstract value class, which is how Integer and BigInteger now coexist under Number.

The rule that catches most people is the constructor one. A value object has to be complete before anyone can observe it, so the entire body runs before super(), and this doesn’t exist there yet:

value class Name {
    String name;
    int length;
    private int strLength() { return name.length(); }

    Name(String n) {
        name = n;
        length = strLength();   // error: reference to strLength() may only
    }                           // appear after an explicit constructor invocation
}

The way out is making the method static, or calling super() by hand after setting every field. And with preview enabled this applies to all records, value or not, so a record that uses this in its canonical constructor stops compiling.

As for what to mark, the rule is short: immutable state, and you never need to distinguish two instances holding the same content. If you model with DDD, your value objects package is the first place to look. What stays out is anything mutable, anything used as a lock, and anything holding sensitive data, since == compares private fields.

Without identity, the JVM no longer needs to hand out addresses

Go back to the list of consequences above and invert each one.

If the object has no identity, it doesn’t need to be distinguishable. If it doesn’t need to be distinguishable, it doesn’t need its own address. Which buys the JVM two freedoms.

Flattening means putting the fields directly inside the array or the field that references the object:

Before and after diagram. On the left, BEFORE: an array whose cells point with arrows to loose scattered objects, each with a dark header band and the fields y, m and d. A large arrow points right. On the right, AFTER: a single contiguous block of five rows, each row holding the values 1, 1996, 01 and 23 written directly inside it, with no arrows and no headers. Marked flattened.

No pointers, no headers, all contiguous, with the first bit saying whether the reference is null. The JEP says this array may end up with performance characteristics similar to an int[].

That’s what the JEP describes. I wanted to watch it happen.

I allocated a LocalDate[] of two million slots, all distinct dates, and measured the heap. Same program, same JDK, same machine, changing only --enable-preview:

  bytes per element total
no preview, LocalDate is identity 28.5 56.9 MB
with preview, LocalDate is value 8.4 16.8 MB

Eight bytes per element, which is exactly the 64-bit word the JEP predicted.

And you don’t need to trust my heap measurement to accept it, because the arithmetic closes on its own: if each element were still a pointer to a separate object, the objects alone would take two million times 16 bytes, the minimum size of an object on the heap. That’s 32 MB. It doesn’t fit in 16.8.

The objects aren’t there. Only the values.

Now, a caveat worth more than the measurement: none of this is in the spec. JEP 401 doesn’t even have a Specification section, and it states outright that flattening and scalarization are “optimizations, not language features”, done at the discretion of the JVM. Guaranteeing memory layout is a declared non-goal.

What the JEP guarantees is semantics. A value object has no identity, == compares fields, synchronizing throws. That’s the contract.

The contiguous array is permission, not a promise. The JEP removes what was stopping the JVM from flattening, and each implementation decides whether it does. I showed you one that did.

Scalarization is the next step, inside the JIT. When the object sits in a local variable or a parameter, it gets decomposed into loose values. The compiled plusYears stops taking a pointer and starts taking (boolean isNull, int year, byte month, byte day), returning another tuple like it.

The object simply never exists in memory.

Escape analysis already did something similar for ordinary objects, but a single code path comparing identity makes the optimization evaporate. With a value class the guarantee is static, and it crosses method boundaries.

Less allocation is less GC

This is where it shows up in your Grafana.

Every object the JVM doesn’t allocate is an object the GC doesn’t have to mark, sweep or move. A LocalDate[] of a million slots stops being a million live objects on the heap and becomes a block of memory.

That loop you wrote without thinking, creating a LocalDate per iteration only to throw it away, stops generating garbage. It isn’t that the GC got faster: there’s nothing left to collect. And contiguous data is still data the CPU fetches with fewer cache misses, which usually matters more than the allocation time itself.

And since it isn’t a promise, there are ways for it not to happen. Three things get in the way in practice:

  • A mutable field has a 64-bit ceiling, because reads and writes need to be atomic. A LocalDateTime doesn’t fit and goes back to being a pointer.
  • Object kills flattening. Integer[] is flattenable, Object[] isn’t, and erased generics fall in the same bucket. It changes no semantics, only layout.
  • Old code needs recompiling, because the JVM relies on a new class file attribute to learn in time that a class is a value class.

What you lose is exactly what depended on identity

And there’s a logic to the price: the same design decision charging you on the way out what it charged on the way in. Everything that breaks is something that needed to distinguish instances. I ran each one to get the real message:

synchronized via Object : java.lang.IdentityException:
                          Cannot synchronize on an instance of value class java.time.LocalDate
d.notify()              : java.lang.IllegalMonitorStateException: java.time.LocalDate
new WeakReference<>(d)  : java.lang.IdentityException:
                          java.time.LocalDate is not an identity class
weakHashMap.put(d, "x") : java.lang.IdentityException:
                          java.time.LocalDate is not an identity class

The lock lives in the header, in that mark word from earlier, so synchronized stops working. All of Java Concurrency in Practice assumes any object can serve as a lock, and now it can’t. wait and notify fall with it, since they depend on that same lock, and so do WeakHashMap and all of java.lang.ref, because a weak reference needs to point at one specific instance.

Beyond that, some things keep working but not the way you expect.

== now compares internal fields, so it can diverge from your equals, which might look at something else. It also became an operation with a cost, because the comparison is recursive and a deep tree of value objects can hit StackOverflowError. And since it reads private fields, it became an inference channel. The JEP says it plainly: value objects weren’t designed to protect sensitive data.

Even == on identity objects got marginally more expensive, because the if_acmpeq bytecode now needs an extra test to detect value objects. The identity path became a fast path, but it exists, and it’s charged to code that uses none of this.

The missing piece: JEP 539

There was still a hole. A value object promises its value never changes, but in Java a field can be read before it’s initialized, holding 0 or null.

The JEP’s example is a circular dependency:

class App {
    public static final long appID = Log.currentPID();
    public static void main() {
        IO.println("App[" + appID + "] has started");
        Log.log("Completed 'main'");
    }
}

class Log {
    private static final String prefix = "App[" + App.appID + "]: ";
    public static void log(String msg) { IO.println(prefix + msg); }
    public static long currentPID() { return ProcessHandle.current().pid(); }
}

Running it prints:

App[8145] has started
App[0]: Completed 'main'

Two reads of the same final field, two different values. Log gets initialized in the middle of initializing App, reads appID holding the default 0, and bakes that zero into prefix. And here’s the nasty part: if Log were initialized first, the bug would disappear. It’s the kind of bug that vanishes when you go looking for it.

A final field that yields two different values destroys the entire premise of a value object. That’s why JEP 539 introduces the ACC_STRICT_INIT flag: a field marked with it has no default value and must be written before any read. javac marks every field of a value class with it, which is why both JEPs landed in the same commit.

If you go run that example, don’t expect it to fix itself: I turned on --enable-preview and App[0] is still there. Imposing strict initialization on existing code is a declared non-goal of JEP 539, so only value class fields get the flag.

Trying it today

There’s a logistics trap here, and I only found it because I went and ran things.

The obvious path is grabbing the JDK 28 early access from jdk.java.net/28. It doesn’t work yet. Build 9 shipped on July 31st, 2026, the same day as the integration, and it was cut before that landed: value record is a syntax error, Objects.hasIdentity doesn’t exist, and Integer 1996 == 1996 is still false.

What runs today is Valhalla’s own early access, at jdk.java.net/valhalla. Build 27-jep401ea3+1-1 implements JEP 401, and it’s where I ran everything in this post that produces output.

javac --release 27 --enable-preview Demo.java
java --enable-preview Demo

Preview has to be on at both ends, and you can’t pick the identity version of LocalDate in that mode: it’s all or nothing.

What didn’t change

Two things got left behind, and both are funny.

The Integer cache still exists. The doc/value-class-preview.md that shipped with the commit says it was kept on purpose, for performance, and that it now has no semantic impact whatsoever. The workaround that created the gotcha keeps running under the hood. You just can’t observe it anymore.

And String didn’t migrate. The class has identity dependencies in its API and its implementation, so Objects.hasIdentity("abcd") still returns true. Java’s most famous gotcha, == on String, is still standing.

Past that, the rest is foundation. JEP 402 will improve primitive boxing on top of this, and JEP 218 will let generics specialize layout when parameterized with a value class, which is List<int> without boxing.

But the big change already happened, and it’s conceptual before it’s technical.

== stopped asking “do you two live at the same address?” and started asking “can you two be told apart?”. For a car, the answer is still the address. For a twenty-dollar bill, it’s now the value.

That surprise you had at the beginning never had “use equals” as its answer, let alone “use a primitive”. For LocalDate there was never a primitive to use.

The answer was that the date never needed identity, and you had been paying for it all along.

Thanks for reading!


References