Hello Java 27! Have You Been Working Out?
It’s been six months since we saw a new Java release, and this new edition is seems to be ready to flex its muscles. With numerous improvements to performance and security, Java 27 is like that friend that you haven’t seen all summer, and when you finally see them again it’s clear that they spent a lot of time in the gym!
This post focuses on everything that has been added in this release, giving you a brief introduction to each of the features. Where applicable the differences with Java 26 are highlighted and a few typical use cases are provided, so that you’ll be more than ready to start using these features after reading this.

Photo by Elena Kravets, from Pexels
JEP Overview
To start off, let’s look at an overview of the JEPs that ship with Java 27. This table contains the preview status for all JEPs, to which project they belong, what kind of features they add and the things that have changed since Java 26.
| JEP | Title | Status | Project | Feature Type | Changes since previous Java version |
|---|---|---|---|---|---|
| 523 | Make G1 the Default Garbage Collector in All Environments | HotSpot | Performance | New defaults | |
| 527 | Post-Quantum Hybrid Key Exchange for TLS 1.3 | Security Libs | Security | New feature | |
| 531 | Lazy Constants | Third Preview | Core Libs | New API | Major |
| 532 | Primitive Types in Patterns, instanceof, and switch | Fifth Preview | Amber | Language | None |
| 533 | Structured Concurrency | Seventh Preview | Loom | Concurrency | Minor |
| 534 | Compact Object Headers by Default | HotSpot | Performance | New defaults | |
| 536 | JFR In-Process Data Redaction | HotSpot / JFR | Security | New feature | |
| 537 | Vector API | Twelfth Incubator | Core Libs | New API | None |
| 538 | PEM Encodings of Cryptographic Objects | Third Preview | Security Libs | Security | Minor |
New Features
Let’s start with the JEPs that add brand-new features to Java 27.
JEP 527: Post-Quantum Hybrid Key Exchange for TLS 1.3
The advancements in the field of quantum computing threatens today’s public-key based encryption algorithms, like Rivest-Shamir-Adelman (RSA) and Elliptic-Curve Diffie-Hellman (ECDH). Through “harvest now, decrypt later” attacks, adversaries can store encrypted data now and decrypt it once quantum hardware matures. The Internet Engineering Task Force (IETF) TLS Working Group created a hybrid key exchange framework for TLS 1.3 that pairs a quantum-resistant algorithm with a traditional one, remaining secure as long as either algorithm holds. Java is progressively enabling this: the KEM API landed in Java 21 and ML-KEM in Java 24, making hybrid key exchange for TLS the logical next step in Java’s post-quantum cryptography support.
JEP 527 proposes to enhance the JDK’s TLS 1.3 implementation to support three new post-quantum hybrid key exchange schemes that combine ML-KEM with the traditional Ephemeral ECDH algorithms:
X25519MLKEM768: Hybrid scheme combining ECDHE with X25519 and ML-KEM-768;SecP256r1MLKEM768: Hybrid scheme combining ECDHE using the secp256r1 curve with ML-KEM-768;SecP384r1MLKEM1024: Hybrid scheme combining ECDHE using the secp384r1 curve with ML-KEM-1024.
The TLS specification refers to these key exchange schemes as “named groups”. Accordingly, the names of these schemes are added to the Named Groups section of the Java Security Standard Algorithm Names specification.
Using Hybrid Key Exchange Schemes
To benefit from quantum-resistant TLS no changes to existing code are necessary, as the JDK’s TLS 1.3 implementation places the X255119MLKEM768 hybrid scheme at the front of its priority list. Setting specific schemes is possible by calling the SSLParameters::setNamedGroups method, like so:
SSLSocket tlsSock = (SSLSocket)(SSLContext.getDefault().
getSocketFactory().createSocket());
SSLParameters params = tlsSock.getSSLParameters();
// Configure the socket to use two hybrid KEM schemes and
// two traditional schemes
params.setNamedGroups(new String[] {
"SecP256r1MLKEM768", "X25519MLKEM768", "secp256r1", "x25519"
});
tlsSock.setSSLParameters(params);
More Information
For more information on this feature, read JEP 527.
JEP 536: JFR In-Process Data Redaction
The Java Flight Recorder (or JFR) is an event recorder built into the JVM. It captures information about the JVM itself – and the applications running in it – not unlike a data flight recorder (or ‘black box’) in a commercial aircraft.
JFR recording files automatically capture startup and configuration events — including command-line arguments, environment variables, and system properties. Because this data appears verbatim, those files can leak sensitive information (secrets in arguments, access tokens in environment variables, and passwords in system properties) whenever recordings are shared, archived, or attached to support cases.
If you would start an application with JFR enabled, like this…
$ export ACCESS_TOKEN=SECRET_TOKEN
$ java -XX:StartFlightRecording:filename=dump.jfr \
-Xmx2G \
-Djavax.net.ssl.keyStorePassword=SECRET_PASSWORD \
-jar application.jar \
--dbpassword ANOTHER_SECRET_PASSWORD
…then the resulting dump.jfr would contain events that, when read, would reveal all the values passed to the java process (including the secret ones).
JEP 536 proposes to redact many kinds of sensitive information by default, without any additional configuration. You can explicitly select the information that JFR should redact via new sub-options of the existing command-line option -XX:FlightRecorderOptions. Each sub-option specifies one or more filters that select the command-line arguments, environment variables, and system properties to be redacted.
- The
redact-argumentsub-option specifies a filter list for command-line arguments. If one of the filters matches an argument, the argument is redacted. - The
redact-keysub-option specifies a filter list for key-value pairs in the form of environment variables and system properties. If one of the filters matches a key, the associated value is redacted.
Matching is case-insensitive. Filters use glob patterns, where * and ? are wildcards. If a filter matches an argument or a key, the argument or the key’s value is recorded in the event as [REDACTED].
For example, to redact any environment variable or system property named confidential or CONFIDENTIAL, plus any command-line argument that looks like a URL containing a username and password (i.e., username:password@host), use:
$ export CONFIDENTIAL=SOME_SECRET
$ java -XX:FlightRecorderOptions:'redact-key=confidential,redact-argument=https://*:*@*' \
-XX:StartFlightRecording:filename=dump.jfr \
-Dconfidential=ANOTHER_SECRET \
-jar application.jar https://john:YET_ANOTHER_SECRET@example.com/login --verbose
To verify that sensitive information has been redacted, use jfr print:
$ jfr print \
--events InitialSystemProperty,JVMInformation,StringFlag,InitialEnvironmentVariable \
dump.jfr
jdk.JVMInformation {
startTime = 17:39:02.196 (2026-02-15)
jvmVersion = "Java HotSpot(TM) 64-Bit Server VM"
jvmArguments = "-Dconfidential=[REDACTED]
-XX:FlightRecorderOptions:redact-key=confidential,redact-argument=[REDACTED]
-XX:StartFlightRecording:filename=dump.jfr"
jvmFlags = "N/A"
javaArguments = "-jar application.jar [REDACTED] --verbose"
jvmStartTime = 17:39:02.050 (2026-02-15)
pid = 43671
}
jdk.InitialSystemProperty {
startTime = 17:39:02.196 (2026-02-15)
key = "confidential"
value = "[REDACTED]"
}
jdk.InitialSystemProperty {
startTime = 17:39:02.196 (2026-02-15)
key = "sun.java.command"
value = "-jar application.jar [REDACTED] --verbose"
}
jdk.StringFlag {
startTime = 17:39:02.196 (2026-02-15)
name = "FlightRecorderOptions"
value = "redact-key=confidential,redact-argument=[REDACTED]"
origin = "Command line"
}
jdk.InitialEnvironmentVariable {
startTime = 17:39:02.244 (2026-02-15)
key = "CONFIDENTIAL"
value = "[REDACTED]"
}
Matching Multiple Command-Line Arguments
A filter can also match a sequence of command-line arguments, separated by whitespace. For example, this filter redacts any argument named --password, together with the argument that is supplied directly after:
$ java -XX:FlightRecorderOptions:'redact-argument=--password *' ...
Loading Filters From a File
To avoid overly long command lines, JFR can load redaction filters from a file. To specify a filter file on the command line, prefix the filename with @:
$ java '-XX:FlightRecorderOptions:redact-argument=@args.txt,redact-key=@keys.txt' ...
Default Filters
If the redact-key sub-option is not specified, JFR uses a default filter list for key-value pairs:
*api*key*
*auth*
*client*secret*
*credential*
*jaas*config*
*jwt*
*passphrase*
*passwd*
*password*
*private*key*
*pwd*
*secret*
*token*
If the redact-argument sub-option is not specified, JFR uses this default filter list for command-line arguments:
-*api*key *
-*client*secret *
-*credential *
-*jaas*config *
-*jwt *
-*passphrase *
-*passwd *
-*password *
-*private*key *
-*pwd *
-*secret *
-*token *
*api*key*
*client*secret*
*credential*
*jaas*config*
*passphrase*
*passwd*
*password*
*private*key*
*pwd*
*secret*
*token*
Adding to the Default Filters
To include the default filters when you specify your own filters, prefix the first filter with +:
$ java -XX:FlightRecorderOptions:'redact-key=+confidential;secret;@keys.txt' ...
More Information
For more information on this feature, see JEP 536.
Repreviews
Now it’s time to take a look at a few features that may already be familiar to you, because they were introduced in a previous version of Java. They have been repreviewed in Java 27, with only minor changes compared to Java 26 in most cases.
JEP 531: Lazy Constants (Third Preview)
Immutable objects are a far less complicated concept than mutable objects, because they can only be in a single state and can be shared freely across multiple threads.
Currently, the main tool to achieve immutability in Java is final fields.
But they come with two drawbacks, restricting their potential in many real-world applications:
- they must be set eagerly;
- the order in which multiple
finalfields are initialized can never be changed, as it is determined by the textual order in which the fields are declared.
Consider the use of immutability in the following code example, which takes place in a guitar store domain:
class OrderController {
private final Logger logger = Logger.create(OrderController.class);
void submitOrder(User user, List<Guitar> guitar) {
logger.info("Ordering new guitars...");
// ...
logger.info("New guitars have been ordered, let's get to work!");
}
}
Whenever an instance of OrderController is created, the logger field is initialized eagerly, which potentially makes creating an OrderController slow.
And this might not be the only place in our application where a logger field is initialized eagerly:
class GuitarStore {
static final OrderController ORDERS = new OrderController();
static final GuitarRepository GUITARS = new GuitarRepository();
static final ManufacturerService MANUFACTURERS = new ManufacturerService();
}
All this initialization work causes the application to start up more slowly, and the worst thing is: it may not even be necessary!
If a user is simply browsing the guitar store, with no intention of ordering a new guitar, the OrderController won’t even be called and we will have initialized the logger field for nothing.
Sacrificing Immutability For More Flexible Initialization
The only alternative we currently have is to resort to a mutability-based approach, in which we delay the initialization of complex objects to as late a time as possible:
class OrderController {
private Logger logger;
Logger getLogger() {
if (logger == null) {
logger = Logger.create(OrderController.class);
}
return logger;
}
void submitOrder(User user, List<Guitar> guitar) {
getLogger().info("Ordering new guitars...");
// ...
getLogger().info("New guitars have been ordered, let's get to work!");
}
}
This decreases application startup time, but comes with a few drawbacks of its own:
- All accesses to the
loggerfield must go through thegetLoggermethod, but code that fails to follow this practice runs the risk of encounteringNullPointerExceptions; - In multi-threaded environments, multiple logger objects could be created during concurrent calls to the
submitOrdermethod; - Constant-folding access to an already-initialized
loggerfield is no longer viable, as the JVM can’t trust its content never to change after its initial update.
What we need is a solution that has the best of both worlds:
- a way to promise that a field will be initialized by the time it is used;
- with a value that is computed at most once, and;
- safely with respect to concurrency.
In other words, we want to defer immutability, and have first-class support for it in the Java runtime.
Lazy Constants
JEP 531 introduces that first-class support in the form of lazy constants.
A lazy constant is an object of type LazyConstant, that holds a single data value.
It must be initialized some time before its content is first retrieved, and is immutable thereafter.
Let’s rewrite the OrderController class to use a lazy constant for its logger:
class OrderController {
private final LazyConstant<Logger> logger = LazyConstant.of(() -> Logger.create(OrderController.class));
void submitOrder(User user, List<Guitar> guitar) {
logger.get().info("Ordering new guitars...");
// ...
logger.get().info("New guitars have been ordered, let's get to work!");
}
}
Initially, the lazy constant is uninitialized. When it is accessed for the first time through the get() method, it is initialized by invoking the lambda expression that was passed to the of() factory method.
If the lazy constant was already initialized, then the get method simply returns its content.
Thus, the get method guarantees that the provided lambda expression is evaluated only once (even when it is invoked concurrently).
If we look at the properties of lazy constants, we see that they fill a gap between final and non-final fields:
| Update count | Update location | Constant folding? | Concurrent updates? | |
|---|---|---|---|---|
final field |
1 | Constructor or static initializer | Yes | No |
LazyConstant |
[0, 1] | Computing function | Yes, after update | Yes, by winner |
non-final field |
[0, ∞] | Anywhere | No | Yes |
Usage of lazy constants is certainly not limited to loggers–we can also use a lazy constant to store the OrderController component itself, and related components:
class GuitarStore {
static final LazyConstant<OrderController> ORDERS = LazyConstant.of(OrderController::new);
static final LazyConstant<GuitarRepository> GUITARS = LazyConstant.of(GuitarRepository::new);
static final LazyConstant<ManufacturerService> MANUFACTURERS = LazyConstant.of(ManufacturerService::new);
public static OrderController orders() {
return ORDERS.get();
}
public static GuitarRepository guitars() {
return GUITARS.get();
}
public static ManufacturerService manufacturers() {
return MANUFACTURERS.get();
}
}
The application’s startup time improves because it no longer initializes its components, such as OrderController, up front.
Rather, it initializes each component on demand, via the get method of the corresponding lazy constant.
Each component, moreover, initializes its sub-components, such as its logger, on demand in the same way.
Under the hood, the JVM treats the content of any lazy constant that is declared as final as a constant, allowing constant-folding optimizations to happen.
Lazy Lists
What if you wanted to keep track of multiple lazy constants, for example when keeping a pool of objects? We can implement this by using a lazy list:
class GuitarStore {
static final int POOL_SIZE = 10;
static final List<OrderController> ORDERS = List.ofLazy(POOL_SIZE, _ -> new OrderController());
public static OrderController orders() {
long index = Thread.currentThread().threadId() % POOL_SIZE;
return ORDERS.get((int) index);
}
}
Here, ORDERS is no longer a lazy constant, but a lazy list, in which each element is stored in a lazy constant.
To access the content, clients call ORDERS.get(...), passing it an index, of which the first invocation will invoke the lambda function that ignores the index and invokes the OrderController() constructor.
Subsequent invocations of ORDERS.get(...) with the same index will return the element’s content immediately.
Lazy Maps
Alternatively, we could have solved the problem with a lazy map, whose keys are known at construction time and whose values are stored in lazy constants, initialized on demand by a computing function that is also provided at construction:
class GuitarStore {
static final Map<String, OrderController> ORDERS = Map.ofLazy(Set.of("Customers", "Internal", "Testing"), _ -> new OrderController());
public static OrderController orders() {
return ORDERS.get(Thread.currentThread().getName());
}
}
In this example, OrderController instances are associated with thread names (“Customers”, “Internal”, and “Testing” in this case) rather than integer indexes computed from thread identifiers. Lazy maps allow for more expressive access idioms than lazy lists, but otherwise have all the same benefits.
Lazy Sets
When you don’t need to map the lazily computed value to an index (like in a lazy list) or a key (like in a lazy map), a lazy set may be the best fit.
Consider a feature in our guitar store program that determines whether a guitar type is currently in stock. This may be a slow call to an ERP system that we don’t want to be executed eagerly. On top of that, we sell a lot of guitar types and we don’t want to call the ERP system for each and every one of them–just for the ones that end up in an order. This is a good use case for a lazy set:
class GuitarStore {
// We sell a LOT of guitar types...
enum GuitarType { LES_PAUL, TELECASTER, STRATOCASTER, SUPER_STRAT, BARITONE, FLAMENCO, ... }
private static boolean inStock(GuitarType guitarType) {
// (slow) call to ERP system
...
}
// Lazily initialized Set of GuitarTypes
static final Set<GuitarType> GUITAR_TYPES_IN_STOCK =
Set.ofLazy(EnumSet.allOf(GuitarType.class), GuitarStore::inStock);
public static void order(GuitarType guitarType) {
// This line causes a call to inStock for at most 1 guitar type at a time
if (!GUITAR_TYPES_IN_STOCK.contains(guitarType)) {
throw new OutOfStockException("Sorry, out of stock");
}
// Actual processing order logic
...
}
}
What’s Different From Java 26?
The API was changed significantly in Java 26, shifting the feature’s focus to high-level use cases only. The minor changes applied in Java 27 have a similar purpose–they include:
- Removing the low-level methods
isInitializedandorElse, as these could be used in ways not consistent with the design goals of the API. - Adding a new factory method,
Set.ofLazy(...), that can create a stableSetof pre-defined element candidates. With this addition, there lazy versions of the three fundamental collection types now exist:List,Set, andMap.
More Information
JEP 531 has more details on the current state of this feature, should you wish to learn more.
JEP 532: Primitive Types in Patterns, instanceof, and switch (Fifth Preview)
Since Java 23, pattern matching supports primitive types in all pattern contexts, and in the instanceof and switch constructs. The feature has been in four consecutive preview statuses, and will be previewed for a fifth time in Java 27. Let’s first go through the differences with Java 22.
Pattern Matching for Switch
Java 22’s version of pattern matching for switch didn’t support type patterns that specify a primitive type. In Java 23 support was added for primitive type patterns in switch, allowing the following code example:
switch (reverb.roomSize()) {
case 1 -> "Toilet";
case 2 -> "Bedroom";
case 30 -> "Classroom";
default -> "Unsupported value: " + reverb.roomSize();
}
…to be written as follows:
switch (reverb.roomSize()) {
case 1 -> "Toilet";
case 2 -> "Bedroom";
case 30 -> "Classroom";
case int i -> "Unsupported int value: " + i;
}
This also allows guards to inspect the matched value, like so:
switch (reverb.roomSize()) {
case 1 -> "Toilet";
case 2 -> "Bedroom";
case 30 -> "Classroom";
case int i when i > 100 && i < 1000 -> "Cinema";
case int i when i > 5000 -> "Stadium";
case int i -> "Unsupported int value: " + i;
}
Record Patterns
Record patterns used to have limited support for primitive types. Recall that a record pattern decomposes a record into its individual components, but when one of them is a primitive type, the record pattern must be precise about its type. To illustrate this point, consider the following code example:
record Tuner(double pitchInHz) implements Effect {}
var tuner = new Tuner(440); // int argument is widened to double
// Attempt 1: record pattern match on int argument
if (tuner instanceof Tuner(int p)) {} // doesn't compile!
// Attempt 2: record pattern match on double argument
if (tuner instanceof Tuner(double p)) {
int pitch = p; // doesn't compile! needs a cast to int
}
// Attempt 3: record pattern match on double argument, cast to int
if (tuner instanceof Tuner(double p)) {
int pitch = (int) p;
}
To put it differently, the Java compiler widens the provided int to a double, but it doesn’t narrow it back to an int. This limitation exists because narrowing could lead to data loss: the value of the double at runtime might exceed the range of an int or have more precision than an int can accommodate. However, one significant advantage of pattern matching is its ability to automatically reject invalid values by not matching them at all. If the double component of a Tuner is either too large or too precise to safely convert back to an int, then instanceof Tuner(int p) would simply return false, allowing the program to manage the large double component in a different code branch.
This is analogous to how pattern matching currently behaves for reference type patterns. For example:
record SingleEffect(Effect effect) {}
var singleEffect = new SingleEffect(...);
if (singleEffect instanceof SingleEffect(Delay d)) {
// ...
} else if (singleEffect instanceof SingleEffect(Reverb r)) {
// ...
} else {
// ...
}
instanceof can be used here to try to match a SingleEffect with a Delay or a Reverb component; it automatically narrows if the pattern matches.
To summarize, the JEP proposes to make primitive type patterns work as smoothly as reference type patterns, allowing Tuner(int p) even if the corresponding record component is a numeric primitive type other than int.
Pattern Matching for instanceof
The Java 22 version of pattern matching for instanceof didn’t support primitive type patterns, but this capability would perfectly align with the purpose of instanceof: to test whether a value can be converted safely to a given type. To convert primitives safely, Java developers had to deal with lossy casts and range checks to prevent loss of information:
int roomSize = reverb.roomSize();
if (roomSize >= -128 && roomSize < 127) {
byte r = (byte) roomSize;
// now it's safe to use r
}
The JEP proposes the possibility to replace these constructs with simple instanceof checks that operate on primitives. Let’s rewrite the code example to make use of this feature:
int roomSize = reverb.roomSize();
if (roomSize instanceof byte r) {
// now it's safe to use r
}
The pattern roomSize instanceof byte r will match only if roomSize fits into a byte, eliminating the need for casts and range checks.
Primitive Types in instanceof
The instanceof keyword used to take a reference type only, and since Java 16 it can also take a type pattern.
But it would make sense to have instanceof take a primitive type also.
In that case instanceof would check if the conversion is safe but would not actually perform it:
if (roomSize instanceof byte) { // check if value of roomSize fits in a byte
... (byte) roomSize ... // yes, it fits! but cast is required
}
The JEP proposes to support this construct, which makes it easier to change the instanceof check to take a type pattern and vice versa.
Primitive Types in switch
The Java 22 version of the switch statement/expression supported byte, short, char, and int values.
The JEP proposes to also add support for the other primitive types: boolean, float, double and long.
A switch on a boolean value can be a good alternative for the ternary operator (?:), because its branches can also hold statements instead of just expressions.
String guitaristResponse = switch (guitar.isInTune()) {
case true -> "Ready to play a song.";
case false -> {
log.warn("Guitar is out of tune!");
yield "Let's take five!";
}
}
What’s Different From Java 26?
Compared to the fourth preview version of this feature in Java 26, nothing was changed or added.
Preview Warning
Note that this JEP is in the preview stage, so you’ll need to add the --enable-preview flag to the command-line to take the feature for a spin.
More Information
For more information on this feature, read JEP 532.
JEP 533: Structured Concurrency (Seventh Preview)
Java’s take on concurrency has always been unstructured, meaning that tasks run independently of each other. There’s no hierarchy, scope, or other structure involved, which means errors or cancellation intent is hard to communicate. To illustrate this, let’s look at a code example that takes place in a restaurant:
These code examples were taken from my conference talk “Java’s Concurrency Journey Continues! Exploring Structured Concurrency and Scoped Values”.
public class MultiWaiterRestaurant implements Restaurant {
@Override
public MultiCourseMeal announceMenu() throws ExecutionException, InterruptedException {
Waiter grover = new Waiter("Grover");
Waiter zoe = new Waiter("Zoe");
Waiter rosita = new Waiter("Rosita");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Course> starter = executor.submit(() -> grover.announceCourse(CourseType.STARTER));
Future<Course> main = executor.submit(() -> zoe.announceCourse(CourseType.MAIN));
Future<Course> dessert = executor.submit(() -> rosita.announceCourse(CourseType.DESSERT));
return new MultiCourseMeal(starter.get(), main.get(), dessert.get());
}
}
}
Note that the announceCourse(..) method in the Waiter class sometimes fails with an OutOfStockException, because one of the ingredients for the course might not be in stock. This can lead to some problems:
- If
zoe.announceCourse(CourseType.MAIN)takes a long time to execute butgrover.announceCourse(CourseType.STARTER)fails in the meantime, theannounceMenu(..)method will unnecessarily wait for the main course announcement by blocking onmain.get(), instead of cancelling it (which would be the sensible thing to do). - If an exception happens in
zoe.announceCourse(CourseType.MAIN),main.get()will throw it, butgrover.announceCourse(CourseType.STARTER)will continue to run in its own thread, resulting in thread leakage. - If the thread executing
announceMenu(..)is interrupted, the interruption will not propagate to the subtasks: all threads that run anannounceCourse(..)invocation will leak, continuing to run even afterannounceMenu()has failed.
Ultimately the problem here is that our program is logically structured with task-subtask relationships, but these relationships exist only in the mind of the developer. We might all prefer structured code that reads like a sequential story, but this example simply doesn’t meet that criterion.
In contrast, the execution of single-threaded code always enforces a hierarchy of tasks and subtasks, as shown by the single-threaded version of our restaurant example:
public class SingleWaiterRestaurant implements Restaurant {
@Override
public MultiCourseMeal announceMenu() throws OutOfStockException {
Waiter elmo = new Waiter("Elmo");
Course starter = elmo.announceCourse(CourseType.STARTER);
Course main = elmo.announceCourse(CourseType.MAIN);
Course dessert = elmo.announceCourse(CourseType.DESSERT);
return new MultiCourseMeal(starter, main, dessert);
}
}
Here, we don’t have any of the problems we had before. Our waiter Elmo will announce the courses in exactly the right order, and if one subtask fails the remaining one(s) won’t even be started. And because all work runs in the same thread, there is no risk of thread leakage.
It became apparent from these examples that concurrent programming would be a lot easier and more intuitive if enforcing the hierarchy of tasks and subtasks was possible, just like with single-threaded code.
Introducing Structured Concurrency
In a structured concurrency approach, threads have a clear hierarchy, their own scope, and clear entry and exit points. Structured concurrency arranges threads hierarchically, akin to function calls, forming a tree with parent-child relationships. Execution scopes persist until all child threads complete, matching code structure.
Shutdown on Failure
Let’s look at a structured, concurrent version of our example now:
public class StructuredConcurrencyRestaurant implements Restaurant {
@Override
public MultiCourseMeal announceMenu() throws ExecutionException, InterruptedException {
Waiter grover = new Waiter("Grover");
Waiter zoe = new Waiter("Zoe");
Waiter rosita = new Waiter("Rosita");
try (var scope = StructuredTaskScope.open()) {
Supplier<Course> starter = scope.fork(() -> grover.announceCourse(CourseType.STARTER));
Supplier<Course> main = scope.fork(() -> zoe.announceCourse(CourseType.MAIN));
Supplier<Course> dessert = scope.fork(() -> rosita.announceCourse(CourseType.DESSERT));
scope.join(); // 1
return new MultiCourseMeal(starter.get(), main.get(), dessert.get()); // 2
}
}
}
The scope’s purpose is to keep the threads together. At 1, we wait (join) until all threads are done with their work. If one of the threads is interrupted, an InterruptedException is thrown. A RuntimeException can also be thrown here, if an exception occurs in one of the spawned threads. Once we reach 2, we can be sure everything has gone well, and we can retrieve and process the results.
Actually, the main difference with the code we had before is the fact that we create threads (fork) within a new scope. Now we can be certain that the lifetimes of the three threads are confined to this scope, which coincides with the body of the try-with-resources statement.
Furthermore, we’ve gained short-circuiting behaviour. When one of the announceCourse(..) subtasks fails, the others are canceled if they didn’t complete yet. We’ve also gained cancellation propagation. When the thread that runs announceMenu() is interrupted before or during the call to scope.join(), all subtasks are cancelled automatically when the thread exits the scope.
Shutdown on Success
The factory method that gave us the scope (StructuredTaskScope.open()) implements a shutdown-on-failure policy by default, which cancels any remaining tasks in the scope if one of the tasks has failed. A shutdown-on-success policy is also available: it cancels any remaining tasks in the scope if one of the tasks has succeeded. It can be used to avoid doing unnecessary work when a successful result has already been achieved.
We can use a shutdown-on-success policy by calling an overload of the StructuredTaskScope.open() method that takes a Joiner as its parameter. Let’s see what that would look like:
record DrinkOrder(Guest guest, Drink drink) {}
public class StructuredConcurrencyBar implements Bar {
@Override
public DrinkOrder determineDrinkOrder(Guest guest) throws InterruptedException, ExecutionException {
Waiter zoe = new Waiter("Zoe");
Waiter elmo = new Waiter("Elmo");
try (var scope = StructuredTaskScope.open(Joiner.<DrinkOrder>anySuccessfulOrThrow())) {
scope.fork(() -> zoe.getDrinkOrder(guest, BEER, WINE, JUICE));
scope.fork(() -> elmo.getDrinkOrder(guest, COFFEE, TEA, COCKTAIL, DISTILLED));
return scope.join(); // 1
}
}
}
In this example the waiter is responsible for getting a valid DrinkOrder object based on guest preference and the drinks supply at the bar.
In the method Waiter.getDrinkOrder(Guest guest, DrinkCategory... categories), the waiter starts to list all available drinks in the drink categories that were passed to the method.
Once a guest hears something they like, they respond and the waiter creates a drink order. When this happens, the getDrinkOrder(..) method returns a DrinkOrder object and the scope will shut down.
This means that any unfinished subtasks (such as the one in which Elmo is still listing different kinds of tea) will be cancelled.
The join() method at 1 will either return a valid DrinkOrder object, or throw a RuntimeException if one of the subtasks has failed.
More Shutdown Policies
We’ve seen examples of two shutdown policies so far, but four more are provided out-of-the-box through the static factory methods in the StructuredTaskScope.Joiner interface. For example, Joiner.allSuccessfulOrThrow() will keep the scope alive until all subtasks have completed successfully, and cancels it if any subtasks fails. It’s also possible to create your own shutdown policies by implementing the Joiner interface. That will allow you to have full control over when the scope will be shut down and what results will be collected.
What’s Different From Java 26?
A few minor changes were made to the API compared to Java 26:
- The
StructuredTaskScopeandJoinerinterfaces now have a third type parameter,R_X, to enable the caller to influence the exception type that thejoin()method ofStructuredTaskScopecan throw. - A new static
openmethod inStructuredTaskScopetakes aUnaryOperatorto produce theStructuredTaskScopeconfiguration, allowing you to pass a custom configuration while still applying the default join policy. - The
Joinerfactory methodsallSuccessfulOrThrow(),anySuccessfulOrThrow(), andawaitAllSuccessfulOrThrow()now create joiners that causejoin()to throw anExecutionExceptionwhen the outcome is an exception. New overloads of the three methods allow aFunctionto be specified to produce a different exception, should you need a different one from the default. - The
Joinerfactory methodawaitAll()has been removed. - The
onTimeout()method of theJoinerinterface has been replaced by thetimeout()method, which either produces the result or throws an exception when the scope is cancelled by a timeout. If thetimeout()method throws an exception then the exception is thrown with aCancelledByTimeoutExceptionas the cause.
Preview Warning
Note that this JEP is in the preview stage, so you’ll need to add the --enable-preview flag to the command-line to take the feature for a spin.
More Information
JEP 533 has more details on the current state of this feature, should you wish to learn more.
JEP 537: Vector API (Twelfth Incubator)
The Vector API makes it possible to express vector computations that reliably compile at runtime to optimal vector instructions. This means that these computations will significantly outperform equivalent scalar computations on the supported CPU architectures (x64 and AArch64).
Vector Computations? Help Me Out Here!
A vector computation is a mathematical operation on one or more one-dimensional matrices of an arbitrary length. Think of a vector as an array with a dynamic length. Furthermore, the elements in the vector can be accessed in constant time via indices, just like with an array.
In the past, Java programmers could only program such computations at the assembly-code level. But now that modern CPUs support advanced SIMD features (Single Instruction, Multiple Data), it becomes more important to take advantage of the performance gains that SIMD instructions and multiple lanes operating in parallel can bring. The Vector API brings that possibility closer to the Java programmer.
Code Example
Here is a code example (taken from the JEP) that compares a simple scalar computation over elements of arrays with its equivalent using the Vector API:
void scalarComputation(float[] a, float[] b, float[] c) {
for (int i = 0; i < a.length; i++) {
c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
}
}
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
void vectorComputation(float[] a, float[] b, float[] c) {
int i = 0;
int upperBound = SPECIES.loopBound(a.length);
for (; i < upperBound; i += SPECIES.length()) {
// FloatVector va, vb, vc;
var va = FloatVector.fromArray(SPECIES, a, i);
var vb = FloatVector.fromArray(SPECIES, b, i);
var vc = va.mul(va)
.add(vb.mul(vb))
.neg();
vc.intoArray(c, i);
}
for (; i < a.length; i++) {
c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
}
}
From the perspective of the Java developer, this is just another way of expressing scalar computations. It might come across as being more verbose, but on the other hand it can bring spectacular performance gains.
Typical Use Cases
The Vector API provides a way to write complex vector algorithms in Java that perform extremely well, such as vectorized hashCode implementations or specialized array comparisons. Numerous domains can benefit from this, including machine learning, linear algebra, encryption, text processing, finance, and code within the JDK itself.
What’s Different From Java 26?
Compared to the eleventh incubator version of this feature in Java 26, no API changes were made.
The Vector API will keep incubating until necessary features of Project Valhalla become available as preview features. When that happens, the Vector API will be adapted to use them, and it will be promoted from incubation to preview.
More Information
For more information on this feature, read JEP 537.
JEP 538: PEM Encodings of Cryptographic Objects (Third Preview)
Within a Java context, cryptographic objects such as public keys, private keys and certificates can be easily created and distributed. But outside of the Java world, the de facto standard is the Privacy-Enhanced Mail (PEM) format. Let’s see an example of a PEM-encoded cryptographic object:
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEi/kRGOL7wCPTN4KJ2ppeSt5UYB6u
cPjjuKDtFTXbguOIFDdZ65O/8HTUqS/sVzRF+dg7H3/tkQ/36KdtuADbwQ==
-----END PUBLIC KEY-----
Prior to the introduction of this JEP and its predecessors, the Java Platform didn’t include an easy-to-use API for decoding and encoding text in the PEM format, which means that decoding a PEM-encoded key could be a tedious job that involved careful parsing of the source PEM text. For example, encrypting and decrypting a private key used to require over a dozen lines of code.
To solve this problem, JEP 538 introduces an API that can encode objects to the PEM format. It effectively acts as a bridge between Base64 and cryptographic objects. It involves a new interface and three new classes, in the java.security package:
BinaryEncodable- A sealed interface that groups together all cryptographic objects that support converting their instances to and from byte arrays in the Distinguished Encoding Rules (DER) format.
PEMEncoder- A class that declares methods for encoding
BinaryEncodableobjects into PEM text. PEMDecoder- A class that declares methods for decoding PEM text to
BinaryEncodableobjects. PEM- A class that implements
BinaryEncodable, which can hold any type of PEM data. It allows you to encode and decode PEM tests yielding cryptographic objects for which no Java representation currently exists.
Typical Usage
The following code example shows typical usage of the API:
PrivateKey privateKey = ...;
PublicKey publicKey = ...;
// let's encode a cryptographic object!
PEMEncoder pemEncoder = PEMEncoder.of();
// this returns PEM text in a byte array
byte[] privateKeyPem = pemEncoder.encode(privateKey);
// this returns PEM text in a String
String keyPairPem = pemEncoder.encodeToString(new KeyPair(publicKey, privateKey));
// this returns encrypted PEM text
String password = "java-first-java-always";
String pem = pemEncoder.withEncryption(password).encodeToString(privateKey);
// let's decode a cryptographic object!
PEMDecoder pemDecoder = PEMDecoder.of();
// this returns a DEREncodable, so we need to pattern-match
switch (pemDecoder.decode(pem)) {
case PublicKey publicKey -> ...;
case PrivateKey privateKey -> ...;
default -> throw new IllegalArgumentException("Unsupported cryptographic object");
}
// alternatively, if you know the type of the encoded cryptographic object in advance:
PrivateKey key = pemDecoder.decode(pem, PrivateKey.class);
// this decodes an encrypted cryptographic object
PrivateKey decryptedkey = pemDecoder.withDecryption(password).decode(pem, PrivateKey.class);
Preview Warning
Note that this JEP is in the preview stage, so you’ll need to add the --enable-preview flag to the command-line to take the feature for a spin.
What’s Different From Java 26?
A few minor changes were made to the API compared to Java 26:
- The
PEMclass is now an ordinary class rather than a record. It includes constructors that accept Base64-encoded content in byte arrays, which is more convenient for some use cases. - The
DEREncodableinterface is now namedBinaryEncodable, to more accurately describe the binary data stored in PEM text. - The
EncryptedPrivateKeyInfoclass now includesgetKeyPairmethods that decrypt PKCS#8-encoded text containing aPublicKey. - The
getKeyandgetKeyPairmethods ofEncryptedPrivateKeyInfothat took a password andProvidernow take only aKey. - The
withFactorymethod ofPEMDecoderis now namedwithFactoriesOfto better describe that key and certificate factories are obtained from the givenProvider. - A new
CryptoExceptionclass indicates failures in cryptographic processing at runtime.
More Information
For more information on this feature, see JEP 538.
New Defaults
Two JEPs in Java 27 make features that were introduced earlier the default.
JEP 523: Make G1 the Default Garbage Collector in All Environments
The G1 garbage collector has been designed to provide high performance and low pause times for applications with large heaps. It divides the heap into regions and prioritizes garbage collection in regions with the most garbage, hence the name “Garbage-First.” G1 aims to achieve predictable pause times by performing most of its work concurrently with the application threads, minimizing the impact on application performance.
G1 has been Java’s default garbage collector in server environments since Java 9. Back then, testing showed that the Serial garbage collector had significant advantages in throughput and footprint in constrained environments with a single CPU or limited available physical memory. And so the decision was made to select Serial as the default garbage collector in those environments.
Since then, the performance of G1 has improved steadily. With the latest performance improvements (reduced synchronization) G1’s performance is now sufficient to replace Serial in all situations, including the aforementioned constrained enviroment.
What’s Different From Java 26?
In constrained environments with a single CPU or less than 1792 MB of physical memory, the JVM used to select Serial. From Java 26 onwards, G1 will be chosen as default in all environments, including constrained ones.
JEP 534: Compact Object Headers by Default
JDK 24 introduced compact object headers as an experimental feature, which enabled a reduction of the object header size to 64 bits and, with it, a significant decrease of memory footprint and garbage collection pressure. Since then, compact object headers have proven their stability and performance. They have been tested at Oracle, Amazon, and SAP. Various experiments have demonstrated that enabling compact object headers improves performance, and that it is time to make compact object headers the default:
- In one setting, the SPECjbb2015 benchmark used 22% less heap space and 8% less CPU time.
- In another setting, the number of garbage collections done by SPECjbb2015 was reduced by 15%, with both the G1 and Parallel collectors.
- A highly parallel JSON parser benchmark ran in 10% less time.
What’s Different From Java 26?
In Java 26, compact object headers could only be enabled via a command-line option:
$ java -XX:+UseCompactObjectHeaders ...
In Java 27, compact object headers are the default, and no longer require a command-line option to use. You can disable the feature by running with:
$ java -XX:-UseCompactObjectHeaders ...
Note that the old object header layout will be deprecated in a future release.
More Information
For more information on this feature, see JEP 534.
Final thoughts
And that concludes our discussion of the JEPs that come with Java 27. But that’s not even all that’s new: many other updates were included in this release, including various minor performance, stability and security updates. One thing is for sure: this version of Java is in top shape. So what are you waiting for? It’s time to take this brand-new Java release for a spin!