Was versteht man unter Nebenläufigkeit und was hat es mit Plattform- und virtuellen Threads auf sich?

Unter Nebenläufigkeit versteht man die Fähigkeit eines Rechnersystems, mehrere Aufgaben gleichzeitig oder scheinbar gleichzeitig auszuführen. Hierzu bietet Java die Möglichkeit, zwei verschiedene Thread-Typen zu erzeugen.

Bei der Ausführung einer Anwendung kann ein Prozessor ohne weitergehende Maßnahmen keine zwei Aufgaben gleichzeitig ausführen1. Die Abarbeitung eines Programms (Prozesses) erfolgt im einfachsten Fall innerhalb eines Ausführungsstrangs, in der durch den Entwickler und den Compiler vorgegebenen linearen Reihenfolge. Eine gleichzeitige Ausführung mehrerer Aufgaben ist bei Nutzung eines Prozessorkerns nicht möglich1.
Beispielhaft kann dies z.B. anhand zweier Schleifen gezeigt werden: Die zweite Schleife wird erst nach vollständiger Abarbeitung der ersten betreten:

public class ThreadTest {
    public static void main(String[] args) {
       for (int i = 0; i < 5; i++) {
           System.out.println("A: " + i);
       }
       for (int i = 0; i < 5; i++) {
           System.out.println("B: " + i);
       }
    }
}

Das Programm liefert erwartungsgemäß die folgende Ausgabe:

A: 0
A: 1
A: 2
A: 3
A: 4
B: 0
B: 1
B: 2
B: 3
B: 4

In vielen Fällen ist es jedoch sinnvoll, wenn nicht gar notwendig, mehrere Aufgaben gleichzeitig auszuführen. Man denke etwa an verschiedene, zeitgleich beauftragte Bestellungen in einem Webshop, eine über längere Zeit auszuführende Berechnung, während der das Programm weitere Funktionen bereitstellen soll oder auch nur das Öffnen eines Dialogfensters, während dessen ein Desktopprogramm weiter bedienbar bleiben muss.
Die Lösung besteht darin, den einzelnen Aufgaben jeweils einen gesonderten Ausführungsstrang, einen Thread, zuzuweisen.
Diesen werden hierbei in schnellem Wechsel Prozessorzeiten zu deren Abarbeitung zugewiesen, sodass deren Aufgaben scheinbar gleichzeitig ausgeführt werden. Bei einer entsprechenden Umformulierung des obigen Beispiels könnte die gewünschte Ausgabe in etwa wie folgt aussehen:

A: 0
B: 0
B: 1
A: 1
B: 2
A: 2
B: 3
A: 3
A: 4
B: 4

Man erkennt die wechselnde Ausgabe beider Schleifen, die jedoch nicht genau abwechselnd erfolgt. Die Ursache liegt darin, dass die Zuweisung der Prozessorzeit zwar alternierend erfolgt, der Zeitpunkt des Wechsels jedoch nicht exakt vorhersagbar ist.

Plattform- und virtuelle Threads

Threads können in Java zwei verschiedenen Typen entsprechen: Plattform-Threads und virtuellen Threads. Beide Varianten unterscheiden sich grundlegend in ihrem internen Aufbau. Daraus ergeben sich auch Folgerungen bezüglich sinnvoller Verwendungszwecke.

Die Ausführungsgeschwindigkeiten beider Thread-Varianten unterscheiden sich kaum. Allerdings werden virtuelle Threads um ein Vielfaches schneller gestartet.

Erzeugen von Threads

Vor Java 19 bestand nur durch Plattform-Threads die Möglichkeit, Aufgaben parallel abzuarbeiten. Dies geschah oft durch die Erzeugung von Objekten der Klasse java.lang.Thread mittels Konstruktoraufruf. Die Klasse stellt hierzu neun öffentliche Konstruktoren bereit, von denen alleine sechs ein Runnable-Objekt als Parameter übergeben bekommen.
Virtuelle Threads können auf diesem Weg nicht gebildet werden, da die von Thread abgeleitete zugehörige Klasse java.lang.VirtualThread einen Konstruktor mit lediglich package-Sichtbarkeit aufweist. Stattdessen besitzt Thread ein inneres Interface Thread.Builder, mit dem über den Aufruf der statischen Methoden Thread.ofVirtual() und Thread.ofPlatform() beide Threadtypen erzeugt werden können. Dies geschieht so, dass hierbei jeweils ein Builder der inneren Typen Thread.Builder.OfVirtual bzw. Thread.Builder.OfPlatform erzeugt wird. Die Methode start() von Thread.Builder erzeugt und startet dann durch Parametrisierung mit einem Runnable-Objekt den neuen Thread.

Thread tv = Thread.ofVirtual().start(() -> System.out.println("virtual thread started"));
tv.join();
Thread tp = Thread.ofPlatform().start(() -> System.out.println("platform thread started"));
tp.join();

