Skip to content

Java 26 Features (with Examples)

A 26 laid out of roasted coffee beans on a light oak board leaning against a pink wall, in front of it a row of five white espresso cups of which only the second is filled with espresso, eucalyptus sprigs in a white vase on the right

Java 26 was released on March 17, 2026, and is available for download from the OpenJDK archive.

With 10 delivered JDK Enhancement Proposals (JEPs), Java 26 is one of the more manageable releases of recent years. Half of the JEPs are minor to medium-sized changes – such as warnings for mutating final fields and HTTP/3 support.

The other half are re-submitted preview and incubator features – i.e., updates to features that have already been introduced but not yet finalized. Except for Lazy Constants (previously known as Stable Values), there were only minimal changes here.

For all JEPs and changes from the release notes, I use the original English terms as always.

Prepare to Make Final Mean Final – JEP 500

Most developers assume that a field we mark as final is immutable. In fact, we cannot simply assign a new value to a final field using an equals sign. However, so-called Deep Reflection allows us to bypass this restriction.

Here is a small example class to which we pass a value in the constructor, which is then stored in the final field value:

public class Box {
  private final Object value;

  public Box(Object value) {
    this.value = value;
  }

  @Override
  public String toString() {
    return "Box{value=" + value + "}";
  }
}

The following code shows how easily the content of the final field can be changed – even from outside the object:

Box box = new Box("Rubik's Cube");

IO.println("box = " + box);

Field valueField = Box.class.getDeclaredField("value");
valueField.setAccessible(true);
valueField.set(box, "Magic Wand");

IO.println("box = " + box);

Here we bypass both the encapsulation of the field via private and immutability via final. And this code can be executed from anywhere, for example, by third-party libraries that we don’t even know are on the classpath.

This problem was recognized years ago, and therefore, with Hidden Classes in Java 15 and Records in Java 16, Deep Reflection was not allowed from the outset.

Java 16 also brought “Strong Encapsulation” of JDK internals (JEP 396), which became final in Java 17 (JEP 403). Since then, we have had to explicitly allow Deep Reflection across module boundaries with --add-opens. However, this only works for custom applications if they also use the module system.

To make Java more secure, the modification of final fields via Deep Reflection will be fundamentally prohibited in the future – even within a module.

Warnings in Java 26

In Java 26, JDK Enhancement Proposal 500 introduces warnings as a first step. The code shown above runs without issues in Java 25; in Java 26, it now displays the following warning:

WARNING: Final field value in class eu.happycoders.java26.jep500.Box has been mutated reflectively by class eu.happycoders.java26.jep500.FinalTest in unnamed module @3f99bd52
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled

The use of Deep Reflection can be explicitly allowed for specific modules via --enable-final-field-mutation=<module name> – thus preventing the warning.

In a future Java version, Deep Reflection will be forbidden by default – instead of a warning, an exception will be thrown, unless Deep Reflection is explicitly allowed via --enable-final-field-mutation.

The future behavior can already be enabled using the VM option --illegal-final-field-mutation. The option offers the following possibilities:

OptionEffect
--illegal-final-field-mutation=allowFinal fields can be modified without warning (default behavior before Java 26).
--illegal-final-field-mutation=warnA warning appears the first time a module modifies a final field – at most one per module (default behavior from Java 26).
--illegal-final-field-mutation=debugA warning appears every time a final field is modified.
--illegal-final-field-mutation=denyFinal fields must not be changed – attempting to do so leads to an IllegalAccessException.

In a future Java version, deny will become the default setting, and allow will no longer be allowed. Then, Deep Reflection must be activated at the module level via --enable-final-field-mutation.

Serialization libraries that need to set final fields during deserialization should, according to the JEP, use the sun.reflect.ReflectionFactory API, which is supported for exactly this purpose. And if you want to know where an application mutates final fields, JDK Flight Recorder shows you the locations as jdk.FinalFieldMutation events.

The guarantee that final fields are truly final not only protects against unexpected behavior but also improves application performance: If the JVM knows that a field cannot be changed, it can optimize access to it via Constant Folding – a type of inlining for constants.

Ahead-of-Time Object Caching with Any GC – JEP 516

In Java 24, Ahead-of-Time Class Loading & Linking was introduced – the ability to store the classes required by an application in an architecture-specific binary format in a so-called Ahead-of-Time cache – and to load them from there to significantly accelerate application startup (by up to 42% in tests).

Previously, classes were stored in the cache exactly as the Garbage Collector places them on the heap, including headers and references to other objects (e.g., from Class objects to strings). This has the advantage that the Ahead-of-Time cache file can be mapped directly from the file system to the heap – and this happens with virtually no time loss if the Ahead-of-Time cache file is in the file system cache.

