adesso Blog

Die Entwicklung der virtuellen Threads, auch bekannt als Projekt Loom, begann Ende 2017. Zunächst wurden sie als Vorschaufunktion in JDK 19 JEP 425 eingeführt und setzten sich dann als Vorschaufunktion in JDK 20 durch JEP 436 fort. In JDK 21 wurden mit einem entsprechenden JEP (JDK Enhancement Proposal), der die endgültige Implementierung virtueller Threads vorschlägt, zwei wesentliche Änderungen vorgenommen:

  • Virtuelle Threads unterstützen nun standardmäßig Thread-lokale Variablen, was die Kompatibilität mit vorhandenen Bibliotheken verbessert und die Migration von aufgabenorientiertem Code erleichtert.
  • Virtuelle Threads werden jetzt, die direkt mit dem Thread.Builder-API erstellt werden, standardmäßig überwacht und sind über den neuen Thread-Dump beobachtbar.

Mit diesen implementierten Verbesserungen sind virtuelle Threads nun ein fester Bestandteil von JDK ab Java 21.

Plattform-Threads

Ein Plattform-Thread entspricht in der Regel einem Kernel-Thread im Verhältnis 1:1, der vom Betriebssystem entworfen und als dünne Hülle um diesen implementiert wird. Er führt Java-Code auf dem darunter liegenden OS-Thread aus und bleibt während der gesamten Lebensdauer des Plattform-Threads an diesen gebunden. Dadurch wird die Anzahl der verfügbaren Plattform-Threads auf die Anzahl der ressourcenintensiven OS-Threads begrenzt.


Abbildung 1: Plattform-Threads

Plattform-Threads haben typischerweise einen großen Thread-Stack. Betriebssysteme reservieren Thread-Stacks in der Regel als monolithische Speicherblöcke bei der Thread-Erzeugung, die später nicht mehr geändert werden können. Darüber hinaus beanspruchen sie weitere Ressourcen, die vom Betriebssystem verwaltet werden und standardmäßig bis zu 8 MB belegen. Obwohl sie für eine Vielzahl von Aufgaben geeignet sind, stellen sie eine begrenzte Ressource dar. Im Vergleich zur reinen CPU-Leistung oder zum Netzwerkverkehr können Threads schneller erschöpft sein.

Was ist ein virtueller Thread?

Wie ein Plattformthread ist auch ein virtueller Thread eine Instanz von java.lang.Thread. Allerdings ist ein virtueller Thread nicht an einen bestimmten OS-Thread gebunden. Ein virtueller Thread führt immer noch Code auf einem OS-Thread aus, aber sie werden nicht vom Betriebssystem verwaltet oder geplant. Stattdessen ist die JVM für die Planung verantwortlich.

Natürlich muss jede tatsächliche Arbeit in einem Plattformthread ausgeführt werden, aber die JVM nutzt sogenannte Träger-Threads, die Plattform-Threads sind, um jeden virtuellen Thread "zu tragen", wenn seine Ausführung ansteht. Wenn jedoch Code, der in einem virtuellen Thread läuft, eine blockierende I/O-Operation aufruft, setzt die Java-Laufzeitumgebung den virtuellen Thread aus, bis er wieder aufgenommen werden kann. Der mit dem ausgesetzten virtuellen Thread verbundene OS-Thread ist nun frei, Operationen für andere virtuelle Threads auszuführen.

Die Java-Laufzeitumgebung ordnet einer großen Anzahl von virtuellen Threads eine kleine Anzahl von OS-Threads zu. Im Gegensatz zu Plattform-Threads haben virtuelle Threads typischerweise einen flachen Aufrufstapel und führen so wenig wie einen einzelnen HTTP-Client-Aufruf oder eine einzelne JDBC-Abfrage aus.


Abbildung 2: Virtuelle Threads

Obwohl virtuelle Threads Thread-lokale Variablen und vererbbare Thread-lokale Variablen unterstützen, solltet ihr sorgfältig abwägen, ob ihr sie verwenden möchten, da eine einzelne JVM Millionen virtueller Threads unterstützen könnte.

Virtuelle Threads sind geeignet für die Ausführung von Aufgaben, die die meiste Zeit blockiert sind und oft auf Abschluss von I/O-Operationen warten. Sie sind jedoch nicht für langlaufende CPU-intensive Operationen gedacht.

