All you need to know about Java21
Pratik Dwivedi
October 20, 2023

The Java community is buzzing once again, this time due to the release of Java 21. We believe that Java 21 boasts several groundbreaking and uniquely valuable features and I want to highlight these features in this post.

Both seasoned Java developers and newcomers will find this post informative and educational.

Java 21's release is driven by a dual mission: not only to deliver a multitude of performance, security, and stability enhancements but also to realign the language with the evolving requirements of modern applications. Java adapts to the changing landscape and the expectations surrounding it to maintain its agility and relevance.

Some noteworthy features, highly relevant to contemporary programming and application needs, include:

A. Scoped Values

Thread-local variables have two main purposes. First, they are connected to a specific thread and can only be accessed by that thread or any threads created by it. Second, they remove the need for synchronization. These variables are typically used to share information or context between threads that come from the same parent. Without thread-local variables, you would have to pass extra object parameters to functions to carry information about the state, session, or context.

However, using thread-local variables brings some problems as applications become more complex and handle multiple requests at the same time:

  1. Mutability: Thread-local variables can be changed, and it can be tricky to figure out which methods modified them and in what order.
  2. Potentially Unbounded Lifetime: If you forget to remove a thread-local variable, its value can persist for any thread that uses it until that thread ends. This can lead to data leaking into unrelated tasks, especially when using a thread pool.
  3. Memory Leaks: Thread data isn't garbage collected until the thread ends. If multiple threads share a thread-local variable, it can be challenging to know when to safely remove it without affecting other threads.
  4. Heavy Memory Usage: When you create many child threads, they inherit all the thread-local variables from their parent, which can lead to a significant memory footprint.

Additionally, the introduction of virtual threads has made these issues more pronounced, especially because virtual threads allow you to create many threads that share the same operating system thread, making their usage more complex.

Here is an example to clarify:

Imagine we have a graphics library with a general <span  class="pink" >drawShape()</span> function that paints closed figures by handling each of their sides. There are specific versions of this method optimized for drawing certain closed figures like a rhombus (painting one side and then repeating for the opposite side) or a square (painting just one side and repeating this process three times).

In this code snippet, there's a concept called <span  class="pink" >ScopedValue</span>, and we're using it to manage the figure type during drawing:

private static final ScopedValue<String> shape = ScopedValue.newInstance();