However, the binary representation of both object headers and references can differ from Garbage Collector to Garbage Collector or depending on VM options:

  • With Compressed Oops (default for heaps up to 32 GB), references are 32 bits long.
  • Without Compressed Oops (with a heap larger than 32 GB or with the VM option -XX:-UseCompressedOops), they are 64 bits long.
  • ZGC uses some bits of the references for metadata.
  • By activating Compact Object Headers (via -XX:+UseCompactObjectHeaders up to Java 26, the default since Java 27) or deactivating Compressed Class Pointers (deprecated since Java 25), the binary format of the header changes.

Therefore, an Ahead-of-Time cache created with G1, Serial GC, or Parallel GC could not previously be read by applications using ZGC – and vice versa. Similarly, a cache created with Compressed Oops or Compact Object Headers enabled could not be read by an application where Compressed Oops or Compact Object Headers were disabled – and vice versa.

GC-Independent Ahead-of-Time Cache

To use the Ahead-of-Time cache more flexibly, starting with Java 26, we can create a GC-independent Ahead-of-Time cache. In this approach, objects in the cache file are referenced by their index within the cache. The cache file can no longer be directly mapped to the heap. Instead, objects are gradually read from the file and then stored on the heap in the GC-specific format and correctly linked there – or, in other words: they are streamed from the cache file to the heap.

This leaves us with two approaches: With mapping, the cache file is mapped into the heap in one piece – extremely fast, but only possible when the binary format matches the garbage collector exactly. With streaming, the objects are read from the file one by one and rebuilt and linked in the heap in the garbage collector’s own format – this costs a little more time at startup, but works with any garbage collector.

Ahead-of-Time Object Caching with Any GC: With mapping, the cache file is mapped into the heap in one piece, which only works with Serial GC, Parallel GC, and G1, not with ZGC; with streaming, a background thread reads the objects, referenced by index, one by one from the file and builds them in the heap in the format of the current garbage collector – with any GC, even ZGC
Loading the AOT cache: mapping only with a matching GC format, streaming with any GC

The GC-independent format (also called “Streamable Objects”) is used if, during the training run...

  • ZGC was used – or
  • Compressed Oops were deactivated with -XX:-UseCompressedOops – or
  • the heap was larger than 32 GB – or
  • the VM option -XX:+AOTStreamableObjects was specified.

If, on the other hand, an application was started with G1, Serial GC, or Parallel GC and a heap of a maximum of 32 GB, the previous, GC-specific format is used.

HTTP/3 for the HTTP Client API – JEP 517

The next feature is already fully explained by the heading: The HttpClient API supports version 3 of the HTTP protocol (introduced in 2022) starting with Java 26.

With the HttpClient API, you could, for example, load the content of my homepage and store it in a string as follows:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://www.happycoders.eu/"))
    .build();

HttpResponse<String> response =
    client.send(request, BodyHandlers.ofString());

String responseBody = response.body();

By default, HTTP/2 was used here – and that doesn’t change with Java 26, as only about one-third of all websites currently support HTTP/3.

However, you can now explicitly enable HTTP/3 as follows:

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://www.happycoders.eu/"))
    .version(HttpClient.Version.HTTP_3) // ← Try to use HTTP/3
    .build();

Thus, an attempt is made to communicate with the server via HTTP/3. If the server does not support HTTP/3, it will transparently switch to HTTP/2 or HTTP/1.1.

The request option H3_DISCOVERY lets you adjust this behavior: With Http3DiscoveryMode.ALT_SVC, the client starts with HTTP/2 or HTTP/1.1 and only switches to HTTP/3 once the server advertises it via an Alt-Svc header; with Http3DiscoveryMode.HTTP_3_URI_ONLY, only HTTP/3 is used – if that fails, there is no fallback.

G1 GC: Improve Throughput by Reducing Synchronization – JEP 522

G1 is the default Garbage Collector of the JVM – it offers a balanced ratio of high throughput and low latencies (in contrast, Serial GC and Parallel GC are designed for the highest possible throughput, and ZGC and Shenandoah for the lowest possible latencies).

When the Garbage Collector moves an object, it must also adjust all references to that object. Scanning the entire heap for this would be very inefficient. Therefore, G1 only scans objects within a region (as a reminder: G1 divides the heap into approximately 2,048 regions) – references from one region to another, however, are stored in a separate, efficient data structure called Card Table. G1 can search this particularly quickly.