Virtuelle Threads sind keine schnelleren Threads. Sie führen Code nicht schneller aus als Plattform-Threads. Sie dienen dazu, Skalierbarkeit (höhere Durchsatzrate), nicht Geschwindigkeit (geringere Latenzzeit) zu bieten.

Die JVM verwaltet einen Pool von OS-Threads und weist einem als "Trägerthread" einen zu, um die Aufgabe des virtuellen Threads auszuführen. Wenn eine Aufgabe blockierende Operationen umfasst, kann der virtuelle Thread pausiert werden, ohne den Trägerthread zu beeinträchtigen, was anderen virtuellen Threads ermöglicht, gleichzeitig ausgeführt zu werden.

Die Synchronisierung zwischen virtuellen Threads ist unter Verwendung traditioneller Methoden möglich, wobei die JVM eine ordnungsgemäße Koordination sicherstellt. Nach Abschluss einer Aufgabe können virtuelle Threads für zukünftige Aufgaben wiederverwendet werden.

Wenn ein virtueller Thread pausiert wird, kann die JVM seine Ausführung für mehr Effizienz auf einen anderen virtuellen Thread oder Trägerthread umschalten.

Zusammenfassung – Das sind virtuelle Threads

Zusammenfassend bieten Javas virtuelle Threads eine leichtgewichtige und effiziente Nebenläufigkeit, die von der JVM durch die Zuordnung von OS-Threads und die Optimierung von Ressourcen verwaltet wird. Es gibt einige bewährte Methoden, die ihr beachten solltet, wenn ihr virtuelle Threads verwenden:

  • Startet für jede Aufgabe einen neuen virtuellen Thread.
  • Virtuelle Threads sollten nicht gepoolt werden.
  • Die meisten virtuellen Threads haben flache Aufrufstapel und sind daher kurzlebig.

Wie benutze ich virtuelle Threads in Standard-Java?

Bei der Verwendung virtueller Threads müssen die folgenden Punkte berücksichtigt werden:

  • Ein virtueller Thread hat keinen Zugriff auf seinen Träger, und Thread.currentThread() gibt den virtuellen Thread selbst zurück.
  • Die Stacktraces sind getrennt, und jede Ausnahme, die in einem virtuellen Thread ausgelöst wird, enthält nur seine eigenen Stackframes.
  • Thread-lokale Variablen eines virtuellen Threads sind für seinen Träger nicht verfügbar, und umgekehrt.
  • Aus codeperspektive ist die gemeinsame Nutzung eines Plattform-Threads durch den Träger und seinen virtuellen Thread unsichtbar.

Die Thread- und Thread.Builder-APIs bieten Möglichkeiten, sowohl Plattform- als auch virtuelle Threads zu erstellen.

	
		Thread thread = Thread.ofVirtual().start(runnableTask);
	

oder

	
		Thread thread = Thread.startVirtualThread(() -> {
		  // your task code
		});
	

Die Klasse java.util.concurrent.Executors definiert ebenfalls Methoden, um einen ExecutorService zu erstellen, der für jede Aufgabe einen neuen virtuellen Thread startet.

	
		ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
		executorService.submit(() -> {
		  // your task code
		});
	

Wie benutze ich virtuelle Threads in Spring Boot?

Um virtuelle Threads zu nutzen, benötigt ihr JDK 21 und Spring Boot 3.

In Spring Boot 3.2

In Spring Boot 3.2 können einfach die folgende Eigenschaft zur application.properties-Datei hinzugefügt werden, um virtuelle Threads zu aktivieren. Diese Einstellung funktioniert sowohl für den eingebetteten Tomcat als auch für Jetty.

	
		spring.threads.virtual.enabled=true
	

Oder für application.yml-Datei

	
		spring:
		  threads:
		    virtual:
		      enabled: true
	
In Spring Boot 3.1

Für Spring Boot 3.1 und ältere Versionen können die folgende Konfiguration verwendet werden, um mit dem eingebetteten Tomcat zu arbeiten:

	
		@EnableAsync
		@Configuration
		public class VirtualThreadConfig {
		    @Bean
		    public AsyncTaskExecutor applicationTaskExecutor() {
		        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
		    }
		    @Bean
		    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
		        return protocolHandler -> {
		protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
		        };
		    }
		}
	

