Twelve Years in the Making: JDK 28 Ships Value Classes Preview
A technical deep dive into the features shaping the next Java feature release, due March 2027 — including the twelve-year backstory behind its headline feature.
JDK 28 is a non-LTS ("feature release") of the Java Platform, tracked as JSR 403 in the Java Community Process. It follows JDK 27 (due September 15, 2026) on Java's strict six-month release cadence, and — like every other non-LTS release since Java moved to time-boxed shipping — it will get only six months of Oracle support before end-of-life. The next Long-Term Support release isn't due until JDK 29 in September 2027.
The JDK 28 Expert Group was formally approved in early June 2026: Iris Clark (Oracle) as specification lead, alongside Simon Ritter (Azul Systems), Stephan Herrmann (Eclipse Foundation), and Christoph Langer (SAP SE). Early-access builds have been available since Build 0 in early June 2026, and the release schedule currently calls for a public review period running December 2026 through February 2027, ahead of general availability in March 2027.
As of early August 2026, three JEPs are formally Targeted to JDK 28, and a fourth sits at Candidate status awaiting a proposed-to-target vote:
| JEP | Title | Status | Type |
|---|---|---|---|
| 401 | Value Classes and Objects | Targeted | Preview language & VM feature |
| 539 | Strict Field Initialization in the JVM | Targeted | Preview VM feature |
| 535 | Shenandoah GC: Generational Mode by Default | Targeted | Default change / deprecation |
| 542 | PEM Encodings of Cryptographic Objects | Candidate | Finalization (from preview) |
Three or four targeted JEPs this early is typical for a non-LTS cycle — JDK 26 shipped ten JEPs total by the time it froze, JDK 27's set was leaner still — and the list above is a snapshot, not a final manifest; the feature set won't lock until Rampdown Phase One, expected ahead of the December 2026 public review.
By far the most consequential item, and the one this article spends the most time on, is the first preview of value objects — the flagship deliverable of Project Valhalla, arriving after twelve years of development.
JEP 401 — Value Classes and Objects (Preview)
Why this took twelve years
Officially, Project Valhalla began in 2014. James Gosling reportedly described the effort at the time as "six PhDs tied into a single knot" — not an exaggeration. The goal was to close a gap that had existed in Java since 1995: primitive types (int, long, double, boolean, and friends) are stored by value — fast, compact, no allocation — while everything else is a reference type, stored as a heap pointer carrying a unique identity. Java's own designers wanted value types in the original language but shelved the idea in 1995 because the problem was too hard to solve alongside everything else.
The team built five distinct prototypes over the following decade, each probing different corners of the design space:
- "Q World" (early prototypes) treated value types as a fundamentally separate kind of thing from objects — their own descriptors, their own bytecodes, their own type hierarchy root, mirroring how primitives already worked. It flooded the JVM's type system with duplicated machinery: nearly everything needed two variants.
- "L World" (~2019), the breakthrough, unified value types under the same "L carrier" the JVM already uses for object references. The team expected this unification to be too constraining and were surprised when it worked cleanly — while also revealing that the language model and the JVM model don't need to match one-to-one. The JVM's L World representation could simply be a compilation target, with the language layer free to offer programmers something more ergonomic on top. That separation of concerns shaped everything that followed.
The naming also went through several complete rewrites, each tracking a real change in the underlying model:
- "Value types" — the vague, earliest term.
- "Inline classes" (~2019–2020) — the identity/no-identity split crystallizes, along with the "codes like a class, works like an int" slogan.
- "Primitive classes" and dual projections (2021 State of Valhalla) — the most ambitious, and ultimately abandoned, design. Every type would have two projections: a value form (flat, never null, primitive-like — written
Point.valorPoint!) and a reference form (a nullable box —Point.reforPoint?). Powerful, but the team concluded it was too mentally heavy for day-to-day programming — juggling two forms of one type and reasoning about implicit conversions between them. In line with Valhalla's guiding principle — simplify the model for the human, even at some cost to the performance ceiling — the dual-projection design was scrapped. - "Value classes" and "value objects" (today's JEP 401) — a single new concept: a
valuemodifier on a class, whose instances lack identity but remain, importantly, a reference type that can still benull. Non-nullability became a separate, optional, later JEP instead of being baked into the core model.
If you find older material — including some still-popular blog posts — describing "primitive classes" with.val/.refprojections, that's describing a design OpenJDK explicitly walked away from. It doesn't exist in the shipped JEP.
On June 15, 2026, Oracle engineer Lois Foltan confirmed integration of JEP 401 into OpenJDK mainline, targeting JDK 28. The change was large enough that other committers were asked to hold off on unrelated large commits during the merge window. The numbers, from the actual GitHub pull request:
- ~197,000 lines changed across 1,816 files
- 2,682 commits merged as the "master" integration PR
- Split into three parallel sub-review PRs by layer: #31121 (Java language implementation, issue JDK-8317277), #31122 (JVM implementation, JDK-8317278), and #31123 (standard library implementation, JDK-8317279)
- Development happened for years on a dedicated fork,
openjdk/valhalla, branchlworld, which continuously merged fromjdk/master
Brian Goetz, Oracle's Java Language Architect and the person most associated with Valhalla, was careful to temper expectations the moment it landed: value objects are "just the first part of Valhalla." Removing identity is, in his words, the first barrier — and clears the way for further optimization, especially for smaller objects — but truly full value semantics requires giving up more: nullability, and what he called "atomicity-safety-under-race." He also set expectations on timing bluntly: asked whether JEP 401 would exit preview in time for JDK 29 (the next LTS, due September 2027), Goetz wrote that "hoping for it to exit preview for 29 seems... optimistic." Multi-release preview windows aren't unusual in Java — Structured Concurrency and Pattern Matching forswitchboth spent multiple releases in preview — but it does mean most enterprises running LTS releases won't see a stable version of this feature for a while yet.
The core problem, concretely
Consider LocalDate. Two separately constructed LocalDate objects representing 1996-01-23 are equals() but not ==, because each call to LocalDate.of(...) allocates a fresh object with a distinct identity:
jshell> LocalDate d1 = LocalDate.of(1996, 1, 23)
d1 ==> 1996-01-23
jshell> LocalDate d2 = d1.plusYears(30).minusYears(30)
d2 ==> 1996-01-23
jshell> d1.equals(d2)
$3 ==> true
jshell> d1 == d2
$4 ==> false
That surprise is familiar to every Java developer who's ever compared boxed Integers with == and gotten burned outside the small-integer cache range:
jshell> Integer i = 96, j = 96;
jshell> i == j
$3 ==> true // both fit in the cache
jshell> Integer x = 1996, y = 1996;
jshell> x == y
$6 ==> false // outside the cache — surprise!
Identity isn't just a semantic wrinkle, either — it's a real performance cost, and one that's grown more painful as hardware has evolved. In 1995, a memory access cost roughly the same as a CPU instruction; today's CPUs are roughly two orders of magnitude faster than main memory, and the whole gap is bridged by cache. Processors fetch memory in 64-byte cache lines: if data sits contiguously, one fetch pulls in many useful values at once; if you're chasing pointers scattered across the heap, nearly every access risks a cache miss — sometimes a hundred times slower than a hit.
An int[] is one contiguous block:
+----------+
| int[5] |
+----------+
| 1996 |
| 2006 |
| 1996 |
| 1 |
| 23 |
+----------+
A LocalDate[] of equivalent data is an array of pointers into scattered heap objects, each with its own object header:
+--------------+
| LocalDate[5] |
+--------------+
| 87fa1a09 -----------------------> +-----------+
| 87fa1a09 -----------------------> | LocalDate |
| 87fb4ad2 ------> +-----------+ +-----------+
| 00000000 | | LocalDate | | y=1996 |
| 87fb5366 --- +-----------+ | m=1 |
+--------------+ | | y=2026 | | d=23 |
v | m=1 | +-----------+
+-----------+ | d=23 |
| LocalDate | +-----------+
+-----------+
| y=1996 |
| m=1 |
| d=23 |
+-----------+
Even though the underlying data (a year, a month, a day — around 48 bits) is barely bigger than the int case, the memory footprint and cache behavior are dramatically worse. This is exactly the trade-off developers in performance-sensitive domains — game engines, graphics, image processing, databases, HPC — have historically dodged by giving up abstraction entirely: encoding a Color as three raw bytes instead of a class, or splitting a Point array into parallel int[] xs, ys. It works, but you lose names, validation, and methods, and open the door to exactly the kind of bug JEP 401's authors like to cite — misreading raw color bytes as BGR instead of RGB and silently corrupting an image, a mistake a real Color class would catch.
Declaring a value class
value class USDCurrency implements Comparable<USDCurrency> {
private int totalCents; // implicitly final
public USDCurrency(int dollars, int cents) {
this.totalCents = dollars * 100 + (dollars < 0 ? -cents : cents);
}
public USDCurrency plus(USDCurrency that) {
return new USDCurrency(0, this.totalCents + that.totalCents);
}
public int dollars() { return totalCents / 100; }
public int cents() { return Math.abs(totalCents % 100); }
@Override
public int compareTo(USDCurrency that) {
return Integer.compare(this.totalCents, that.totalCents);
}
}
Or, more idiomatically, as a value record — records are natural candidates since their fields are already implicitly final:
value record Point(int x, int y) {}
jshell> Point p1 = new Point(17, 3);
jshell> Point p2 = new Point(17, 3);
jshell> Objects.hasIdentity(p1)
$3 ==> false
jshell> p1 == p2
$4 ==> true
The rules: all instance fields of a value class are implicitly final; the class is implicitly final unless declared abstract (an abstract value class can be extended by other value classes, and, importantly, its subclasses may themselves be identity classes — abstract value class Number is a real example migrating in the JDK); methods can't be synchronized; a value class can't extend an identity class, though it can implement any number of interfaces. Beyond those constraints, it behaves like an ordinary class — constructors, private fields, validation logic, and regular methods all work as usual.
Thirty classes in the JDK itself become value classes under preview, among them:
java.lang:Integer,Long,Float,Double,Byte,Short,Character,Boolean, and the abstract classesNumberandRecordjava.util:Optional,OptionalInt,OptionalLong,OptionalDoublejava.time:Duration,Instant,LocalTime,LocalDate,LocalDateTime,Year,YearMonth,MonthDay,Period,OffsetTime,OffsetDateTime,ZonedDateTimejava.time.chrono:MinguoDate,HijrahDate,JapaneseDate,ThaiBuddhistDate
Notably,Stringstays an identity class — it isn't migrating.
== now means something different
This is the single biggest behavioral shift in the JEP. Traditionally, == compares object references — are these two variables pointing at the exact same memory location? For value objects, == instead performs a statewise (substitutability) comparison: two value objects are == if they're instances of the same value class and every field is recursively equal — primitive fields compared bit-for-bit, reference fields compared by (recursive) == themselves.
The JEP's own worked example is LazySubstring — a value class that lazily represents a substring by storing a source string and two offsets, rather than eagerly allocating a new char[]:
value class LazySubstring {
private String str;
private int start, end;
public LazySubstring(String s, int i, int j) {
str = s; start = i; end = j;
}
public String toString() {
return str.substring(start, end);
}
public boolean equals(Object o) {
return o instanceof LazySubstring &&
toString().equals(o.toString());
}
public int hashCode() {
return Objects.hash(LazySubstring.class, toString());
}
}
Two instances can represent the same character sequence (and thus be equals()) while holding different internal state (and thus not be ==) — which is exactly the case the field-by-field == semantics are meant to expose:
jshell> LazySubstring sub1 = new LazySubstring("ringing", 1, 4);
sub1 ==> ing
jshell> LazySubstring sub2 = new LazySubstring("ringing", 4, 7);
sub2 ==> ing
jshell> sub1.equals(sub2)
$3 ==> true
jshell> sub1 == sub2
$4 ==> false
sub1 and sub2 both stringify to "ing" (the substrings [1,4) and [4,7) of "ringing" are both "ing"), so equals() — which the class author deliberately defined around the represented value — returns true. But their internal start/end fields differ, so the JVM's field-by-field == correctly reports false. This is the nuance worth internalizing: == on value objects compares literal internal representation, which doesn't always match the developer's notion of "the same value" — that's still equals()'s job.
Practical consequence: == is safe again for boxed primitives and JDK value-based types like Integer and LocalDate, ending one of Java's oldest gotchas — but as LazySubstring shows, == is not a blanket replacement for equals(), and class authors should keep overriding both deliberately.
synchronized on a value object is simply illegal — there's no identity to lock on:
jshell> LocalDate d1 = LocalDate.of(1996, 1, 23)
jshell> synchronized (d1) { d1.notify(); }
| Error:
| unexpected type
| required: a type with identity
| found: java.time.LocalDate
Cast to Object and it becomes a runtime IdentityException instead of a compile error — the same underlying rule, enforced at whichever point the compiler can no longer prove it statically. Two new java.util.Objects methods let code check explicitly: Objects.hasIdentity(Object) and Objects.requireIdentity(Object).
Runtime payoff: scalarization and heap flattening
This is the actual point of the exercise. Because two equal value objects are provably indistinguishable, the JVM is free to represent them without ever allocating on the heap.
Scalarization is a JIT-compiler technique: a reference to a value object gets broken down into its constituent primitive fields wherever the JIT can prove the concrete type. Consider a LocalDate flowing through plusYears:
LocalDate d = dates[0];
dates[0] = d.plusYears(30);
public LocalDate plusYears(long yearsToAdd) {
int newYear = YEAR.checkValidIntValue(this.year + yearsToAdd);
return new LocalDate(newYear, this.month, this.day);
}
Conceptually — this is illustrative pseudocode, not real bytecode — the JIT can compile this so that no pointer to a heap LocalDate object is ever touched:
{ d_null, d_year, d_month, d_day } = $decode(dates[0]);
dates[0] = $encode($plusYears(d_null, d_year, d_month, d_day, 30));
static { boolean, int, byte, byte }
$plusYears(boolean this_null, int this_year, byte this_month, byte this_day, long yearsToAdd) {
if (this_null) throw new NullPointerException();
int newYear = YEAR.checkValidIntValue(this_year + yearsToAdd);
return { false, newYear, this_month, this_day };
}
Scalarization is more predictable and reaches further than the escape analysis JVMs already do for ordinary objects — critically, it can span method-call boundaries the JIT never even attempts to inline, whereas classic escape analysis breaks the moment an object crosses a boundary the JIT can't fully trace (gets stored in a field, put in an array, passed to un-inlined code). Escape analysis has always been "a nice bonus, not a foundation to build on," in the words of one detailed community write-up — and value objects turn that unpredictable optimization into something closer to a language-level guarantee.
Heap flattening applies the same idea to storage, not just computation. When a field or array slot would normally hold a pointer to a value object, the JVM can instead encode the object's field values directly, inline, prefixed with a null-indicator bit:
Integer[] ints = {1996, 2006, 1996, null, null};
+--------------+
| Integer[5] |
+--------------+
| 1 | 1996 |
| 1 | 2006 |
| 1 | 1996 |
| 0 | 0 |
| 0 | 0 |
+--------------+
A LocalDate (year, month, day, plus a null flag) fits comfortably in a single 64-bit word:
+--------------+
| LocalDate[5] |
+--------------+
| 1|1996|01|23 |
| 1|1996|01|23 |
| 1|2026|01|23 |
| 0|0000|00|00 |
| 1|1996|01|23 |
+--------------+
There's a hard physical constraint here worth internalizing: flattened data must be readable and writable atomically, or concurrent access risks "tearing" — reading a corrupted mix of old and new field bits. On common hardware today, that effectively caps flattening at 64 bits including the null flag. A LocalDateTime (a LocalDate plus a LocalTime, each already near the 64-bit limit on its own) is too large to flatten directly into a field — the JVM instead stores an ordinary pointer to a LocalDateTime object, whose own fields can each independently flatten:
+------------------------------------------+
| Event |
+------------------------------------------+
| timestamp = 87fa50a0 ---> +----------------------+
| ... | LocalDateTime |
+------------------------------------------+ | date = 1|2026|01|23 |
| time = 1|09|00|00|0000|
+----------------------+
This is also why a naive value record Point(int x, int y) {} — two full ints plus a null flag, 65 bits — can't flatten under today's atomicity constraint on most platforms, something the JEP's own authors have confirmed in community discussion. 128-bit atomic encodings are called out as future work for platforms that support them, and the Null-Restricted Value Class Types follow-on JEP will let developers exclude null entirely from a field's type, freeing up that headroom for larger flattened payloads.
Two more constraints govern whether flattening/scalarization actually kick in for a given piece of code:
- The JVM must statically know the concrete value class. A
LocalDate-typed field or array flattens; anObject-typed or unbounded-generic-typed (T, erased toObject) one generally can't, because it must remain able to hold arbitrary future references:
Integer[] ints = {1996, 2006, 1996, null, null}; // flattenable
Object[] objs = {1996, 2006, 1996, null, null}; // NOT flattenable
record Box<T>(T field) {} // field is erased to Object — not flattenable
var b = new Box<Integer>(1996); // field stores a heap pointer, not a flattened value
- The class file must declare the dependency. A new
LoadableDescriptorsclass-file attribute records which value classes a compiled class's field and method signatures reference, authorizing the JVM to load those classes early enough to lay out flattened fields and scalarized parameters. Practical implication: if a classVis migrated to become a value class, code compiled against the old (identity) version ofVshould be recompiled to benefit from flattening — otherwise the JVM may fall back to treating references toVas ordinary pointers.
Safe construction: the "larval object" problem
Because a value object can be silently duplicated, elided, or represented without ever touching the heap, its fields must be fully and safely set before any code outside the constructor can observe them. An object mid-construction is termed "larval" — created but not yet fully formed — and if a larval object leaks (say, via an overridable method invoked from a superclass constructor), calling code could observe an inconsistent, half-initialized value, or even watch a supposedly-final field appear to mutate.
JEP 401 builds directly on Java 25's Flexible Constructor Bodies, which introduced a two-phase construction model: an early construction phase (before the super(...)/this(...) call, where fields can be set but this can't be used) and a late construction phase (after that call, where instance methods and this become available). In a value class, by default, all constructor code runs in the early phase — the compiler inserts the super() call at the end of the constructor, not the beginning:
value class Name {
String name;
int length;
Name(String n) {
name = n;
length = strLength(); // ERROR — implicitly invokes this.strLength()
}
private int strLength() { return name.length(); }
}
To use this deliberately, an explicit super()/this() call marks the transition, and every field must already be set beforehand without touching this:
value class Name {
String name;
int length;
Name(String n) {
name = n;
length = strLength(name); // OK — static helper, no `this`
super(); // explicit transition to late-construction phase
System.out.println("Name: " + this); // now legal
}
private static int strLength(String n) { return n.length(); }
}
This same tightening now applies to identity record classes too — in JDK 28, record canonical constructors always run in early-construction phase, which is a (minor, expected-to-be-low-impact) source-incompatible change from Java 25's behavior:
record Node(String label, List<Node> edges) {
public Node {
nullCheck(label, this); // OK in Java 25, error in JDK 28
nullCheck(edges, this); // OK in Java 25, error in JDK 28
}
static void nullCheck(Object arg, Object owner) {
if (arg == null) {
String msg = "null arg for " + owner.toString();
throw new IllegalArgumentException(msg);
}
}
}
What's explicitly out of scope
The JEP is careful about its boundaries. It does not:
- Redefine
==to be a universal replacement forequals()— the usual advice to compare viaequals()in most contexts still stands. - Introduce a C-style
struct— Java still has exactly two kinds of data at the language level: primitives and object references. - Guarantee any specific memory layout or optimization — flattening and scalarization remain JVM discretion, not a language contract.
- Change how primitive types themselves behave (a separate, related effort — Enhanced Primitive Boxing — covers making boxing conversions cheaper and more ergonomic; it isn't fully baked and isn't guaranteed to land alongside JEP 401).
Two follow-on JEPs are explicitly flagged as future work this JEP depends on or sets up for: Null-Restricted Value Class Types (Preview), letting developers excludenullto enable denser flattening, and JVM class and method specialization (revisiting the long-dormant JEP 218), which would let generic classes and methods specialize their internal layout by type argument — the prerequisite for a genuinely flatArrayList<Point>.
The unfinished half: generics and type erasure
This is worth dwelling on, because it's the most common point of confusion (and the most common disappointment) once people start experimenting. Java implements generics via type erasure: List<String> and List<Integer> are, at the bytecode level, the same List, with the type parameter erased to Object. That was a deliberate, defensible 2004-era decision — it let existing non-generic classes become generic without breaking a single already-compiled client, at a moment when Java already had an enormous installed codebase. It would be an even harder sell to break compatibility today.
The trouble is that erasure directly undermines Valhalla's central promise. Since a generic T erases to Object, a value object placed into List<Point> still has to be materialized as an ordinary heap object — the collection holds references, not flattened data. All the flattening density gained in Point[] evaporates the moment those points go into an ArrayList<Point>.
The fix is planned in two further phases, neither of which is in JDK 28:
- Universal Generics — a language-level change letting type variables also range over value types (so you can even write
List<int>or generic code that's "specialization-ready"), still using erasure under the hood. The visible cost to developers: new compiler warnings about "null pollution," since a field of typeTdefaults tonulleven whenTturns out to be a non-nullable value type. - Specialized Generics — the deeper JVM-level follow-up that would actually generate distinct, specialized class layouts per concrete type argument (internally termed "species"), so
ArrayList<Point>could genuinely be backed by flat memory. This remains largely research work.
Until both land,Point[]flattens;ArrayList<Point>does not.
Migration guidance, from the JEP itself
For final/abstract classes whose fields are already all final, adding or removing the value modifier is a binary-compatible change. But migrating an existing identity class to a value class does carry real behavioral risk, which the JEP calls out explicitly:
- Public constructors that callers relied on to produce distinguishable-by-
==objects will silently stop doing that. The JDK's own migration path for this is to deprecate public constructors in favor of factory methods —Integer,Float, etc. already deprecate their constructors in favor ofInteger.valueOf()and friends for exactly this reason. - Code that synchronizes on instances of the migrated class breaks outright, either at compile time (if the static type is known) or at runtime via
IdentityException. equals()/hashCode()that haven't already been overridden will change behavior post-migration, since the inheritedObjectimplementations now compare/hash by field values instead of identity. Good migration candidates override these before migrating.- Sensitive internal state becomes indirectly observable through
==orSystem.identityHashCode()once a class is value-based — value classes offer no protection against that kind of inference attack, so classes encapsulating secrets are poor migration candidates.
Ecosystem and platform interactions worth knowing
- Serialization: works automatically for value records. Non-record value classes implementing
Serializablemust implementwriteReplace/readResolvethemselves, because deserialization can't safely satisfy strict field initialization (below) — without those methods, attempts to (de)serialize throwInvalidClassException. - Deep reflection:
Field.setAccessible-based mutation of value-class fields is blocked outright, even with--enable-final-field-mutation— reflective libraries have to go through constructors. java.lang.ref/WeakHashMap: creating aReference(weak, soft, phantom) to a value object throwsIdentityExceptionat runtime;javacwarns about it at compile time.AccessFlag: a newACC_IDENTITYclass-file flag marks identity classes; its absence marks value classes. This supersedes the oldACC_SUPERflag, though compilers keep settingACC_SUPERtoo for backward tooling compatibility.
JEP 539 — Strict Field Initialization in the JVM (Preview)
JEP 401 depends on this JEP directly — it's the lower-level bytecode-verification mechanism that makes safe value-object construction actually enforceable, and per the OpenJDK pull request, its implementation shipped in the same codebase as JEP 401 for exactly that reason. It's aimed less at everyday application developers than at compiler writers and designers of JVM-hosted languages, but it stands on its own as a JEP.
The problem
Java's memory model already guarantees a program can never read truly uninitialized memory: any field not explicitly set is implicitly initialized to a default — 0, false, or null. But that default is inherently ambiguous. Code reading a field can't distinguish "this was deliberately set to zero" from "nothing has written here yet." For final fields specifically, there's a related integrity gap in the current model: a larval (partially-constructed) object can leak through an overridable method called from a superclass constructor, and code that captures a reference to it can observe a final field's default value — or even watch that field appear to "mutate" once construction finishes, which is supposed to be impossible for final.
What it introduces
A new strictly-initialized field, opted into via a new ACC_STRICT_INIT class-file flag — recognized only under preview class-file versions with --enable-preview set at both compile and run time. The bytecode verifier tracks, per instance, whether each strict field has been set (putfield/putstatic) and read (getfield/getstatic) during the larval window — before the constructor's super()/this() call for instance fields, or before class initialization completes for static fields. This tracking happens even when the field is touched from another method or accessed through a subclass reference. The resulting guarantees:
- A strictly-initialized field can never be observed before it's written — the verifier rejects any code path where a
getfield/getstaticcould execute before a correspondingputfield/putstatic. Default values of0/nullare therefore provably unobservable. - If the field is also
final, every read observes the same value, permanently — no possibility of divergence, ever.
The JDK's existingConstantValueclass-file attribute (used for literal-constant static fields) can be thought of as already satisfying a narrow case of this guarantee — but it only covers compile-time literal constants of primitive orStringtype. JEP 539 generalizes the guarantee to values computed at runtime, from constructor parameters or arbitrary bytecode logic — which covers the vast majority of real-world field initialization, including everything value classes need.
Deep reflection is explicitly walled off: Field.setAccessible-based mutation now categorizes strictly-initialized final fields as non-modifiable — the same bucket static-final fields (and, since JDK 26's JEP 500, final fields generally) already fall into. JNI code is not constrained by any of this — native code retains its existing, unrestricted ability to read and write raw memory locations, strict-field guarantees notwithstanding; the JEP points to JEP 472 (Prepare to Restrict the Use of JNI) as the complementary effort addressing integrity-by-default at the native boundary.
The JEP is explicit about two non-goals, worth repeating because they head off a common misreading: it does not add a Java-language strict field modifier, and it does not change how javac compiles ordinary Java source by default. This is infrastructure — a stronger guarantee that language and VM features (chiefly value objects) and third-party JVM-language compilers can opt into, not a behavior change for everyday javac users.
JEP 535 — Shenandoah GC: Generational Mode by Default
Shenandoah — OpenJDK's ultra-low-pause-time collector, distinguished by performing compaction concurrently with the running application rather than stopping the world for it — has had an optional generational mode since it was delivered experimentally as JEP 404 back in JDK 21, later graduating to production quality (JEP 521, delivered in JDK 25). JDK 28 proposes flipping the default.
The mechanics
The ShenandoahGCMode flag's default value moves from satb (the original, non-generational, snapshot-at-the-beginning algorithm) to generational. The satb value is simultaneously deprecated, with intent — not yet a firm commitment — to remove non-generational Shenandoah in a future release.
| Flags supplied | Result |
|---|---|
-XX:+UseShenandoahGC (mode unspecified) |
Generational Shenandoah (new default) |
-XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational |
Generational Shenandoah, no warning |
-XX:+UseShenandoahGC -XX:ShenandoahGCMode=satb |
Non-generational Shenandoah, deprecation warning emitted |
Under generational mode, tuning flags behave somewhat differently than under satb — operators using heuristics-tuning flags like -XX:ShenandoahInitFreeThreshold, -XX:ShenandoahMinFreeThreshold, -XX:ShenandoahAllocSpikeFactor, and -XX:ShenandoahGarbageThreshold (which govern when the adaptive heuristic triggers a collection cycle, how much heap headroom is reserved for allocation spikes, and what fraction of a region must be garbage before it's eligible for reclamation) should expect to revisit that tuning, since the generational collector uses dedicated heuristics for starting young- and old-generation cycles separately.
Why the change
Generational collection separates young, short-lived objects — collected cheaply and often — from old, long-lived ones — collected rarely. Because most objects in typical Java workloads die young (the "generational hypothesis," which holds broadly across most real-world OOP workloads), this dramatically shrinks how much of the heap needs tracing on any given cycle. For Shenandoah specifically, the JEP cites lower concurrent CPU overhead, better allocation throughput, and reduced sustained memory footprint for most workloads compared to the single-generation design.
The stated non-goals matter here too: this is explicitly not a goal to remove non-generational mode immediately, and the JEP is candid that generational Shenandoah won't win everywhere — some workloads genuinely are non-generational in character (objects that survive roughly equally long regardless of age, defeating the generational hypothesis outright) and could see a mild regression from the switch. OpenJDK's stated judgment is that this is a sufficiently small slice of real-world workloads to accept, in exchange for no longer maintaining and validating two structurally distinct collector implementations side-by-side indefinitely — a genuine, ongoing engineering cost that (per the JEP) has been slowing other Shenandoah improvements.
This isn't a novel move for OpenJDK — it's the second time this exact playbook has run. ZGC made the identical switch earlier via JEP 474 (Generational ZGC by Default), which itself built on JEP 439's earlier delivery of generational ZGC as an option. JDK 28 essentially brings Shenandoah's defaults into alignment with where ZGC's have already landed, for the same underlying reasons — and the risk/assumption language in JEP 535 explicitly mirrors JEP 474's.
Log output and the data surfaced via serviceability and management APIs will differ once generational mode is the default, so tooling or scripts that parse GC logs or scrape those APIs are worth re-checking against generational output before this ships; the JEP commits to keeping existing tests passing under the same configurations, but doesn't extend that promise to arbitrary third-party log-parsing.
Part 4: JEP 542 — PEM Encodings of Cryptographic Objects (Candidate, not yet targeted)
Not yet formally targeted as of early August 2026, but likely close: the finalization of the PEM API, a java.security-adjacent API for converting between the widely used Privacy-Enhanced Mail text format and Java objects representing cryptographic keys, certificates, and certificate revocation lists — supporting PKCS#8 (private keys), X.509 (public keys, certificates, CRLs), and PKCS#8 v2.0 (encrypted private/asymmetric keys).
This API has had an unusually long preview runway — three full rounds:
- JEP 470 — first preview, JDK 25
- JEP 524 — second preview (minor changes), JDK 26
- JEP 538 — third preview (minor changes, including renaming the core
DEREncodableinterface toBinaryEncodable), JDK 27 - JEP 542 — proposed finalization, without further changes, JDK 28 (Candidate)
The settled API surface (illustrative usage — imports and the surrounding method/class are omitted for brevity;myPrivateKey,myCertificate,pemText, andpasswordCharsare assumed already declared):
// Encoding
PEMEncoder encoder = PEMEncoder.of();
String pem = encoder.encodeToString(myPrivateKey);
byte[] pemBytes = encoder.encode(myCertificate);
PEMEncoder encryptedEncoder = encoder.withEncryption(passwordChars);
String encryptedPem = encryptedEncoder.encodeToString(myPrivateKey);
// Decoding
PEMDecoder decoder = PEMDecoder.of();
BinaryEncodable obj = decoder.decode(pemText);
// Type-directed decoding with pattern matching
switch (obj) {
case PrivateKey pk -> handlePrivateKey(pk);
case X509Certificate c -> handleCertificate(c);
case CRL crl -> handleCrl(crl);
default -> throw new IllegalStateException();
}
// Or decode directly to a known type
PrivateKey key = decoder.decode(pemText, PrivateKey.class);
Both PEMEncoder and PEMDecoder instances are thread-safe and reusable — obtain one via the static of() factory and reuse it across calls rather than constructing per-use. Given three consecutive preview cycles with no substantive design churn in the last two, this is a strong candidate to be confirmed for JDK 28 in the near term — but per OpenJDK's process, nothing is official until an Expert Group member formally proposes and confirms the target, which for JEP 535 (a close parallel) happened only weeks before its review concluded.
What developers and organizations are actually saying
Reaction to JEP 401's integration has been the most substantial community conversation around any single JDK 28 item so far — and it's been mixed. Points raised repeatedly across blogs, DEV Community posts, and comment threads:
- The C#/Kotlin comparison. Commenters have repeatedly noted that C# has had
structvalue types since its very first release, and asked whether Java simply took twelve years to arrive somewhere .NET started. The Java team's counter-position, articulated by Goetz: maintaining full backward compatibility with three decades of existing bytecode and source while retrofitting this distinction is the genuinely hard part — not implementing value semantics in a vacuum. Unlike C# structs, Java value objects have no mutation and no identity at all (a C# struct still has identity and can be mutated in place), a simpler model for the programmer that the JVM implementation is free to represent however it likes at runtime. - Skepticism about the abandoned dual-projection design. Some engineers, including ones with Kotlin and TypeScript backgrounds, pushed back on the stated rationale for scrapping the
.val/.reftwo-projection model, pointing out that nullable-vs-non-nullable distinctions haven't proven especially confusing to users of other languages with that exact split. - The generics gap as the biggest practical letdown. By far the most common critique: without specialized generics,
List<Point>still boxes every element onto the heap, which undercuts the headline pitch for a large fraction of real code that stores value objects in collections rather than arrays. This is explicitly acknowledged by the JEP itself as Phase 2 work, not something JDK 28 delivers. - Timing against Oracle's broader restructuring. JDK 28's arrival coincides with a period of significant cost-cutting at Oracle tied to its AI-infrastructure investments; multiple observers have noted the Java platform team appears comparatively insulated, and that deep language-design work like Valhalla can't meaningfully be accelerated by adding headcount regardless.
The practical guidance converging across these sources: this is not an urgent adopt-today feature for typical business-logic-heavy applications with a modest number of live objects. The benefit concentrates specifically in code that creates large volumes of small immutable domain values — payment processing, event streaming, real-time analytics, coordinate/geometry math, timestamps — exactly the domains that have historically resorted to hand-rolled primitive encodings to get acceptable performance. For those workloads, downloading an early-access build and experimenting withvalueon a few candidate types now, while the design is still actively soliciting feedback, is a reasonable way to get ahead of it — with the expectation that the feature, being preview, may still change shape release to release.
How the pieces connect
- Years of groundwork paid off at once. JDK 25's Flexible Constructor Bodies existed specifically to prepare safer object construction for what became JEP 401; JEP 539 is the bytecode-verification layer JEP 401 needed to make that construction model actually enforceable rather than just advisory. Both integrated together, in the same codebase, for exactly that reason.
- "Generational by default" is now an established playbook, not a one-off. ZGC went first (JEP 474); Shenandoah follows an almost identical path and rationale in JDK 28. Expect the non-generational modes of both collectors to eventually be proposed for removal outright, though neither JEP commits to a release.
- JDK 28 is a comparatively light release by JEP count, in keeping with the last several non-LTS cycles — but "light" undersells JEP 401 specifically, whose ~197,000-line implementation is almost certainly the single largest code change to land in a Java feature release in years, regardless of how many JEPs it's bundled under.
As with any release still mid-cycle, this feature list can still change before Rampdown Phase One. Developers wanting to track it directly can follow the JDK 28 project page, the early-access builds at jdk.java.net/28, and — for anyone specifically interested in trying value objects before JDK 28 ships — the dedicated Valhalla early-access builds at jdk.java.net/valhalla, plus theopenjdk/valhallarepository, where ongoinglworld-branch development continues ahead of each weekly JDK mainline sync.
Quick-reference: enabling preview features
Both JEP 401 and JEP 539 are preview features, disabled by default and requiring explicit opt-in at both compile time and run time:
# Compiling
javac --release 28 --enable-preview Main.java
# Running a compiled class
java --enable-preview Main
# Running via the source-code launcher (single file)
java --enable-preview Main.java
# jshell
jshell --enable-preview
Omitting --enable-preview at either stage means value is treated as an ordinary (contextual) identifier rather than a modifier, and JDK classes like Integer and LocalDate continue behaving exactly as they do in JDK 27 and earlier — a deliberate compatibility choice so that enabling the JDK 28 upgrade alone doesn't silently change == semantics anywhere in an existing codebase.