How does G1 know that the application code has set a reference from one object to another, which also needs to be stored in the Card Table? Through so-called Write Barriers: short pieces of machine code that G1 weaves into the code when an application starts.

To ensure that scanning the Card Table after moving an object is as fast as possible, G1 continuously optimizes the Card Table in the background. Since G1 runs in parallel with the application, access to the Card Table by application threads via Write Barriers and access by the optimization thread must be synchronized. This, in turn, represents a significant overhead.

Reducing Synchronization Overhead in Java 26

Therefore, G1 is optimized in Java 26 as follows:

A second Card Table is introduced. While the application threads access one of these two Card Tables, the optimization thread optimizes the other. As soon as a Card Table has been modified so heavily by the application threads that the time required to scan it exceeds a certain limit, the Card Tables are swapped. The application threads then access the previously optimized Card Table, while the optimization thread re-optimizes the Card Table that has become cumbersome.

G1 GC: Improve Throughput by Reducing Synchronization: The application threads write into the active card table through their write barriers without any synchronization, while the optimizer thread cleans up the inactive second card table; the two card tables are swapped atomically as soon as scanning the active card table would exceed the pause-time goal
G1 in Java 26: The application threads write into one card table, the optimizer thread cleans up the other – the swap is atomic, and no synchronization is needed in between

The optimization thread therefore does not need to be synchronized with the concurrently running application. The reduced synchronization overhead can lead to an increase in overall throughput of 5 to 15%.

There is a second effect on top: Since the write barriers no longer need to synchronize, they have become much shorter – on x64, from around 50 to 12 instructions. That brings up to 5% more throughput even in applications that write only few references, and GC pauses get slightly shorter.

The costs of the second Card Table are minimal: a Card Table occupies 0.2% of the heap – accordingly, with a constant heap size, only 0.2% more native memory is required.

This optimization takes effect after an upgrade to Java 26 without any code changes or VM parameter adjustments.

Re-submitted Preview and Incubator Features

None of the five features that were in preview or incubator status in Java 25 were finalized in Java 26. All five features were re-proposed with minor or major changes. The following sections describe the features with a focus on the changes compared to Java 25.

PEM Encodings of Cryptographic Objects (Second Preview) – JEP 524

PEM stands for Privacy-Enhanced Mail and represents an encoding scheme for cryptographic objects. You have certainly seen PEM-encoded objects before – such as the following PEM-encoded cryptographic certificate:

-----BEGIN CERTIFICATE-----
MIIDtzCCAz2gAwIBAgISBUCeYELtjMmr4FAIqHapebbFMAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
. . .
DBeMde1YpWNXpF9+B/OMKgn7RgXRj5b2QpBCnFsP92T4cK/Nn+xFIjYCMCCx4E79
toSQBlYnNHv0eXnWkI8TmXsU/A6rU4Gxdr9GbGixgRJvkw0C6zjL/lH2Vg==
-----END CERTIFICATE-----

Writing or reading PEM-encoded objects in Java was previously extremely complicated and required over a dozen lines of code. The following example shows what you had to write to read a PEM-encoded private key in Java:

String encryptedPrivateKeyPemEncoded = . . .
String passphrase = . . .

String encryptedPrivateKeyBase64Encoded = encryptedPrivateKeyPemEncoded
    .replace("-----BEGIN ENCRYPTED PRIVATE KEY-----", "")
    .replace("-----END ENCRYPTED PRIVATE KEY-----", "")
    .replaceAll("[\\r\\n]", "");

Base64.Decoder decoder = Base64.getDecoder();
byte[] encryptedPrivateKeyBytes = decoder.decode(encryptedPrivateKeyBase64Encoded);
EncryptedPrivateKeyInfo encryptedPrivateKeyInfo =
    new EncryptedPrivateKeyInfo(encryptedPrivateKeyBytes);

String algorithmName = encryptedPrivateKeyInfo.getAlgName();
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(algorithmName);

PBEKeySpec pbeKeySpec = new PBEKeySpec(passphrase.toCharArray());
Key pbeKey = secretKeyFactory.generateSecret(pbeKeySpec);

Cipher cipher = Cipher.getInstance(algorithmName);
AlgorithmParameters algParams = encryptedPrivateKeyInfo.getAlgParameters();
cipher.init(Cipher.DECRYPT_MODE, pbeKey, algParams);

KeyFactory rsaKeyFactory = KeyFactory.getInstance("RSA");
KeySpec keySpec = encryptedPrivateKeyInfo.getKeySpec(cipher);
PrivateKey privateKey = rsaKeyFactory.generatePrivate(keySpec);