void drawShape() {
    ScopedValue.where(shape, "quadrilateral").run(() -> drawQuad());

void drawQuad() {
    drawShape(shape.get()); // draws all 4 sides independently
    ScopedValue.where(shape, "rhombus").run(() -> drawRhombus() );
    System.out.println(shape.get()); // prints "quadrilateral"

drawRhombus() {
     if (sideA == sideB == sideC == sideD) {
         ScopedValue.where(X, "square").run(() -> drawSquare() );
        drawSideAndRepeatTwice(shape.get()); // draws 1 side and repeats the process for the opposite side, then repeats the same for the other set of parallel and equal sides
   System.out.println(shape.get()); // prints "rhombus"

drawSquare() {
    drawSideAndRepeat(shape.get()); // draws 1 side and repeats the process
   System.out.println(shape.get()); // prints "square"

In this code, the <span  class="pink" >shape</span> variable holds the figure type, and it changes within different scopes as we draw different figures. Scoped values can be inherited by the method they call, and they allow for communicating different values to their own callees. This feature of inheritance and rebinding makes <span  class="pink" >ScopedValue</span> a valuable programming construct.

When to use <span  class="teal" >ThreadLocal</span>:Now, you might be wondering when to use <span  class="pink" >ThreadLocal</span>. It's more suitable for inherently mutable classes like <span  class="pink" >Java.util.DateM</span>, <span  class="pink" >StringBuilder</span>, <span  class="pink" >StringBuffer</span>, etc. These classes can't be shared between threads without synchronization. So, using ThreadLocal to provide each thread with its own mutable object, which persists throughout the thread's lifetime, is a practical approach. It removes the need for writing synchronization code and is safer and more efficient.

The above code example shows how <span  class="pink" >ScopedValue</span> can manage the type of figure being drawn, and <span  class="pink" >ThreadLocal</span> is preferable in situations involving inherently mutable classes for thread safety.

B. Key Encapsulation Mechanisms (KEMs)

Java21 introduces Key Encapsulation Mechanisms (KEMs), as the preferred approach over Authenticated Key Exchanges (AKE) within Public Key Encryption (PKE) schemes.

In the older key exchange algorithm, the sender encrypts the message using their private key, and the receiver decrypts it with the sender's publicly available key. However, this approach is no longer considered secure, as quantum computers can compromise most key exchange algorithms, rendering AKEs vulnerable.

In the KEM scheme, in addition to the public-private key pair, the sender and receiver mutually agree upon an additional "secret value." The sender then transmits this "secret value" to the receiver in an encrypted form. Only the receiver possesses the capability to decrypt this "secret value" and put it to use.

Here's a breakdown of the KEM process: The sender initially leverages the public key and an encryption function to invoke the key encapsulation, which subsequently yields a secret key (referred to as K) and a key encapsulation message (termed ciphertext in ISO 18033-2). The sender then dispatches this key encapsulation message to the receiver. On the receiver's side, they acquire this encapsulated data and apply a key decapsulation function employing their private key along with the received key encapsulation message. This process unveils the secret key K, which the receiver employs to decrypt further messages.

In essence, an additional "secret value" is exchanged between the two parties alongside the PKEs, bolstering the security of the process. Previously, support for KEMs was primarily provided by third-party providers such as BouncyCastle. However, Java now offers a standardized mechanism for KEMs as well.

In the following example, we employ the Diffie-Hellman-based KEM, which uses the receiver's Diffie-Hellman or elliptic curve Diffie-Hellman public key to create encapsulation, thus enforcing the Key Encapsulation Mechanisms in the ensuing data exchange.

Here is a code snippet to explain the above steps

1. The receiver generates a keypair and hands over his public key to the sender

// Receiver side 
import javax.crypto.*; 

KeyPairGenerator receiverKPG = KeyPairGenerator.getInstance("X25519");
KeyPair receiverKP = receiverKPG .generateKeyPair();
PublicKey receiverPublicKey = receiverKP.getPublic();
PrivateKey receiverPrivateKey = receiverKP.getPrivate();

// Now send this to the prospective Sender 	
try {
            Socket socket = new Socket();
            socket.connect(new InetSocketAddress(senderHostName, senderPortNumber), 5000);
            ObjectOutputStream objectOutputStream = new ObjectOutputStream(

            logger.trace("sent public key"); 
catch (Exception e ){

2. The sender generates an encapsulation, using the receiver's public key

Note that on the sender side, we use DHKEM, an advanced version of the Diffie-Hellman algorithm. Earlier, we used X25519( or id-kema-dhkem-x25519) to generate a keypair, per IETF spec.

import javax.crypto.spec.DHParameterSpec; 
import javax.crypto.spec.*; 
import javax.crypto.KEM; 

//Let's use the default KEM class  provided by Java21, which offers the functionality of a Key Encapsulation Mechanism (KEM). 
KEM kemS = KEM.getInstance("DHKEM");
PublicKey receiverPublicKey = retrieveKey(); // read it from the socket 

//Now, construct a parameter set for Diffie-Hellman, using a prime modulus p and a base generator g.
DHParameterSpec specS = new DHParameterSpec(BigInteger p, BigInteger g);
KEM.Encapsulator e = kemS.newEncapsulator(receiverPublicKey, specS, null); 

//Each invocation of the encapsulate method generates a secret key and key encapsulation message that is returned in an KEM.  
KEM.Encapsulated senderENC = e.encapsulate();
// Now our encapsulated object "senderENC" contains the shared secret, key encapsulation message, and optional parameters. 
SecretKey secretKeySender = senderENC.key();

3. The receiver receives the encapsulation, and uses their private key to decapsulate the received packet, in order to know the "secret value".

// Receiver side 
import javax.crypto.spec.DHParameterSpec; 
import javax.crypto.spec.*; 
import javax.crypto.KEM;  

byte[] encapsulation = receiveBytes(); // just sent using sendBytes() above 
byte[] params = receiveBytes();
KEM kemReceiver = KEM.getInstance("DHKEM");
AlgorithmParameters algParams = AlgorithmParameters.getInstance("DHKEM");
ABCKEMParameterSpec specR = algParams.getParameterSpec(DHParameterSpec.class);
KEM.Decapsulator receiverDCAP = kemReceiver.newDecapsulator(receiverKP.getPrivate(), specR);
SecretKey secretKeyReceiver = receiverDCAP.decapsulate(encapsulation); 

// Now, secretKeySender and secretKeyReceiver will be the same , and can be used for further secure communications via encapsulation.

4. All further communication between the receiver and the sender will use the "secret value"

You can also first use KEM's to only transmit the "secret value", and later data exchanges can work on the older PKE schemes, assuming that every decryption at the receiver's end will also use the "secret value" for additional privacy.

Here is an example where the sender and receiver can implement a secure file exchange:

//Sender side 
public void encryptFile(byte[] input, File output, SecretKey secretKeySender) throws IOException, GeneralSecurityException {
		this.cipher.init(Cipher.ENCRYPT_MODE, secretKeySender);
		writeToFile(output, this.cipher.doFinal(input));
	} // The file is now encrypted using the KEM key 

private void writeToFile(File output, byte[] toWrite) throws IllegalBlockSizeException, BadPaddingException, IOException {
		FileOutputStream fos = new FileOutputStream(output);

	public byte[] getFileInBytes(File f) throws IOException {
		FileInputStream fis = new FileInputStream(f);
		byte[] fbytes = new byte[(int) f.length()];;
		return fbytes;
	} // Accept the file from the Sender 

   getFileInBytes(new File receivedFile)  ; 

public void decryptFile(receivedFile , File receivedFileDecrypted, SecretKey secretKeyReceiver)  throws IOException, GeneralSecurityException {
		this.cipher.init(Cipher.DECRYPT_MODE, secretKeyReceiver);
		writeToFile(output, this.cipher.doFinal(receivedFileDecrypted)); 
	} // Decrypt the file using his own copy of the Secret Key

The file named <span  class="pink" >receivedFileDecrypted</span> has the information that was shared by the sender.

Even if someone is eavesdropping or knows the private keys of either sender or receiver, they have no way to break this encryption if the "Secret key" has already been shared using the KEM mechanisms.

C. Simplification - Unnamed Classes and Instance Main Methods

While some programming constructs are valuable in organizing large projects and team collaborations, they may seem overly complex for beginners or small programs. For instance, consider the iconic "Hello, World!" program:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");

For beginners, concepts like access control (public/private), the static modifier, void return type, and enclosing everything within a class named "HelloWorld" can be confusing. Often, explanations like "we'll discuss it later, for now, just accept it" further mystify newcomers.

Java21 introduces a new feature that allows you to write small programs without the need to define a class, specify parameters and data types, or access control details. For example, the classic "Hello, World!" program can be simplified as follows:

void main() {
    System.out.println("Hello, World!");

In this simplified version, class declarations are implicit, and method parameters and access control modifiers don't need explicit declarations. There's no requirement for encapsulation, namespaces, modularization, or scoping. What truly matters are variables, control flow, and subroutines. Internally, Java21 still encapsulates the code within an implicit class/package, but beginners don't need to worry about it. This concept is known as "unnamed classes" and "instance-based main() methods."

This simplification makes programming enjoyable for novice programmers, and as they gain experience, they can gradually transition to more complex structures.

Experienced programmers can also benefit from this feature when writing small utilities, performing bridging, or testing. Keep in mind that other named classes can't access this unnamed class, and the unnamed class is final and can't extend/implement other classes/interfaces. However, it provides a clear example of how to use the fundamental building blocks to execute a simple Java program.

To enable this feature, compile the program using:

javac --release 21 --enable-preview 

And run it with:

java --enable-preview MyFirst 

You can save the code in a source file named "," and then launch this unnamed class using the source-code launcher:


Pretty cool, right? 😎

BTW, if you are using Java 17 or below, do check out Direct Invoke from Unlogged, you can call any Java method directly from your IDE.

D. Enhanced Garbage Collector: Generational ZGC

Java was initially designed to be platform-independent, object-oriented, and efficient for multi-threaded server applications. As technology advanced, modern applications evolved to handle high-throughput workloads, demanding low latency and scalability. This evolution called for a more advanced garbage collector to efficiently manage system resources, conserving CPU cycles and memory.

Generational ZGC, part of the Z Garbage Collector, was first made available for production use in JDK 15 and has now been promoted from "Targeted" to "Completed" status in JDK 21. Generational ZGC operates on the principle that most objects have short lifespans, known as the weak generational hypothesis. Young objects have brief lifespans, while older objects endure longer. This theory informs the practical implementation, optimizing the collection of younger objects for more memory and older objects with fewer resources.

Generational ZGC divides the heap into two logical generations: the young generation for recently allocated objects and the old generation for long-lived objects. Both generations are collected independently, focusing on quickly collecting younger objects. Generational ZGC follows several design principles to ensure low latency and high scalability:

  • No Multi-Mapped Memory: Instead of using multi-mapped memory as older garbage collectors did, Generational ZGC employs explicit code in load and store barriers. This approach allows for more metadata bits in colored pointers and the potential expansion of the maximum heap size beyond the 16-terabyte limit of non-generational ZGC.
  • Optimized Barriers: ZGC uses highly optimized store+load barriers to maximize throughput. These barriers include Fast-Path (checks if additional work is needed before the referenced object is used) and Slow-Path (performs additional work if needed). For objects in the old generation referencing objects in the young generation, remembered sets are used to streamline the process.
  • Double-Buffered Remembered Sets and Relocations: Generational ZGC divides memory relocation into two passes: marking and relocation. This approach allows for relocation without consuming a portion of the heap memory.
  • Dense Heap Regions: Generational ZGC recognizes densely populated regions in the young generation. These regions are marked as dense and not evacuated promptly, saving resources.
  • Large Objects: Large objects are initially placed in the young generation. If they have short lifespans, they are collected with the young generation. Otherwise, they are promoted to the old generation.
  • Concurrent Garbage Collections: Young-generation collection can run concurrently with the old-generation marking phase.

To use Generational ZGC, add the -XX:+ZGenerational option to the -XX:+UseZGC command-line option. Generational ZGC will become the default in future releases, with non-generational ZGC eventually being removed.

$ java -XX:+UseZGC -XX:+ZGenerational 

Additionally, this release introduces other features such as Structured Concurrency (in preview), preparing to disallow the dynamic loading of agents (proposal), and Vector API (still in incubation). These features enhance Java's capabilities and performance.

Please note that the text provides an overview of the key points without delving into all the technical details.

We have not gone into much detail about some other features like:-

  • Structured Concurrency (Preview) - Structured concurrency was previously incubated in JDK 19, the only significant change this time is that the StructuredTaskScope::fork(...) method returns a [Subtask] rather than a Future. Since it's still in the preview stage and an evolved topic on its own,  we will discuss it in a future post that primarily focuses on Structured Concurrency only.
  • Prepare to Disallow the Dynamic Loading of Agents - it is in proposal state only, and its aim is to prepare users for a future release that would disallow the dynamic loading of agents by default, with a view to improving integrity.
  • Vector API - still in its sixth incubation phase, on supported CPU configurations, the Vector APIs allow users to express vector computations that reliably compile at runtime to optimal vector instructions, thus giving a performance boost over scalar computations.

I am preparing some content around these points, stay tuned for my next blog post.

Pratik Dwivedi
October 20, 2023
Begin your shift-left testing journey, now!