PolarSPARC

Virtual Threads in Java 21


Bhaskar S 11/18/2023


In a typical Java business application that involves multiple steps to be executed concurrently, one could leverage the Java platform Threads. These units of execution are often referred to as the Platform Threads. Under the hood, a Platform Thread is implemented as a thin wrapper around an OS thread and is the smallest unit of processing that can be scheduled by the OS.

As an example, consider a simple scenario of a business application executing two services to accomplish a business goal.

For the demonstration, let the following Java class represents the first business service:


ServiceOne.java
/*
 * Description: Service One
 * Author:      Bhaskar S
 * Date:        11/17/2023
 * Blog:        https://www.polarsparc.com
 */

import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;

public final class ServiceOne {
    private static Logger LOGGER = null;

    static {
        System.setProperty("java.util.logging.SimpleFormatter.format",
                "%1$ta %1$tb %1$td %1$tH:%1$tM:%1$tS.%1$tL %1$tZ %1$tY [%4$s] <%2$s> - %5$s %n");
        LOGGER = Logger.getLogger(ServiceOne.class.getName());
        LOGGER.setLevel(Level.INFO);
    }

    private ServiceOne() {}

    public static void getServiceOneDetails(String id) {
        LOGGER.log(Level.INFO, "Ready to fetch details for -> " + id);

        TimeUnit time = TimeUnit.MILLISECONDS;
        try {
            time.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        LOGGER.log(Level.INFO, "Done fetching details for -> " + id);
    }
}

For the demonstration, let the following Java class represents the second business service:


ServiceTwo.java
/*
 * Description: Service Two
 * Author:      Bhaskar S
 * Date:        11/17/2023
 * Blog:        https://www.polarsparc.com
 */

import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;

public final class ServiceTwo {
    private static Logger LOGGER = null;

    static {
        System.setProperty("java.util.logging.SimpleFormatter.format",
                "%1$ta %1$tb %1$td %1$tH:%1$tM:%1$tS.%1$tL %1$tZ %1$tY [%4$s] <%2$s> - %5$s %n");
        LOGGER = Logger.getLogger(ServiceOne.class.getName());
        LOGGER.setLevel(Level.INFO);
    }

    private ServiceTwo() {}

    public static void getServiceTwoDetails(String id, boolean flag) {
        LOGGER.log(Level.INFO, "Ready to fetch details for -> " + id);

        TimeUnit time = TimeUnit.MILLISECONDS;
        try {
            time.sleep(1250);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        if (flag) {
            throw new RuntimeException("ServiceTwo timed out !!!");
        }

        LOGGER.log(Level.INFO, "Done fetching details for -> " + id);
    }
}

For the first demonstration, the following Java class leverages two Java Platform Threads to concurrently execute the two business services:


Sample_1.java
/*
 * Description: Sample One
 * Author:      Bhaskar S
 * Date:        11/17/2023
 * Blog:        https://www.polarsparc.com
 */

import java.util.logging.Level;
import java.util.logging.Logger;

public class Sample_1 {
    private static final Logger LOGGER;

    static {
        System.setProperty("java.util.logging.SimpleFormatter.format",
                "%1$ta %1$tb %1$td %1$tH:%1$tM:%1$tS.%1$tL %1$tZ %1$tY [%4$s] <%2$s> - %5$s %n");
        LOGGER = Logger.getLogger(Sample_1.class.getName());
        LOGGER.setLevel(Level.INFO);
    }