In Java 25, a dedicated PEM API was introduced as a preview feature, intended to significantly simplify handling PEM-encoded objects. Reading the encrypted private key is thus possible with just a few lines of code (or just one, if you write everything on a single line):

PrivateKey privateKey = PEMDecoder.of()
    .withDecryption(passphrase.toCharArray())
    .decode(encryptedPrivateKeyPemEncoded, PrivateKey.class);

In addition to the PEMDecoder shown above, there is a corresponding PEMEncoder with an encode() method.

In Java 26, the basic functionality of this API remains the same – only a few details have been changed and the scope of functions has been slightly increased:

  • Some classes and methods have been renamed, and some methods have been added (the simple example above was not affected by these changes).
  • Some exceptions have been adjusted.
  • In addition to the existing cryptographic objects, key pairs and PKCS#8-encoded keys can now also be encoded and decoded.

More details on the new API and these changes can be found in JDK Enhancement Proposal 524.

Structured Concurrency (Sixth Preview) – JEP 525

Structured Concurrency is already in its sixth preview round. If you are already familiar with the API and just want to know what has changed in Java 26, you can jump to the section Structured Concurrency – Changes in Java 26.

What is Structured Concurrency?

Structured Concurrency means – in contrast to Unstructured Concurrency and based on Structured Programming – that the execution paths created when starting concurrent threads converge at a single point in the code – and that it is guaranteed that no orphaned threads are still running at that point.

The following graphic illustrates this. It also shows that “scopes” (= the areas in which concurrent tasks are executed) created by Structured Concurrency can be nested:

nested structured concurrency

This was previously possible – to a limited extent – with an ExecutorService and the invocation of close() or shutdown() and awaitTermination(). However, the Java API for Structured Concurrency, StructuredTaskScope, offers additional ways to prematurely terminate a scope upon the occurrence of certain events and cancel all still-running tasks. With an ExecutorService, this was only possible with extremely complex and thus error-prone orchestration.

StructuredTaskScope API

The following example shows how multiple subtasks can be started in parallel with the StructuredTaskScope API, and then the result of all subtasks is awaited. Should one of the subtasks fail, the still-running subtasks are canceled, and scope.join() throws a FailedException that contains the exception of the failed subtask as its cause.

Invoice createInvoice(int orderId, int customerId, String language)
    throws InterruptedException {
  try (var scope = StructuredTaskScope.open()) {
    var orderTask    = scope.fork(() -> orderService.getOrder(orderId));
    var customerTask = scope.fork(() -> customerService.getCustomer(customerId));
    var templateTask = scope.fork(() -> templateService.getTemplate(language));

    scope.join();

    var order    = orderTask.get();
    var customer = customerTask.get();
    var template = templateTask.get();

    return Invoice.generate(order, customer, template);
  }
}

The following example shows another strategy (enabled by Joiner.anySuccessfulOrThrow()): Here, only the result of one of the subtasks is needed – and as soon as it is available, the other subtasks are canceled:

AddressVerificationResponse verifyAddress(Address address)
    throws InterruptedException {
  try (var scope = StructuredTaskScope.open(
      Joiner.<AddressVerificationResponse>anySuccessfulOrThrow())) {

    scope.fork(() -> verificationService.verifyViaServiceA(address));
    scope.fork(() -> verificationService.verifyViaServiceB(address));
    scope.fork(() -> verificationService.verifyViaServiceC(address));

    return scope.join();
  }
}

The Joiner interface used here provides further strategies. You can find out what these are and how you can implement your own strategies in the main article on Structured Concurrency in Java.

Structured Concurrency – Changes in Java 26

Structured Concurrency was first introduced in Java 21. In Java 25, the API was fundamentally revised (keyword: “Composition over Inheritance”). In Java 26, the following extensions and adjustments were made through JDK Enhancement Proposal 525:

  • The Joiner interface has received an additional method onTimeout(), in addition to onFork() and onComplete(). This method is called by StructuredTaskScope.join() in case of a timeout and throws a TimeoutException by default. However, it can be overridden in a custom Joiner to handle timeouts alternatively – e.g., to return a specific value. (In Java 27, it was replaced by timeout().)
  • The Joiner created by Joiner.allSuccessfulOrThrow() no longer returns a stream of subtasks (which are all successful anyway), but a list of the results of the subtasks.
  • The Joiner created by Joiner.allUntil(...) returns a list of subtasks instead of a stream of subtasks.
  • The method Joiner.anySuccessfulResultOrThrow() was renamed to anySuccessfulOrThrow() (the word Result was removed, as it is not used in the other Joiner factory methods either).
  • There is an overloaded StructuredTaskScope.open(...) method that allows customizing the configuration of a joiner. In this method, the type for the configuration parameter was changed from Function<Configuration, Configuration> to UnaryOperator<Configuration> (which is ultimately a Function<Configuration, Configuration> again).

