> ## Content Index
> Fetch the complete content index at: https://sudosecurity.org/llms.txt
> Use this file to discover other available public pages before exploring further.

# Twelve Years in the Making: JDK 28 Ships Value Classes Preview
- URL: https://sudosecurity.org/jdk-28-feature-preview/
- Published: 2026-08-08T01:25:02.000Z
- Updated: 2026-08-08T01:25:02.000Z
- Author: Traven
- Tags: Programming, Software Development, Programming Languages, Java, Garbage Collection, Project Valhalla, JVM

> *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](https://openjdk.org/projects/jdk/28/?ref=sudosecurity.org) 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](https://openjdk.org/jeps/401?ref=sudosecurity.org) | Value Classes and Objects                   | Targeted  | Preview language & VM feature |
| [539](https://openjdk.org/jeps/539?ref=sudosecurity.org) | Strict Field Initialization in the JVM      | Targeted  | Preview VM feature            |
| [535](https://openjdk.org/jeps/535?ref=sudosecurity.org) | Shenandoah GC: Generational Mode by Default | Targeted  | Default change / deprecation  |
| [542](https://openjdk.org/jeps/542?ref=sudosecurity.org) | 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:
1. **"Value types"** — the vague, earliest term.
2. **"Inline classes" (\~2019–2020)** — the identity/no-identity split crystallizes, along with the "codes like a class, works like an int" slogan.
3. **"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.val` or `Point!`) and a reference form (a nullable box — `Point.ref` or `Point?`). 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.
4. **"Value classes" and "value objects" (today's JEP 401)** — a single new concept: a `value` modifier on a class, whose instances lack identity but remain, importantly, a reference type that can still be `null`. 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`/`.ref` projections, 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](https://github.com/openjdk/jdk/pull/31120?ref=sudosecurity.org):

- **\~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](https://github.com/openjdk/jdk/pull/31121?ref=sudosecurity.org) (Java language implementation, issue JDK-8317277), [#31122](https://github.com/openjdk/jdk/pull/31122?ref=sudosecurity.org) (JVM implementation, JDK-8317278), and [#31123](https://github.com/openjdk/jdk/pull/31123?ref=sudosecurity.org) (standard library implementation, JDK-8317279)
- Development happened for years on a dedicated fork, [openjdk/valhalla](https://github.com/openjdk/valhalla?ref=sudosecurity.org), branch `lworld`, which continuously merged from `jdk/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 for `switch` both 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
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 `Integer`s with `==` and gotten burned outside the small-integer cache range:

```jshell
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 `byte`s 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

```java
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:

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

```

```jshell
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 classes `Number` and `Record`
- `java.util`: `Optional`, `OptionalInt`, `OptionalLong`, `OptionalDouble`
- `java.time`: `Duration`, `Instant`, `LocalTime`, `LocalDate`, `LocalDateTime`, `Year`, `YearMonth`, `MonthDay`, `Period`, `OffsetTime`, `OffsetDateTime`, `ZonedDateTime`
- `java.time.chrono`: `MinguoDate`, `HijrahDate`, `JapaneseDate`, `ThaiBuddhistDate`  
Notably, **`String` stays 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[]`:

```java
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
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
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`:

```java
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 `int`s 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:

1. **The JVM must statically know the concrete value class.** A `LocalDate`\-typed field or array flattens; an `Object`\-typed or unbounded-generic-typed (`T`, erased to `Object`) one generally can't, because it must remain able to hold arbitrary future references:

```java
   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

```

1. **The class file must declare the dependency.** A new `LoadableDescriptors` class-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 class `V` is migrated to become a value class, code compiled against the *old* (identity) version of `V` should be **recompiled** to benefit from flattening — otherwise the JVM may fall back to treating references to `V` as 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:

```java
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`:

```java
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:

```java
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 for `equals()` — the usual advice to compare via `equals()` 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 exclude `null` to 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 flat `ArrayList<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:

1. **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 type `T` defaults to `null` even when `T` turns out to be a non-nullable value type.
2. **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 of `Integer.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 inherited `Object` implementations now compare/hash by field values instead of identity. Good migration candidates override these *before* migrating.
- **Sensitive internal state** becomes indirectly observable through `==` or `System.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 `Serializable` must implement `writeReplace`/`readResolve` themselves, because deserialization can't safely satisfy strict field initialization (below) — without those methods, attempts to (de)serialize throw `InvalidClassException`.
- **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 a `Reference` (weak, soft, phantom) to a value object throws `IdentityException` at runtime; `javac` warns about it at compile time.
- `**AccessFlag**`: a new `ACC_IDENTITY` class-file flag marks identity classes; its *absence* marks value classes. This supersedes the old `ACC_SUPER` flag, though compilers keep setting `ACC_SUPER` too 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`/`getstatic` could execute before a corresponding `putfield`/`putstatic`. Default values of `0`/`null` are therefore provably unobservable.
- If the field is also `final`, **every read observes the same value**, permanently — no possibility of divergence, ever.  
The JDK's existing `ConstantValue` class-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 or `String` type. 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 `DEREncodable` interface to `BinaryEncodable`), 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`, and `passwordChars` are assumed already declared):

```java
// 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 `struct` value 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`/`.ref` two-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 with `value` on 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](https://openjdk.org/projects/jdk/28/?ref=sudosecurity.org), the early-access builds at [jdk.java.net/28](https://jdk.java.net/28/?ref=sudosecurity.org), 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 the [openjdk/valhalla](https://github.com/openjdk/valhalla?ref=sudosecurity.org) repository, where ongoing `lworld`\-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:

```bash
# 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.