    public static void main(String[] args) {
        LOGGER.info(">>> Happy Path");

        // Happy Path
        Thread ts1 = new Thread(() -> ServiceOne.getServiceOneDetails("XYZ"));
        Thread ts2 = new Thread(() -> ServiceTwo.getServiceTwoDetails("XYZ", false));

        ts1.start();
        ts2.start();

        try {
            ts1.join();
            ts2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        LOGGER.info(">>> Failure Path");

        // Failure Path
        Thread tsf1 = new Thread(() -> ServiceOne.getServiceOneDetails("XYZ"));
        Thread tsf2 = new Thread(() -> ServiceTwo.getServiceTwoDetails("XYZ", true));

        tsf1.start();
        tsf2.start();

        try {
            tsf1.join();
            tsf2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

Executing the above code will generate the following output:


Output.1

Fri Nov 17 20:34:26.431 EST 2023 [INFO] <Sample_1 main> - >>> Happy Path 
Fri Nov 17 20:34:26.462 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 20:34:26.462 EST 2023 [INFO] <ServiceTwo getServiceTwoDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 20:34:27.462 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Done fetching details for -> XYZ 
Fri Nov 17 20:34:27.713 EST 2023 [INFO] <ServiceTwo getServiceTwoDetails> - Done fetching details for -> XYZ 
Fri Nov 17 20:34:27.714 EST 2023 [INFO] <Sample_1 main> - >>> Failure Path 
Fri Nov 17 20:34:27.715 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 20:34:27.715 EST 2023 [INFO] <ServiceTwo getServiceTwoDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 20:34:28.716 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Done fetching details for -> XYZ 
Exception in thread "Thread-3" java.lang.RuntimeException: ServiceTwo timed out !!!
  at ServiceTwo.getServiceTwoDetails(ServiceTwo.java:35)
  at Sample_1.lambda$main$3(Sample_1.java:42)

When an application encounters failures, the Platform Thread does dump the Stack Trace which typically points to the source of the fault.

Note that when a Platform Thread is created, it is bound to the underlying OS thread for the lifetime of the Platform Thread. More importantly, there are only a limited number of OS threads in a system. This implies we have to be careful and judicious when using the Platform Threads.

Given that the OS threads are a scare resource in a system, is there a different way in building applications that have a concurrency need ??? This leads one to use the Asynchronous style of programming using the CompletableFuture option in Java.

For the second demonstration, the following Java class leverages two Java CompletableFuture instances to asynchronously (and concurrently) execute the two business services:


Sample_2.java
/*
          * Description: Sample Two
 * Author:      Bhaskar S
 * Date:        11/17/2023
 * Blog:        https://www.polarsparc.com
 */

import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
import java.util.logging.Logger;

public class Sample_2 {
    private static final Logger LOGGER;

    static {
        System.setProperty("java.util.logging.SimpleFormatter.format",
                "%1$ta %1$tb %1$td %1$tH:%1$tM:%1$tS.%1$tL %1$tZ %1$tY [%4$s] <%2$s> - %5$s %n");
        LOGGER = Logger.getLogger(Sample_2.class.getName());
        LOGGER.setLevel(Level.INFO);
    }

    public static void main(String[] args) {
        LOGGER.info(">>> Happy Path");

        // Happy Path
        CompletableFuture<Void> cfs1 = CompletableFuture.runAsync(() ->
                ServiceOne.getServiceOneDetails("XYZ"));

        CompletableFuture<Void> cfs2 = CompletableFuture.runAsync(() ->
                ServiceTwo.getServiceTwoDetails("XYZ", false));

        CompletableFuture<Void> cf = CompletableFuture.allOf(cfs1, cfs2);

        cf.join();

        LOGGER.info(">>> Failure Path");

        // Failure Path
        CompletableFuture<Void> cfs3 = CompletableFuture.runAsync(() ->
                ServiceOne.getServiceOneDetails("XYZ"));

        CompletableFuture<Void> cfs4 = CompletableFuture.runAsync(() ->
                ServiceTwo.getServiceTwoDetails("XYZ", true));

        CompletableFuture<Void> cff = CompletableFuture.allOf(cfs3, cfs4);

        cff.join();
    }
}

Executing the above code will generate the following output:


Output.2

Fri Nov 17 20:47:31.676 EST 2023 [INFO] <Sample_2 main> - >>> Happy Path 
Fri Nov 17 20:47:31.715 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 20:47:31.715 EST 2023 [INFO] <ServiceTwo getServiceTwoDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 20:47:32.715 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Done fetching details for -> XYZ 
Fri Nov 17 20:47:32.966 EST 2023 [INFO] <ServiceTwo getServiceTwoDetails> - Done fetching details for -> XYZ 
Fri Nov 17 20:47:32.967 EST 2023 [INFO] <Sample_2 main> - >>> Failure Path 
Fri Nov 17 20:47:32.967 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 20:47:32.968 EST 2023 [INFO] <ServiceTwo getServiceTwoDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 20:47:33.968 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Done fetching details for -> XYZ 
Exception in thread "main" java.util.concurrent.CompletionException: java.lang.RuntimeException: ServiceTwo timed out !!!
  at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:315)
  at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:320)
  at java.base/java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1807)
  at java.base/java.util.concurrent.CompletableFuture$AsyncRun.exec(CompletableFuture.java:1796)
  at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
  at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
  at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
  at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
  at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)

When an asynchronous code encounters failures, it becomes very challenging to reason with and troubleshoot, since the concurrent services (tasks) are often scheduled on one Platform Thread to start with, and when they block at some point later, may be resumed and executed on another Platform Thread. Since every Platform Thread has its own stack, when an exception is thrown, the stacktrace will not be able to capture everything that happened since the beginning, as it is on a different Platform Thread than the one it started on originally.

The asynchronous style of programming using CompletableFuture is sleek and powerful, however hard to debug during failures. We like the use of the Platform Threads since they are easy to troubleshoot during failures, but a scarce resource. Is there any other option available for us ???

YES there is !!! With the release of Java 21, we now have access to Virtual Threads.

A Virtual Thread in Java 21 is a very lightweight thread that helps reduce the effort of creating, maintaining, and debugging any business application with concurrency needs.

Note that both the Platform Threads and the Virtual Threads in Java 21 are instances of the same Java language class java.lang.Thread.

For the third demonstration, the following Java class shows the basic creation and usage of both the Platform Thread as well as the Virtual Thread:


Sample_3.java
/*
 * Description: Sample Three
 * Author:      Bhaskar S
 * Date:        11/17/2023
 * Blog:        https://www.polarsparc.com
 */

import java.time.Duration;
import java.util.logging.Level;
import java.util.logging.Logger;

public class Sample_3 {
    private static final Logger LOGGER;