Code written with Java 25 therefore requires only minor (or no) adjustments to run on Java 26.

Lazy Constants (Second Preview) – JEP 526

Lazy Constants were introduced in Java 25 as Stable Values. If you are already familiar with Stable Values and are only interested in the changes in Java 26, you can jump to the section Lazy Constants – Changes in Java 26.

What problem do we want to solve?

Constants – i.e., immutable values – have many advantages: They make the code simpler and safer, as they can only be in one state and can be safely accessed from multiple threads. In addition, they enable performance optimizations by the JVM, e.g., through Constant Folding (already mentioned above for final fields).

Until now, constants could only be defined by final fields:

  • final static fields, which are initialized when a class is loaded – or
  • final instance fields, which are initialized when an object is created.

If you want to initialize an immutable value only when needed, e.g., because the initialization is expensive, you have to use the concept of “Lazy Initialization”. To make lazy initialization thread-safe in Java, we previously had to resort to either the double-checked locking idiom or the initialization-on-demand holder idiom. Anyone who has done this before knows that errors can easily creep in.

Lazy Constants API

In Java 25, the Stable Values API was introduced to simplify Lazy Initialization. After extensive feedback, the API in Java 26 was greatly simplified by JDK Enhancement Proposal 526 and renamed to Lazy Constants.

The following example shows how we define a LazyConstant that initializes a Settings object by loading it from a database only upon its first access:

private final LazyConstant<Settings> settings =
    LazyConstant.of(this::loadSettingsFromDatabase);

public Locale getLocale() {
  return settings.get().getLocale(); // ⟵ Here we access the lazy constant
}

Only on the first call to settings.get() will the loadSettingsFromDatabase() method be invoked. The value is then stored in the LazyConstant object, and subsequent calls to settings.get() will return this stored value. Thread safety is also guaranteed: Should settings.get() be called simultaneously from multiple threads, loadSettingsFromDatabase() will still be called at most once.

Once the LazyConstant is initialized, it is interpreted as immutable by the JVM, and access to it is optimized through Constant Folding.

Lazy Lists

In addition to individual Lazy Constants, we can also define Lazy Lists – lists where each element is a Lazy Constant. The following example shows a simple Lazy List in which each field is lazily initialized with the square root of the field index:

private final List<Double> squareRoots = List.ofLazy(100, Math::sqrt);

Here too, initialization only occurs on first access – and separately for each element of the list, e.g., during direct access with get(int index) – or when iterating over the list. And here too, initialization is thread-safe, and after initialization, the values are treated and optimized by the JVM like constants.

Lazy Maps

Maps can also be initialized lazily. The following example shows a Lazy Map where Locales are mapped to ResourceBundles:

Set<Locale> supportedLocales = getSupportedLocales();
Map<Locale, ResourceBundle> resourceBundles =
    Map.ofLazy(supportedLocales, this::loadResourceBundle);

On the first access to a map element for a specific Locale, the corresponding ResourceBundle is loaded via loadResourceBundle() and then stored in the map as a constant. Just like Lazy Constants and Lazy Lists, Lazy Maps are also thread-safe.

Lazy Constants – Changes in Java 26

In addition to the obvious change – the renaming to Lazy Constants – the following simplifications were made in Java 26:

  • Low-level methods such as orElseSet(), setOrThrow(), and trySet() were removed, as they made the API unnecessarily complicated.
  • Lazy Lists and Lazy Maps were previously created via StableValue.list(...) and StableValue.map(...). These factory methods were moved to the List and Map interfaces, respectively.
  • The Function and IntFunction implementations created via StableValue.function(...) and StableValue.intFunction(...), respectively, were removed without replacement, as they offered no added value compared to Lazy Lists and Lazy Maps.
  • For performance reasons, Lazy Constants, Lazy Lists, and Lazy Maps may no longer contain null values. If the computation function returns null, a NullPointerException is thrown.

So, if you have already relied on Stable Values in Java 25, extensive refactoring will be required.

You can find more examples and details in the main article about lazy constants.

Vector API (Eleventh Incubator) – JEP 529

And now, for the eleventh time, we come to the Vector API.

With the Vector API, mathematical vector operations can be calculated particularly efficiently using the vector instruction sets of modern CPUs (such as SSE and AVX) – e.g., a vector addition like the following:

java vector addition
Example of a vector addition

As Java code, this operation would be implemented as follows, where a and b are the input vectors and c is the output vector:

static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;

void addVectors(float[] a, float[] b, float[] c) {
  int i = 0;
  int upperBound = SPECIES.loopBound(a.length);
  for (; i < upperBound; i += SPECIES.length()) {
    var va = FloatVector.fromArray(SPECIES, a, i);
    var vb = FloatVector.fromArray(SPECIES, b, i);
    var vc = va.add(vb);
    vc.intoArray(c, i);
  }
  for (; i < a.length; i++) {
    c[i] = a[i] + b[i];
  }
}

Currently, quite a lot of boilerplate is still necessary for such a simple operation:

  • Via SPECIES.length(), it is queried how many vector elements can be processed simultaneously in one CPU cycle.
  • SPECIES.loopBound(...) calculates into how many complete sub-vectors of this length the output vector can be split.
  • All sub-vectors are added via the first loop.
  • If the length of the output vector is not a multiple of the sub-vector length, a remainder remains. Its elements are individually added via the second loop.

I am curious if the API will be further simplified after it reaches the preview stage.

For that, however, the first JEP from Project Valhalla – JEP 401: Value Classes and Objects – has to reach the preview stage. This is because the Vector class is intended to be a Value Class from the start – i.e., a class whose objects have no identity.

When will it be ready? For years, Brian Goetz, Language Architect at Oracle, consistently answered the question about Project Valhalla’s release date with: “It’s ready when it’s ready.” On October 10, 2025, the Java community was given a first early-access build featuring value classes – and now the time has come: JEP 401 has been integrated into JDK 28 since August 7, 2026, so Java 28 will ship the first Valhalla preview.

The eleventh incubator version of the Vector API is described in JDK Enhancement Proposal 529: There are no substantial changes compared to the previous version.

Primitive Types in Patterns, instanceof, and switch (Fourth Preview) – JEP 530

Pattern Matching with primitive types was first introduced in Java 23 as a preview feature. Since then, there have been no fundamental changes. If you are already familiar with the feature and are interested in the minor refinements in Java 26, feel free to jump to the section Primitive Types in Patterns – Changes in Java 26.

Pattern Matching and switch with Primitive Types – Current Status

Pattern Matching has so far been limited to reference types, for example, as follows:

Object obj = . . .
switch (obj) {
  case String s when s.length() >= 5 -> IO.println(s.toUpperCase());
  case Integer i                     -> IO.println(i * i);
  case null, default                 -> IO.println(obj);
}

A switch over primitive types is possible – but only with byte, short, char and int – and in the case-labels, only constants are allowed:

int code = . . .
switch (code) {
  case 200 -> IO.println("OK");
  case 404 -> IO.println("Not Found");
}

Pattern Matching and switch with Primitive Types – What will change?

In the future, all primitive types should be allowed in switch, including long, double, float, and even boolean. And patterns should also be allowed in the case labels. This would allow us to check, for example, an int value against specific number ranges:

int code = . . .
switch (code) {
  case int i when i >= 100 && i < 200 -> IO.println("information");
  case int i when i >= 200 && i < 300 -> IO.println("success");
  case int i when i >= 300 && i < 400 -> IO.println("redirection");
  case int i when i >= 400 && i < 500 -> IO.println("client error");
  case int i when i >= 500 && i < 600 -> IO.println("server error");
  default -> throw new IllegalArgumentException();
}

Patterns with reference types also match derived types, e.g., a case Number n would also match an object of type Integer. With primitive types, there is no inheritance – therefore, the JDK developers have come up with something different here:

In the future, we can check with switch (and similarly with instanceof) whether the value of a primitive variable can be represented by another primitive type without loss of precision:

double value = . . .
switch (value) {
  case byte   b -> IO.println(value + " instanceof byte:   " + b);
  case short  s -> IO.println(value + " instanceof short:  " + s);
  case char   c -> IO.println(value + " instanceof char:   " + c);
  case int    i -> IO.println(value + " instanceof int:    " + i);
  case long   l -> IO.println(value + " instanceof long:   " + l);
  case float  f -> IO.println(value + " instanceof float:  " + f);
  case double d -> IO.println(value + " instanceof double: " + d);
}

The order of the case labels here is no accident: it goes from the smallest type to the largest. Java checks the labels from top to bottom and picks the first type that can represent the value without loss of precision – so a value always ends up in the smallest type it fits into.

