
Java 28 is still in development. According to the schedule, it is to be released on March 9, 2027. On December 3, 2026, the so-called “Rampdown Phase One” begins – from then on, no further JDK Enhancement Proposals (JEPs) will be added. Until then, more JEPs are usually added, and in rare cases, a JEP that has already been targeted is removed again. I will update this article as soon as anything changes. You can download the current early-access build of Java 28 here.
As of November 8, 2026, ten JEPs are targeted to Java 28. By far the biggest feature is Value Objects: the JDK team has been working on objects without identity in Project Valhalla since 2014 – and with Java 28, the first preview version arrives in the JDK. Because a value object is defined solely by its field values, these must never be visible in any state other than their final one – in the JVM, Strict Field Initialization guarantees that.
Two features become final after several preview rounds, without any further changes: Structured Concurrency and the PEM API. New as an incubator feature is a JSON API that lets you read and write JSON without an external library.
Several JEPs improve performance: compiled machine code is now also stored in the AOT cache; ZGC starts up and warms up faster and can adjust the heap size on its own; and Shenandoah runs in generational mode by default. In addition, the macOS/x64 port is deprecated.
Value Objects (Preview) – JEP 401
Every Java object has an identity: two objects with the same field values are still two different objects, and the == operator does not compare their content but their identity. For immutable data such as numbers, dates, or amounts of money, this identity is superfluous – it leads to surprises and costs performance.
A surprise that most Java beginners have probably run into at some point:
void main() {
Integer i = 1996, j = 1996;
IO.println(i == j);
LocalDate d1 = LocalDate.of(2027, 3, 9);
LocalDate d2 = d1.plusYears(1).minusYears(1);
IO.println(d1 == d2);
}
Both comparisons return false, even though the objects compared in each case represent the same value.
And performance: the JVM has to allocate every object on the heap and access it through a pointer. An array of LocalDate objects is therefore an array of pointers to individual objects that may be scattered far apart in memory. That costs memory, and when iterating over such an array, every pointer has to be followed to its object. That requires an additional memory access for each object. And if the objects are scattered across memory, the CPU has to load a new cache line from main memory for almost every one of them.
JDK Enhancement Proposal 401 therefore introduces value classes – classes whose instances, the value objects, have no identity. You declare a value class with the new value modifier. It is implicitly final, and all its fields are implicitly final as well. Records are particularly well suited:
value record Point(int x, int y) {}
The == operator compares value objects by their field values. In addition, with preview features enabled, 30 JDK classes become value classes – among them the wrapper classes such as Integer and Long, the Optional classes, and numerous classes from java.time such as LocalDate, Instant, and Duration. The new Objects.hasIdentity() method lets you check whether an object has an identity:
value record Point(int x, int y) {}
void main() {
Integer i = 1996, j = 1996;
IO.println(i == j);
LocalDate d1 = LocalDate.of(2027, 3, 9);
LocalDate d2 = d1.plusYears(1).minusYears(1);
IO.println(d1 == d2);
Point p = new Point(17, 3);
IO.println(p == new Point(17, 3));
IO.println(Objects.hasIdentity(p));
IO.println(Objects.hasIdentity("Java 28"));
}
If you start the program with java --enable-preview ValueObjects.java, it prints the following:
true
true
true
false
true
Now all three comparisons are true. By the way, String is not a value class – its API and implementation depend on object identity.
What no longer works with value objects is synchronization. The compiler already rejects a synchronized on a variable whose type is a value class. If, on the other hand, the static type is Object, for example, the JVM throws an IdentityException at runtime:
void main() {
Object o = LocalDate.of(2027, 3, 9);
synchronized (o) {
IO.println("locked");
}
}
The message is: Cannot synchronize on an instance of value class java.time.LocalDate.
Because value objects have no identity, the JVM is free to lay them out in memory as it sees fit. For the elements of a LocalDate array, for example, it can write year, month, and day together with a null flag directly into one 64-bit word each, instead of storing pointers to individual objects there (“flattening”) – the array then behaves much like an int array. And in parameters and local variables, it can keep the fields of a value object on the stack or in CPU registers instead of on the heap (“scalarization”). Which of these optimizations the JVM applies, however, is not specified by the JEP.
To ensure that a value object is never visible in a half-initialized state, the constructors of value classes follow stricter rules: all fields must be set before the superclass constructor is called. The compiler therefore places the implicit super() call at the end of the constructor, and before that, the constructor must not use this – for example, it must not call instance methods. This builds on the Flexible Constructor Bodies from Java 25.
With preview features enabled, these rules apply to all records, by the way – including those without value. This is an incompatible change: a record constructor that, for example, passes this to a method no longer compiles with --enable-preview. According to the JEP, this is rare in practice, and such an access is usually a bug anyway, because the record’s fields have not been set at that point.
Strict Field Initialization in the JVM (Preview) – JEP 539
In Java, fields that are not explicitly initialized have a default value: 0, false, or null. And even a final field can still show its default value while its class or object is being initialized. The following example from the JEP (slightly shortened) shows how this can happen with a circular dependency between two classes:
class App {
public static final long appID = Log.currentPID();
public static void main() {
IO.println("App[" + appID + "] has started");
Log.log("Completed 'main'");
}
}
class Log {
private static final String prefix = "App[" + App.appID + "]: ";
public static void log(String msg) {
IO.println(prefix + msg);
}
public static long currentPID() {
return ProcessHandle.current().pid();
}
}
The program prints the following (the process ID varies, of course):
App[66877] has started
App[0]: Completed 'main'
Calling Log.currentPID() while initializing App.appID triggers the initialization of the Log class. That class reads App.appID, which at this point still has its default value 0, and embeds it into prefix. Only afterwards does appID receive the actual process ID.
A second example is inheritance: if a superclass constructor calls a method that is overridden in the subclass, that method still sees a final field of the subclass in its uninitialized state null. I showed what this looks like in the Java 25 article under Fewer Surprises with Inheritance.
JDK Enhancement Proposal 539 introduces strictly-initialized fields in the JVM. Such a field has no default value: the JVM ensures that it is written before it can be read, and that a final field is not modified after it has been read for the first time. For static fields, the JVM throws an exception on a violation; for instance fields, bytecode verification already fails. Such fields are marked in the class file by the new ACC_STRICT_INIT flag.
Important: JEP 539 is a pure JVM feature. There is no new keyword, and both examples behave the same in Java 28. The compiler decides which fields are strictly initialized – javac does this for all fields of value classes and, with preview features enabled, also for the fields of all records. Value objects thus build on JEP 539: since a value object is defined solely by its field values, no one may ever see a field with any value other than its final one.
In addition, the JIT compiler can treat strictly-initialized final fields as unchangeable and reuse a value once it has been read.
Simple JSON API (Incubator) – JEP 540
Until now, if you wanted to read or write JSON in Java, you needed an external library such as Jackson or Gson. JDK Enhancement Proposal 540 brings its own small JSON API into the JDK, initially as the incubator module jdk.incubator.json.
The API is not meant to replace the established libraries: data binding (i.e., converting JSON into Java objects and back), streaming, and syntax extensions such as comments are explicitly not goals. The goal is to accomplish simple tasks – such as reading individual values from a REST response or a configuration file – with little code and without dependencies.
At its center is the sealed interface JsonValue with the six subtypes JsonString, JsonNumber, JsonBoolean, JsonNull, JsonObject, and JsonArray. Json.parse() converts a string into a tree of JsonValue objects and returns its root as a JsonValue. The methods get(String) (for objects) and get(int) (for arrays) are already declared in JsonValue. So you can navigate through objects and arrays directly, without having to cast a value to JsonObject or JsonArray first. Methods such as asString(), asInt(), or asList() convert the values into Java types. For optional fields, there is tryGet(), which returns an Optional:
import jdk.incubator.json.*;
void main() {
String doc = """
{
"version": 28,
"ga": "2027-03-09",
"jeps": [
{ "number": 401, "title": "Value Objects", "preview": true },
{ "number": 543, "title": "Structured Concurrency" }
]
}
""";
JsonValue root = Json.parse(doc);
IO.println("Java " + root.get("version").asInt()
+ ", GA: " + root.get("ga").asString());
for (JsonValue jep : root.get("jeps").asList()) {
boolean preview = jep.tryGet("preview").map(JsonValue::asBoolean).orElse(false);
IO.println("JEP " + jep.get("number").asInt() + ": " + jep.get("title").asString()
+ (preview ? " (Preview)" : ""));
}
IO.println(JsonObject.of(Map.of("version", JsonNumber.of(28))));
}
The last line shows the opposite direction: it creates a JSON object and prints it as a string. You have to wrap the values in JsonValue objects yourself, because JsonObject.of() expects a Map<String, ? extends JsonValue>. So a plain 28 instead of JsonNumber.of(28) would not compile – and neither would a string, a list, an array, or any other object. A list, for example, has to be wrapped with JsonArray.of() – and its elements individually as well.
Since this is an incubator module, you have to add it explicitly at startup: java --add-modules jdk.incubator.json JsonDemo.java. The output:
WARNING: Using incubator modules: jdk.incubator.json
Java 28, GA: 2027-03-09
JEP 401: Value Objects (Preview)
JEP 543: Structured Concurrency
{"version":28}
If a value is missing or has a different type than expected, the API throws a JsonValueException whose message contains the path to the affected location in the document. If you call root.get("jeps").get(1).get("preview"), for example, the message is JsonObject member "preview" does not exist, followed by the path {jeps[1 and the position in the document (line 5, position 4). The API writes the path in its own shorthand: { stands for an object, followed by the member name, [ for an array, followed by the index. The brackets are never closed – so {jeps[1 means: member jeps of the root object, element 1 within it.
Structured Concurrency – JEP 543
After two incubator rounds in Java 19 and Java 20 and seven preview rounds from Java 21 to Java 27, Structured Concurrency becomes final in Java 28 with JDK Enhancement Proposal 543 – without any further changes compared to Java 27.
With Structured Concurrency, you combine concurrent subtasks into a single unit: the subtasks are started in a StructuredTaskScope and come back together at one point in the code. If one of them fails, the others are cancelled, and join() throws an ExecutionException with the original exception as its cause:
ProductPage loadProductPage(long productId)
throws InterruptedException, ExecutionException {
try (var scope = StructuredTaskScope.open()) {
var detailsTask = scope.fork(() -> catalogService.getDetails(productId));
var priceTask = scope.fork(() -> pricingService.getPrice(productId));
var stockTask = scope.fork(() -> inventoryService.getStock(productId));
scope.join();
return ProductPage.assemble(
detailsTask.get(), priceTask.get(), stockTask.get());
}
}
From Java 28 on, you no longer need --enable-preview for this. How the API changed in the last preview rounds is described in the articles on Java 25, Java 26, and Java 27. For a detailed introduction with all joiners, timeouts, and custom policies, see the main article Structured Concurrency with StructuredTaskScope.
PEM Encodings of Cryptographic Objects – JEP 542
Cryptographic objects such as keys and certificates are often exchanged in the PEM text format (Privacy-Enhanced Mail). Java 25 introduced an API that greatly simplifies reading and writing such objects. After three preview rounds in Java 25, Java 26, and Java 27, it becomes final in Java 28 with JDK Enhancement Proposal 542 – without any further changes.
The following example creates a key pair, writes the private key in encrypted PEM format, and reads it back in:
import java.security.*;
void main() throws Exception {
KeyPair keyPair = KeyPairGenerator.getInstance("EC").generateKeyPair();
char[] passphrase = "secret".toCharArray();
String pem = PEMEncoder.of()
.withEncryption(passphrase)
.encodeToString(keyPair.getPrivate());
IO.print(pem);
PrivateKey privateKey = PEMDecoder.of()
.withDecryption(passphrase)
.decode(pem, PrivateKey.class);
IO.println(privateKey.equals(keyPair.getPrivate()));
}
The program prints the private key in PEM format and confirms that the key read back matches the original one (the key is, of course, different on every run):
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIHKMGYGCSqGSIb3DQEFDTBZMDgGCSqGSIb3DQEFDDArBBQWZWQexym0y0AhZ5x/
2dLqLIZqhgICEAACARAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEAQIEEFFSbxsw
2NfBZbCyywgGaP8EYBI91wkxuzTX+qeLVesmGT79us9NBRh5DTR9pf13+u6yVoM3
S96MrWi7ITJLmtda0i1AaHpQbuFnD8lxgEJiWOE0kSwToGurxLv0q6AcOjihOjED
2GvadzzbzGEIvH/e+w==
-----END ENCRYPTED PRIVATE KEY-----
true
How cumbersome this was without the new API is shown in the section of the Java 27 article.
Performance Improvements
Ahead-of-Time Code Compilation – JEP 544
Since Java 24, the JVM can create an AOT cache in a training run, from which it takes over classes in loaded and linked form at the next start. Java 25 added method profiles, and since Java 26, the cache works with any garbage collector.
In Java 28, JDK Enhancement Proposal 544 additionally stores fully compiled machine code of the frequently called methods in the AOT cache. You do not have to change anything for this – the same training run that has created the cache since Java 25 now also compiles the code:
java -XX:AOTCacheOutput=AotCompile.aot -cp AotCompile.jar AotCompile 20
In an example program that compiles 50 generated source files in memory with the javac compiler in each round, the precompiled code halves the execution time of the first round: 252 instead of 527 milliseconds. The JIT compiler keeps running alongside and gradually replaces the precompiled code with newly compiled code. How that works, how long the training run should be, and under which conditions the JVM does not load the machine code is explained in the article Ahead-of-Time Code Compilation.
Faster Startup and Warmup with ZGC – JEP 545
With the Z Garbage Collector (ZGC), the initial heap size (-Xms) has a strong effect on the startup and warmup of an application. Requesting physical memory from the operating system and preparing it for the heap is expensive: with a small initial heap, the JVM starts quickly but has to grow the heap piece by piece during warmup. With a large initial heap, it is the other way round. According to the JEP, the JVM starts in about a tenth of a second with ZGC and a 2 MB heap, but needs more than ten seconds with a 32 GB heap.
JDK Enhancement Proposal 545 resolves this trade-off:
- ZGC starts with a heap of just 2 MB.
- It grows the heap aggressively, in increasingly larger steps – and does so in its own worker threads instead of the application’s threads. For this, it uses at most 25% of the available CPU threads.
- It writes to the newly requested memory page by page as soon as it grows the heap (pre-touching). The operating system thus enters the pages into the page table right away, and the application can use the memory without delay from its very first access.
- On Linux 6.1 and later, it gradually converts small memory pages (4 KB) into large ones (2 MB) – without the kernel having to be reconfigured for this.
In the JEP’s example, the Spring PetClinic application on a machine with 32 GB of RAM, the startup time drops by 38% compared to a large heap pre-touched at startup, and the warmup phase shrinks from 80 to 10 seconds compared to a small heap.
These improvements take effect when, in addition to ZGC, you enable adaptive heap sizing, described in the next section:
java -XX:+UseZGC -XX:+ZAdaptiveHeapSizing ...
Adaptive Heap Sizing for ZGC – JEP 546
Choosing a good maximum heap size is difficult: by default, the JVM sets it to a quarter of the physical memory. If the heap is too small, the garbage collector has to collect more often and uses more CPU time. If it is too large, the operating system can run out of memory, so that it swaps memory pages out to disk or, in the worst case, kills processes.
JDK Enhancement Proposal 546 introduces an optional policy for ZGC that adjusts the heap size automatically. When it is enabled, the following applies:
- The heap starts at 2 MB.
- The maximum heap size defaults to 100% of physical memory – on my Mac with 64 GB of RAM,
-XX:+PrintFlagsFinalshows aMaxHeapSizeof 64 GB. The last point takes care that memory does not run short. - ZGC continuously adjusts the heap size so that memory usage and the garbage collector’s CPU usage are well balanced.
- It monitors how much memory and CPU time the JVM uses compared to the overall system and releases memory when other processes need it.
You enable adaptive heap sizing with -XX:+ZAdaptiveHeapSizing; it is disabled by default. With -XX:ZGCIntensity, you can fine-tune the balance between the garbage collector’s memory usage and its CPU usage: the default value is 5; lower values make ZGC collect less often – it then needs less CPU time but occupies more memory –, higher values do the opposite. If you set only -XX:ZGCIntensity, the JVM turns on adaptive heap sizing automatically.
With adaptive heap sizing, according to the JEP, you no longer need to set -Xmx and -XX:SoftMaxHeapSize – existing settings, however, continue to work.
Shenandoah GC: Generational Mode by Default – JEP 535
The generational mode of the Shenandoah garbage collector was introduced as an experimental feature in Java 24 and declared a production feature in Java 25.
In Java 28, JDK Enhancement Proposal 535 makes it the default mode: the default value of the ShenandoahGCMode option changes from satb to generational. You can still enable the non-generational mode with -XX:ShenandoahGCMode=satb; the JVM then prints a warning, however, that the non-generational mode is deprecated and is to be removed in a future release.
Other Changes in Java 28
Deprecate the macOS/x64 Port for Removal – JEP 541
Between 2020 and 2023, Apple moved its computers to the AArch64 architecture (“Apple silicon”), and it is gradually phasing out support for x64. Oracle has therefore stopped maintaining the macOS/x64 port as of JDK 27.
With JDK Enhancement Proposal 541, the port is deprecated in Java 28 and is to be removed in a future release. If you build the JDK for macOS/x64 yourself, you now have to specify the --enable-deprecated-ports option – and there is no longer any guarantee that the port still builds or works.
Conclusion
As of today, Java 28 brings the following new features:
- Value objects have no identity,
==compares their field values, and the JVM may lay them out compactly in memory. With preview features enabled,Integer,Optional, andLocalDate, among others, become value classes. - Strict Field Initialization ensures in the JVM that fields are written before they are read – a prerequisite for value objects.
- A new JSON API reads and writes JSON without an external library.
- Structured Concurrency and the PEM API are final.
- Compiled machine code is now also stored in the AOT cache.
- ZGC starts up and warms up faster and can adjust the heap size on its own.
- Shenandoah runs in generational mode by default.
- The macOS/x64 port is deprecated.
More JEPs may be added until Rampdown Phase One on December 3, 2026 – I will add them here as soon as they are targeted to Java 28.
What should I cover next? The best way for me to find out is through your feedback: a review on my ProvenExpert profile shows me which topics matter to you – and motivates me to write more articles.