    static {
        System.setProperty("java.util.logging.SimpleFormatter.format",
                "%1$ta %1$tb %1$td %1$tH:%1$tM:%1$tS.%1$tL %1$tZ %1$tY [%4$s] <%2$s> - %5$s %n");
        LOGGER = Logger.getLogger(Sample_3.class.getName());
        LOGGER.setLevel(Level.INFO);
    }

    public static void main(String[] args) {
        Thread.Builder pBuilder = Thread.ofPlatform().name("Platform Thread");
        Thread.Builder vBuilder = Thread.ofVirtual().name("Virtual Thread");

        Runnable task = () -> {
            try {
                Thread.sleep(Duration.ofSeconds(1));
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            LOGGER.info(Thread.currentThread().getName() + " - Runnable Task");
        };

        // Run the task using the Platform Thread
        Thread pt = pBuilder.start(task);

        LOGGER.info("Thread name - " + pt.getName());

        // Run the task using the Virtual Thread
        Thread vt = vBuilder.start(task);

        LOGGER.info("Thread name - " + vt.getName());

        try {
            pt.join();
            vt.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

Executing the above code will generate the following output:


Output.3

Fri Now 17 21:03:25.034 EST 2023 [INFO] <Sample_3 main> - Thread name - Platform Thread 
Fri Now 17 21:03:25.069 EST 2023 [INFO] <Sample_3 main> - Thread name - Virtual Thread 
Fri Now 17 21:03:26.034 EST 2023 [INFO] <Sample_3 lambda$main$0> - Platform Thread - Runnable Task 
Fri Now 17 21:03:26.070 EST 2023 [INFO] <Sample_3 lambda$main$0> - Virtual Thread - Runnable Task

Just like a Platform Thread, a Virtual Thread also runs the application code on a OS thread, however, when the Virtual Thread encounters any kind of blocking operation, the Java Runtime suspends the Virtual Thread and releases the OS thread, so that the other Virtual Threads can use it.

The Virtual Threads can be used for building high-throughput concurrent applications in which the business services (or tasks) perform some kind of blocking operations.

Under the hood, the Java Runtime implements the Virtual Threads using an M:N scheduler, where a large number (M number) of Virtual Threads can be scheduled to run on a smaller number (N number) of OS threads.

Note that a Platform Thread consumes a LOT of system resources in the Java Virtual Machine as well as the OS kernel. This is the reason why we have a limited number of OS threads in a system and consequently the Platform Threads. On the other hand, Virtual Threads is very lightweight and hence one can create a lot of them.

The following are a brief description for some of the APIs in the demonstration code above:

For the fourth demonstration, the following Java class shows the lightfulness of the Virtual Thread by creating and executing 100,000 Virtual Threads:


Sample_4.java
/*
 * Description: Sample Four
 * Author:      Bhaskar S
 * Date:        11/17/2023
 * Blog:        https://www.polarsparc.com
 */

import java.util.ArrayList;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.IntStream;

public class Sample_4 {
    private static final Logger LOGGER;

    static {
        System.setProperty("java.util.logging.SimpleFormatter.format",
                "%1$ta %1$tb %1$td %1$tH:%1$tM:%1$tS.%1$tL %1$tZ %1$tY [%4$s] <%2$s> - %5$s %n");
        LOGGER = Logger.getLogger(Sample_4.class.getName());
        LOGGER.setLevel(Level.INFO);
    }

    public static void main(String[] args) {
        Thread.Builder vBuilder = Thread.ofVirtual().name("vt_worker");

        Runnable vt_task = () -> LOGGER.info(System.nanoTime() + ": " +
                Thread.currentThread().getName() + " - Runnable Virtual Task");

        // Run the task using the Virtual Threads
        int threadsCount = 100_000;
        ArrayList<Thread> threadsList = new ArrayList<>(threadsCount);
        IntStream.range(1, threadsCount+1).forEach(i -> {
            Thread vt = vBuilder.start(vt_task);
            threadsList.add(vt);
        });

        // Wait for the Virtual Threads to finish
        threadsList.forEach(vt -> {
            try {
                vt.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
    }
}

Executing the above code will generate the following trimmed output:


Output.4

Fri Nov 17 21:24:44.974 EST 2023 [INFO] <Sample_4 lambda$main$0> - 19913304827143: vt_worker - Runnable Virtual Task 
Fri Nov 17 21:24:44.974 EST 2023 [INFO] <Sample_4 lambda$main$0> - 19913304827343: vt_worker - Runnable Virtual Task 
Fri Nov 17 21:24:44.974 EST 2023 [INFO] <Sample_4 lambda$main$0> - 19913304827703: vt_worker - Runnable Virtual Task 
Fri Nov 17 21:24:44.974 EST 2023 [INFO] <Sample_4 lambda$main$0> - 19913304827943: vt_worker - Runnable Virtual Task 
Fri Nov 17 21:24:44.974 EST 2023 [INFO] <Sample_4 lambda$main$0> - 19913304828013: vt_worker - Runnable Virtual Task 
...SNIP...
Fri Nov 17 21:24:44.982 EST 2023 [INFO] <Sample_4 lambda$main$0> - 19913312467256: vt_worker - Runnable Virtual Task 
Fri Nov 17 21:24:44.982 EST 2023 [INFO] <Sample_4 lambda$main$0> - 19913312467276: vt_worker - Runnable Virtual Task 
Fri Nov 17 21:24:44.982 EST 2023 [INFO] <Sample_4 lambda$main$0> - 19913312467576: vt_worker - Runnable Virtual Task 
Fri Nov 17 21:24:44.982 EST 2023 [INFO] <Sample_4 lambda$main$0> - 19913312467896: vt_worker - Runnable Virtual Task 
Fri Nov 17 21:24:44.982 EST 2023 [INFO] <Sample_4 lambda$main$0> - 19913312176879: vt_worker - Runnable Virtual Task

For the final demonstration, the following Java class leverages two Virtual Threads to concurrently execute the two business services:


Sample_5.java
/*
 * Description: Sample Five
 * Author:      Bhaskar S
 * Date:        11/17/2023
 * Blog:        https://www.polarsparc.com
 */

import java.util.logging.Level;
import java.util.logging.Logger;

public class Sample_5 {
    private static final Logger LOGGER;

    static {
        System.setProperty("java.util.logging.SimpleFormatter.format",
                "%1$ta %1$tb %1$td %1$tH:%1$tM:%1$tS.%1$tL %1$tZ %1$tY [%4$s] <%2$s> - %5$s %n");
        LOGGER = Logger.getLogger(Sample_5.class.getName());
        LOGGER.setLevel(Level.INFO);
    }

    public static void main(String[] args) {
        LOGGER.info(">>> Happy Path");

        Thread.Builder vBuilder = Thread.ofVirtual().name("Virtual Threads");

        // Happy Path
        Thread vts1 = vBuilder.start(() -> ServiceOne.getServiceOneDetails("XYZ"));
        Thread vts2 = vBuilder.start(() -> ServiceTwo.getServiceTwoDetails("XYZ", false));

        try {
            vts1.join();
            vts2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        LOGGER.info(">>> Failure Path");

        // Failure Path
        Thread vts3 = vBuilder.start(() -> ServiceOne.getServiceOneDetails("XYZ"));
        Thread vts4 = vBuilder.start(() -> ServiceTwo.getServiceTwoDetails("XYZ", true));

        try {
            vts3.join();
            vts4.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

Executing the above code will generate the following output:


Output.5

Fri Nov 17 21:33:52.388 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 21:33:52.388 EST 2023 [INFO] <ServiceTwo getServiceTwoDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 21:33:53.389 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Done fetching details for -> XYZ 
Fri Nov 17 21:33:53.639 EST 2023 [INFO] <ServiceTwo getServiceTwoDetails> - Done fetching details for -> XYZ 
Fri Nov 17 21:33:53.639 EST 2023 [INFO] <Sample_5 main> - >>> Failure Path 
Fri Nov 17 21:33:53.640 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 21:33:53.640 EST 2023 [INFO] <ServiceTwo getServiceTwoDetails> - Ready to fetch details for -> XYZ 
Fri Nov 17 21:33:54.641 EST 2023 [INFO] <ServiceOne getServiceOneDetails> - Done fetching details for -> XYZ 
Exception in thread "Virtual Threads" java.lang.RuntimeException: ServiceTwo timed out !!!
  at ServiceTwo.getServiceTwoDetails(ServiceTwo.java:35)
  at Sample_5.lambda$main$3(Sample_5.java:41)
  at java.base/java.lang.VirtualThread.run(VirtualThread.java:311)

Notice that when the application encounters a failure, the Virtual Thread also dumps the Stack Trace just like in the case of the Platform Thread and points to the source of the fault.


References

Java 21 Virtual Threads Documentation


© PolarSPARC