If value were, say, 42, the pattern byte b would match, because 42 can also be stored in a byte. If value were 50,000, the pattern char c would match. For 100,000, int i would match; for 0.5, float f; and for 0.7, only double d.

The dominance principle also applies to primitive types: The order of the case labels in the previous example must not be changed, as individual case labels would then no longer be reachable. For example, the pattern int i should not appear before byte b, as every possible byte would already match int i.

Primitive Types in Patterns – Changes in Java 26

Through JDK Enhancement Proposal 530, two things were changed in Java 26: The definition of an “unconditionally exact” conversion was extended, and the dominance check was tightened. The following code, for example, can still be compiled without errors with Java 25:

// Compiles in Java 25, although float f already matches 16_777_216
int i1 = . . .
switch (i1) {
  case float f -> {}
  case 16_777_216 -> {}
  default -> {}
}

// Compiles in Java 25, although int _ already matches every int
int i2 = . . .
switch (i2) {
  case int _ -> {}
  case float _ -> {}
}

// Compiles in Java 25, although short s already matches 42
byte b = . . .
switch (b) {
  case short s -> {}
  case 42 -> {}
}

In Java 26, however, all three switch statements are met with compiler errors:

  • In the first switch, the constant 16,777,216 can never match, since this number can also be precisely represented by a float, and thus the constant is dominated by the pattern float f.
  • In the second switch, the pattern float _ can never match, since every value that i2 can take is already matched by the pattern int _.
  • In the third switch, the constant 42 can never match, since 42 can also be stored in a short, and thus the constant is dominated by the pattern short s.

For the first and the third switch, the compiler needs the extended “unconditionally exact” definition: Until now, a conversion was only considered “unconditionally exact” if it is lossless for every value of the source type – e.g., from byte to int. Now this also applies to a single constant if the compiler determines that exactly this value can be converted without loss – like 16,777,216 to float or 42 to short. Only then can it recognize that the type pattern dominates the constant label.

The second switch, on the other hand, is rejected by a new dominance rule: A pattern that matches every value of the selector – here int _ – may no longer be followed by any other case label. Previously, float _ was not considered dominated here, because int _ would not match every float value – that the selector cannot produce any float values at all did not matter.

Deprecations and Removals

In Java 26, the Applet API and the Thread.stop() method were removed. In addition, the LockingMode flag is now ignored. You’ll find details in the following three sections.

Remove the Applet API – JEP 504

The Applet API was deprecated in Java 9 in 2017, as applets were no longer supported by any modern web browser at that time. In Java 17, both the Applet API (JEP 398) and the Security Manager (JEP 411), which was responsible for sandboxing applets, were deprecated for removal.

The Security Manager was disabled in Java 24.

In Java 26, the Applet API – i.e., all classes in the java.applet package as well as some others, such as java.beans.AppletInitializer and javax.swing.JApplet – has now been completely removed via JDK Enhancement Proposal 504.

Thread.stop is removed

Thread.stop() was already marked as deprecated in Java 1.2 – in December 1998 – because it could lead to inconsistent states and unpredictable behavior. In Java 18, it was marked as deprecated for removal, and since Java 20, it throws an UnsupportedOperationException.

In Java 26 – more than 27 years after being marked as deprecated – the method is now completely removed.

(There is no JEP for this change; it is registered in the bug tracker under JDK-8368226.)

Make users and test suite not able to set LockingMode flag

The LockingMode flag, deprecated in Java 24, which allowed temporarily switching back from the default lightweight locking to the legacy stack locking, no longer has any effect in Java 26. In Java 27, it was removed.

(There is no JEP for this change; it is registered in the bug tracker under JDK-8359437.)

Other Changes in Java 26

In this section, you will find a selection of minor changes from the release notes for which no JDK Enhancement Proposals were written.

Add Dark Theme to API Documentation

This feature is quickly explained: in Java 26, the Javadoc documentation offers a dark mode. You can try it out in the Java 26 documentation by clicking on the sun or moon icon in the menu bar.

Here’s an impression of the new mode:

java 26 dark mode.v2

(There is no JEP for this change; it is registered in the bug tracker under JDK-8342705.)

New ofFileChannel Method in java.net.http.HttpRequest.BodyPublishers

To send a file with the HttpClient API (available since Java 11), it previously had to be completely loaded into RAM and passed as a byte array to the HttpRequest:

byte[] fileContent = Files.readAllBytes(Path.of("test.bin"));

try (HttpClient client = HttpClient.newHttpClient()) {
  HttpRequest request =
      HttpRequest.newBuilder()
          .uri(URI.create("https://www.example.com/upload"))
          .POST(BodyPublishers.ofByteArray(fileContent))
          .build();

  HttpResponse<Void> response =
      client.send(request, HttpResponse.BodyHandlers.discarding());
}

