The JEP 401 change javac won't warn you about
Project Valhalla seems to be on the verge of being incorporated into the JVM… as a preview feature in JDK 28 that is.
This blog might be a bit premature, since it isn’t likely value types will be final in JDK 29.
Premature, yes, but the JEP is already worth a careful read.
I stumbled upon some interesting behavioral changes regarding equality.
Traditionally, the purpose of the == operator was to test whether two referenced objects have the same identity.
…
After the introduction of value objects, the purpose of the == operator is to test whether two referenced objects are indistinguishable.
Quick recap
Identity in Java is a very important concept in relation to mutability.
In short, it means the JVM creates an object every time you call new, and, importantly, assigns it an identity.
If we ignore the complex things the garbage collector can do, we can think of the identity as the reference to the object.
When we read or change the object, the reference leads us to the same place on the heap.
When we want to know whether two variables refer to the same object, we compare their references, because identical references mean the same object.
The equals method, on the other hand, delegates equality to the object itself: a class can override it.
By default Object.equals compares the references, just like ==, but an overridden method can compare fields.
A record automatically compares its fields using their equals methods.
This leads to the following summary:
var a = new Vec2(1,2);
var b = new Vec2(1,2);
println(a == b); // false, a and b were both created with new, hence their references are different
println(a.equals(b)); // true, the equals method compares equality of all record fields
private record Vec2(int a, int b) {}
The implications of changing equality
When I read how the JEP proposes to implement this change in equality, I immediately felt a little itch in the back of my mind. This blog will try to show there are subtle issues that might arise when the definition of equality is changed without your library knowing about it. Although the JEP does suggest ways to prevent these errors, I think it’s optimistic to assume every library will upgrade based on the advice in the JEP given the vast landscape of libraries in our ecosystem.
In a nutshell
JEP 401, part of Project Valhalla, aims to add the possibility to remove "identity" from classes, including records.
Traditionally, using the "==" operator would compare "referential equality".
It answers the question: Is this thing I have here the same as that thing over there?
Think of it as the two chairs next to your coffee table.
They might look exactly the same, but they are in fact different chairs.
In software and mathematics this distinction is less obvious. Java traditionally offered .equals() to solve this.
Statements like "4 == 4", "[1,3] == [1,3]", "1 == 3 - 2" hold true and are completely oblivious to any identity the operands might have.
JEP 401 introduces value types.
These types allow developers to opt out of identity, so Java referential equality expressions will work more like their mathematical counterparts.
Next to the conceptual change, this also has great potential for optimization in the JVM.
Some disclaimers
I’m not trying to attack JEP 401. I think the ideas it presents are interesting and I’m excited for these new concepts to land in the JVM. I’m not predicting a wave of bugs, but I wouldn’t be surprised if some of us encounter unexpected behavior caused by the situations I’ll describe. The code examples in this blog are simplified versions of obvious bad practices. The JEP acknowledges the causes for the situations described in this blog, and does offer advice. As far as I know it’s not solved in the implementation. Read this blog as my playground, experimenting with the implications of statements made in the JEP.
Equality behavior at runtime
As mentioned at the top of this blog, JEP 401 proposes to conceptually change equality, from having the same identity, to being indistinguishable.
In practice this means that two normal objects are indistinguishable when they have the same identity, and two value objects are indistinguishable when their fields are the same.
There are two things influencing how a JVM compares objects:
-
When compiling a class declared with
value, the compiler marks the class file as a value class. An identity class has theACC_IDENTITYaccess flag set, whereas a value class does not. -
When running on a compatible JVM, in these examples JDK 28 with preview features enabled, the JVM will compare value objects as indistinguishable based on their fields.
The thing that immediately crossed my mind was the huge ecosystem of libraries surrounding the JVM. All these libraries have different lifecycles, are maintained by professional teams and hobbyists alike, and form the community that makes Java and the JVM the welcoming platform it is.
But what happens when a new JVM feature forces updates because it conceptually changes the runtime?
Some hypothetical scenarios
Company X-Games has been creating games based on Java for years now. They are happily using the libraries YMath for their basic math types and ZMathGame, which adds utility for their games.
YMath has just announced their new version, compiled with value classes. X-Games is eager to adopt it, since YMath promises it will lower the memory footprint significantly.
Unfortunately, bug reports start flooding the company’s mailbox. Let’s analyze what happened. For these scenarios the game depends on the latest YMath (2.0.0), and the latest ZMathGame (1.0.0), which depends on YMath (1.0.0).
"My black labels turn white in dark mode!?"
X-Games lets players pick a label color. If they pick nothing, the label follows the theme. If they pick a color, that color wins.
package GameX;
Label label = new Label();
label.setColor(new Color(0, 0, 0)); // the player explicitly picks black
label.effectiveColor(darkTheme); // black in YMath 1.0.0, white in YMath 2.0.0
package YMath;
public final class Color {
public static final Color DEFAULT = new Color(0, 0, 0);
...
}
package ZMathGame;
public final class Label {
private Color color = Color.DEFAULT;
public void setColor(Color c) { color = c; }
public Color effectiveColor(Theme theme) {
return color == Color.DEFAULT ? theme.textColor() : color;
}
}
The effectiveColor method uses a constant and a comparison by identity to check whether the player chose a color.
DEFAULT is black, but a player who explicitly picks black gets new Color(0, 0, 0).
Based on the old equality definition, that is a different instance than DEFAULT, so the label stays black.
Then YMath 2.0.0 made Color a value class.
The new equality definition defines both Color instances as indistinguishable.
The label now follows the theme, which is white in the dark theme, so players who deliberately chose black get white labels.
"My guard never returns home!?"
X-Games has guards that patrol a route through the level, supported by ZMathGame.
A route is a list of waypoints, and a guard walks it for one lap, ending where it started.
Every waypoint is a YMath.NodeGraph.Node, which the game asks the level’s NodeGraph for by grid position.
YMath.NodeGraph.Node is a very basic Java class without equals() and hashCode(), so in YMath 1.0.0 it is compared by identity.
Nobody ever needed more, since nodeAt(..) hands out a fresh node every time it is called.
package GameX;
Route route = new Route(levelGraph);
route.addWaypoint(1, 2); // home
route.addWaypoint(1, 3);
route.addWaypoint(2, 3);
route.addWaypoint(1, 2); // back home
route.walkOnce(guard);
package YMath;
public class NodeGraph {
public Node nodeAt(int x, int y) {
return new Node(x, y);
}
public static class Node {
final int x, y;
Node(int x, int y) { this.x = x; this.y = y; }
}
}
package ZMathGame;
public final class Route {
private final NodeGraph levelGraph;
private final List<Node> waypoints = new ArrayList<>();
public void addWaypoint(int x, int y) {
waypoints.add(levelGraph.nodeAt(x, y));
}
public void walkOnce(GameObject g) {
Set<Node> reached = new HashSet<>();
int step = 0;
while (!reached.containsAll(waypoints)) { // one lap: every waypoint reached
Node next = waypoints.get(step++);
g.walkTo(next);
reached.add(next);
}
}
}
The Route class creates a new Node for every waypoint, including the last one that brings the guard back home.
When route.walkOnce(guard) runs, ZMathGame keeps the waypoints the guard has reached in a HashSet.
The lap is done once that set contains every waypoint of the route.
Because Node does not override equals() and hashCode(), it inherits the ones from Object.
In the old situation the first and the last waypoint were different nodes, even though they had the same x and y.
The set only contained every waypoint after the guard had walked all four of them, so the guard ended up at home.
Then YMath 2.0.0 made Node a value class.
Since value objects are compared by their fields, it doesn’t need an equals(): the JVM provides one.
That makes the upgrade look completely harmless, but the first and the last waypoint are now equal.
After the third waypoint the HashSet already contains every node in the route, so ZMathGame considers the lap done.
The guard stops at (2,3) and never walks the last leg home.
Conclusion
What to take from this blog?
Let’s first draw the conclusion.
When value objects will eventually hit the LTS version of Java, the behavior of your libraries, and with them your application, can change without warnings or errors.
ZMathGame was compiled against the old YMath, and swapping in YMath 2.0.0 in the game doesn’t recompile ZMathGame, so javac never gets a chance to warn. Even if ZMathGame were recompiled, the JEP lists no javac warning for "==" on value classes. The behavior of "==" changes at run time, and nothing in the process warns about it.
As mentioned, the JEP addresses these risks. Comparing based on object identity has been a bad practice for a long time
and the JEP advises libraries to override equals() and hashCode() before turning an identity class into a value class.
This blog has mainly been my experiment to understand the implications of such a fundamental change to Java.
To read the full JEP, see JEP 401.