Wie benutze ich virtuelle Threads in Quarkus?

Quarkus unterstützt bereits die @RunOnVirtualThread-Annotation, definiert in Jakarta EE 11, seit Version 2.10, die im Juni 2022 veröffentlicht wurde.

Die Verwendung von virtuellen Threads in Quarkus ist unkompliziert. Hierzu benutzt man einfach die @RunOnVirtualThread-Annotation verwenden. Diese gibt Quarkus an, die annotierte Methode auf einem virtuellen Thread anstelle eines regulären Plattform-Threads auszuführen.

	
		@Path("/hello")
		public class SampleVirtualThreadApp {
		  @GET
		  @RunOnVirtualThread
		  public String sayHello() {
		    // your task code
		  }
		}
	

Fallstricke bei der Verwendung von virtuellen Threads

Synchronisierte Blöcke/Methoden vermeiden

Ein virtueller Thread kann während blockierender Operationen nicht von seinem Träger abgehängt werden, wenn er an diesen gebunden ist. Dies tritt in den folgenden Situationen auf:

  • Der virtuelle Thread führt Code innerhalb eines synchronisierten Blocks oder einer synchronisierten Methode aus.
  • Der virtuelle Thread führt eine native Methode oder eine Fremdfunktion aus (siehe Fremdfunktionen und Memory-API).

Das Anbinden macht eine Anwendung nicht falsch, könnte jedoch ihre Skalierbarkeit beeinträchtigen. Versuche, häufiges und langfristiges Anbinden zu vermeiden, indem ihr synchronisierte Blöcke oder Methoden überprüft, die häufig ausgeführt werden, und potenziell langwierige I/O-Operationen mit java.util.concurrent.locks.ReentrantLock absicherst.

Thread-Pools vermeiden, um den Ressourcenzugriff zu begrenzen

Wenn ihr einen Executor verwendet, könnt ihr eine unbegrenzte Anzahl virtueller Threads erstellen. Wenn ihr die Anzahl der virtuellen Threads begrenzen musst, solltet ihr keinen Pool erstellen, sondern stattdessen Semaphoren verwenden.

Verwendung von ThreadLocal reduzieren

Wenn Deine Anwendung Millionen von virtuellen Threads erstellt und jeder virtuelle Thread seine eigene ThreadLocal-Variable hat, kann dies schnell den Speicherplatz des Java-Heaps verbrauchen. Daher ist es wichtig, sorgfältig abzuwägen, welche Datenmenge als ThreadLocal-Variablen gespeichert wird.

Was bietet uns virtuelle Threads

Vorteile virtueller Threads
  • Leichtgewichtig: Virtuelle Threads können in großen Mengen erstellt werden, ohne die Leistung zu beeinträchtigen.
  • Kostengünstig in der Erstellung: Es ist nicht mehr erforderlich, virtuelle Threads zu poolen, was die Implementierung vereinfacht.
  • Kostengünstig beim Blockieren: Das Blockieren eines virtuellen Threads beeinträchtigt nicht den zugrunde liegenden Betriebssystem-Thread während der Ausführung von I/O-Operationen.
  • Geringerer Speicherbedarf: Virtuelle Threads verbrauchen deutlich weniger Speicher als Plattform-Threads, was zu einer erheblichen Leistungssteigerung führt.
  • Niedrigere CPU-Auslastung: Die Planung virtueller Threads durch die JVM ist effizienter und verursacht weniger CPU-Auslastung als bei Betriebssystem-Threads.
  • Schnellere Kontextwechsel: Virtuelle Threads ermöglichen schnellere Kontextwechsel im Vergleich zu Kernel-Threads, da sie von der JVM verwaltet werden.
  • Verbesserter Durchsatz und Latenz: Die Verwendung virtueller Threads erhöht den Durchsatz des Webservers, da mehr Anfragen pro Zeiteinheit verarbeitet werden können, und verbessert die Latenz, indem die Bearbeitungszeit reduziert wird.