Starting with Java 26, a file (or part of it) can also be streamed via FileChannel to the HttpRequest and thus no longer needs to be fully available in memory beforehand. This is particularly helpful for very large files:

try (FileChannel fileChannel =
        FileChannel.open(Path.of("test.bin"), StandardOpenOption.READ);
    HttpClient client = HttpClient.newHttpClient()) {

  HttpRequest request =
      HttpRequest.newBuilder()
          .uri(URI.create("https://www.example.com/upload"))
          .POST(BodyPublishers.ofFileChannel(fileChannel, 0, fileChannel.size()))
          .build();

  HttpResponse<Void> response =
      client.send(request, HttpResponse.BodyHandlers.discarding());
}

(There is no JEP for this change; it is registered in the bug tracker under JDK-8329829.)

Default Initial Heap Size Now Set to MinHeapSize

If neither -Xms nor -XX:InitialRAMPercentage is used to specify an initial heap size when starting a Java application, the heap used to be set by default to 1/64 (= 1.5625%) of the physical RAM. Today, computers have significantly more RAM than twenty years ago, so many applications are equipped with an unnecessarily large heap. On my 64 GB laptop, for example, even a Hello World application used to start with a 1 GB heap.

Besides the unnecessarily occupied RAM, this can also noticeably delay the start of an application, because the Garbage Collector has to create initial data structures for the heap, which scale with the heap size.

In Java 26, the initial heap size is therefore no longer derived from the RAM: InitialRAMPercentage no longer has a default value, and the heap starts at the minimum heap size (MinHeapSize). On my 64 GB laptop, that is 8 MB instead of 1 GB; when needed, the heap then grows as usual. You get the old behavior back with -XX:InitialRAMPercentage=1.5625.

(There is no JEP for this change; it is registered in the bug tracker under JDK-8371986. A first step – 0.2% instead of 1.5625% – had been integrated in September 2025 under JDK-8348278.)

Virtual threads now unmount when waiting for another thread to execute a class initializer

Under certain circumstances, virtual threads are “pinned” to their carrier thread, meaning that if the virtual thread blocks, it cannot be unmounted from its carrier thread (the operating system thread on which the virtual thread is executed). Thus, the carrier thread is also blocked and cannot execute another virtual thread.

Already in Java 24, the serious pinning issue within synchronized blocks was resolved.

Before Java 26, a virtual thread was also pinned if it tried to initialize a class that was currently being initialized by another thread. This no longer happens either.

(There is no JEP for this change; it is registered in the bug tracker under JDK-8369238.)

Support for Unicode 17.0

Java 26 increases Unicode support to version 17.0.

Why is this relevant? All character-processing classes such as String and Character must be able to process the characters and code blocks introduced in the new Unicode version.

(There is no JEP for this change; it is registered in the bug tracker under JDK-8346944.)

Complete List of All Changes in Java 26

In this article, I have presented all JDK Enhancement Proposals that were delivered with Java 26, as well as a selection of changes from the release notes. You can find the complete list of all changes in the Java 26 Release Notes.

Conclusion

Compared to the last two Java versions, the changes in Java 26 are quite manageable:

  • Mutating final fields with deep reflection now issues a warning – in the future, an exception will be thrown.
  • The Ahead-of-Time Class Loading & Linking introduced in Java 24 now works with every garbage collector – including ZGC.
  • HttpClient now supports HTTP/3.
  • The G1 Garbage Collector has been optimized, leading to an increase in throughput.
  • The Applet API and Thread.stop() have been removed.
  • Stable Values have been renamed to Lazy Constants – and the API has been radically simplified.
  • For the remaining features currently in preview, minor improvements have been made.

As always, various other changes round off the release. The Java 26 builds are still available in the OpenJDK archive. For new projects, you are better off with the current LTS version, Java 25, or the current Java 27.

Did this article answer your questions? Then I’d be happy about a review on my ProvenExpert profile – it helps other developers find this content.

👉 Leave a review

This subject in your own code?

You have read the article – in the training your team works with it. Over 2 days we go through the subjects on your own projects instead of constructed examples.

Hands-on, easy to understand, and directly applicable to your day-to-day project work. Instead of theory, I teach principles that help you write code that is better, more maintainable, and more performant in the long run.

Java 25 TrainingSee all trainings

Become a Better Java Developer

My free newsletter keeps you ahead. Modern Java: new versions & features, performance, and JVM insights – once a month.

Search