Was versteht man unter Nebenläufigkeit und was hat es mit Plattform- und virtuellen Threads auf sich?
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.
- Jeder Plattform-Thread kapselt einen eigenen
Betriebssystem-Thread. Er wird vom OS verwaltet und
greift direkt auf dessen Strukturen zu. Dies ist
natürlich sehr ressourcenintensiv und zudem
begrenzt durch die Anzahl der insgesamt zur
Verfügung stehenden OS-Threads. Deren Anzahl
kann nicht allgemein beziffert werden, da dies u.a.
von der Rechnerarchitektur und dem Betriebssystem
abhängt.
Plattform-Threads können zwar jede Form der Aufgabenverarbeitung übernehmen, ihre Verwendung sollte jedoch hinsichtlich der durch sie blockierten Betriebssystem-Ressourcen gut bedacht werden. - Auch virtuelle Threads laufen auf zugrunde liegenden
Betriebssystem-Threads, einem Pool von sog. carriern.
Virtuelle Threads werden jedoch von der Virtuellen
Maschine gemanaged, besitzen dynamische
Stackgrößen und benötigen keinen
direkten und kostenintensiven Systemzugriff.
Sie sind deshalb weniger gut geeignet für CPU-intensive Operationen, sondern eher für Umgebungen mit einer hohen Anzahl an Threads, insbesondere, wenn diese für lange Zeit in einem inaktiven Zustand verharren, wie das etwa in Serverumgebungen der Fall sein kann.
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 Nummer in eckigen Klammern bezeichnet die
Thread-ID. Sie kann durch
Thread#threadId()
abgefragt werden. - Der Bezeichner nach dem '@' ist der Name des Carrier-Threads, des Betriebssystem-Threads, auf dem der virtuelle Thread ausgeführt wird. Eine gesonderte Abfrage dieses Namens ist leider nicht möglich.
- Zwischen '/' und '@' wird der Status des Threads
angegeben. Er kann durch
Thread#getState()
ermittelt werden.
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) 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.