Vorteile der Verwendung virtueller Threads
  • Dramatische Reduzierung des Aufwands: Für das Schreiben, Warten und Beobachten hochdurchsatzfähiger nebenläufiger Anwendungen.
  • Neues Leben für den Thread-pro-Anfrage-Programmierstil: Dies ermöglicht eine nahezu optimale Skalierung der Hardware-Auslastung.
  • Vollständige Kompatibilität mit der bestehenden Thread-API: Dadurch können vorhandene Anwendungen und Bibliotheken mit minimalen Änderungen unterstützt werden.
  • Unterstützung der vorhandenen Debugging- und Profiling-Schnittstellen: Ermöglicht eine einfache Fehlerbehebung, Debugging und Profiling von virtuellen Threads mit vorhandenen Tools und Techniken.

Virtuelle Threads vs. Plattform-Threads

Das Testsetup zur Vergleichsanalyse der Antwortzeiten zwischen virtuellen Threads und Plattform-Threads ist unkompliziert. Es erfordert lediglich eine Methode zur Simulation von CPU- und Speicherlast. Unser Test verwendet eine einfache Klasse, um Lasten zu simulieren. Die Aufgabe besteht darin, eine ArrayList mit zufälligen Zahlen zu erstellen, die sortiert und gemischt werden.

	
		public class MyTask implements Callable<Long> {
			@Override
			public Long call() throws Exception {
				long startTime = System.currentTimeMillis();
				long tId = Thread.currentThread().threadId();
				Random rand = new Random(startTime);
				List<Long> sampleList = new ArrayList<>();
				for (int i = 0; i < 20000; i++) {
					sampleList.add(rand.nextLong(20000));
				}
				Collections.sort(sampleList);
				Collections.shuffle(sampleList);
				return tId;
			}
		}
	
Testaufbau
  • MacBook Pro M2 Pro mit 32 GB RAM
  • Eine einfache REST-API in Spring Boot, die die Testaufgabe aufruft
  • Verwendung von JMeter zur Erstellung von 500 gleichzeitigen Anfragen
Ergebnis

Abbildung 3: Testergebnis mit 500 Anfragen

Die Stabilität und verbesserten Antwortzeiten der virtuellen Threads im Vergleich zu Plattform-Threads sind unter Last deutlich erkennbar.

Es ist wichtig anzumerken, dass verschiedene Ergebnisse durch Optimierungen des Systems und des Codes erzielt werden können. Jedoch ist dies nicht das Hauptziel des Vergleichs. Vielmehr soll der Unterschied in der Durchsatzleistung verdeutlicht werden, der durch den ausschließlichen Einsatz virtueller Threads in einem bestehenden System entsteht.

Die Ergebnisse sind für Plattform-Threads besser, wenn wir weniger gleichzeitige Anfragen ohne weitere Änderungen senden. Da die Last geringer ist, können das Betriebssystem und die JVM die Ressourcen besser verwalten.

Zum Beispiel sieht es bei 300 gleichzeitige Anfragen wie folgt aus:


Abbildung 4: Testergebnis mit 300 Anfragen

Es ist zu erwarten, dass Deine Anwendung möglicherweise nicht das gleiche Ergebnis zeigt, da verschiedene Faktoren wie die Nutzung der Datenbank oder langlaufende Prozesse eine Rolle spielen. Selbst die Verwendung von Synchronisierung kann das Ergebnis beeinflussen.

Fazit

Virtuelle Threads sind eine leistungsstarke Erweiterung der Java-Plattform, die die Erstellung und Verwaltung von nebenläufigen Anwendungen vereinfacht und deren Leistung verbessert. Sie sind besonders nützlich für IO-gebundene und serverseitige Anwendungen, bieten aber auch Vorteile für allgemeine nebenläufige Programmierung.

Ihr möchtet gern mehr über spannende Themen aus der adesso-Welt erfahren? Dann werft auch einen Blick in unsere bisher erschienenen Blog-Beiträge.

Bild Murat   Fevzioglu

Autor Murat Fevzioglu

Murat Fevzioglu ist bei adesso als Software Architekt im Bereich Banking tätig. Seine Tätigkeitsschwerpunkte liegen in der ganzheitlichen Analyse, Konzeption und Umsetzung von komplexen Projekten.

Kategorie:

Softwareentwicklung

Schlagwörter:

Java

Spring Boot

Diese Seite speichern. Diese Seite entfernen.