Durch eine separierte Erzeugung des Builders können auf diese Weise auch mehrere Threads erzeugt und gestartet werden.

Thread.Builder builder = Thread.ofVirtual();
Runnable task = () -> {System.out.println(Thread.currentThread() + " started");};
Thread[] threads = new Thread[5];

for (Thread t : threads) {
    t = builder.start(task);
    t.join();
}

Es wird folgende Ausgabe erzeugt:

VirtualThread[#20]/runnable@ForkJoinPool-1-worker-1 started
VirtualThread[#23]/runnable@ForkJoinPool-1-worker-1 started
VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1 started
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-1 started
VirtualThread[#26]/runnable@ForkJoinPool-1-worker-1 started

Die einzelnen Komponenten der toString()-Ausgabe bedeuten dabei folgendes:

Die Methode join()

Wird innerhalb einer Java-Application ein Thread erzeugt, im Folgenden hier erzeugter Thread genannt, so geschieht dies immer aus einem bereits existierenden Basis-Thread heraus2. Ist die Ausführungszeit dieses Basis-Threads jedoch sehr kurz, kann es geschehen, dass dieser terminiert, bevor der erzeugte Thread vollständig ausgeführt wurde.
Der nachfolgende Code erzeugt so lediglich die abschließende Ausgabe des Basis-Threads: "Basis-Thread wird beendet" [15].

public static void main(String[] args) throws InterruptedException {
            
    Thread.Builder builder = Thread.ofVirtual();
    Runnable task = () -> {
        System.out.println("Erzeugter Thread #" + Thread.currentThread().threadId() + " started");
        for(int i=0; i<3; ++i) {
            System.out.println("Erzeugter Thread #" + Thread.currentThread().threadId() + " is running " + i + " sec.");
            try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {}
        }
    };

    Thread t1 = builder.start(task);   
    System.out.println("Basis-Thread wird beendet");
}

Die Ausgabe ändert sich, wenn zwischen die Zeilen [14] und [15] die Anweisung Thread.sleep(100); eingefügt wird. Sie bewirkt, dass sich die Laufzeit des Basis-Threads um 100 Millisekunden verlängert, sodass ausreichend Zeit für den Start des virtuellen Threads zur Verfügung steht, bevor der Basis-Thread selbst beendet wird. Es ergibt sich dann z.B. die folgende Ausgabe, bei der der Basis-Thread nach dem ersten Schleifendurchlauf des erzeugten Threads terminiert:

Erzeugter Thread #20 started
Erzeugter Thread #20 is running 0 sec.
Basis-Thread wird beendet

Übrigens: Erzeugt man in Zeile [3] durch Aufruf von Thread.ofPlatform() statt Thread.ofVirtual() an Stelle eines virtuellen einen Plattform-Thread, so finden die Ausgaben des erzeugten Threads statt. Die Ursache liegt in der bereits oben erwähnten unterschiedlichen Startzeit für Plattform- und virtuelle Threads: Da die interne Abarbeitung des Startprozesses eines neu erzeugten Plattform-Threads wesentlich länger dauert als diejenige eines virtuellen Threads, kann dessen Ausführung erfolgen bevor der Basis-Thread terminiert. [2]

Um den Basis-Thread warten zu lassen bis der erzeugte Thread abgearbeitet ist, wird auf diesem die Methode join() der Klasse Thread aufgerufen.

//...
Thread t1 = builder.start(task);   
t1.join();
System.out.println("Basis-Thread wird beendet");

Die Ausgabe ändert sich entsprechend:

Erzeugter Thread #20 started
Erzeugter Thread #20 is running 0 sec.
Erzeugter Thread #20 is running 1 sec.
Erzeugter Thread #20 is running 2 sec.
Basis-Thread wird beendet

join() ist vierfach überladen und nimmt in den parametrisierten Versionen jeweils eine Zeitangabe, als Duration-Objekt, in Millisekunden oder in Milli- und Nanosekunden entgegen. Die Zeitangabe bestimmt, wie lange der Basis-Thread wartet bis er seine Ausführung weiter fortsetzt. Terminiert der erzeugte Thread vor Ablauf des Intervalls, wird der Basis-Thread sofort fortgesetzt.

Quellen
  1. https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html
  2. https://liakh-aliaksandr.medium.com/concurrent-programming-in-java-with-virtual-threads-8f66bccc6460
  3. https://pages.mtu.edu/~shene/NSF-3/e-Book/FUNDAMENTALS/thread-management.html

1) Auf Hardwarevarianten und Optimierungsformen wie etwa hardwareseitiges Multithreading wird an dieser Stelle nicht eingegangen.

2) Wird eine Application durch Ausführen von main() durch die JVM gestartet, so wird von dieser immer ein eigener Thread erzeugt, auf dem diese Application läuft.

Wenn Ihnen javabeginners.de gefällt, freue ich mich über eine Spende an diese gemeinnützigen Organisationen.