Vorwort
Am 22.09.2022 wurde JDK19 veröffentlicht. Das größte Highlight dieser Version ist die Unterstützung virtueller Threads, mit denen die Familie der leichtgewichtigen Threads ein weiteres starkes Mitglied erhält. Virtuelle Threads befreien die JVM von der Bindung an die Thread-Planung durch das Betriebssystem: Die JVM plant die Threads selbst. Tatsächlich hatte Sun bereits früh eine Thread-Planung durch die JVM in der virtuellen Maschine für das Betriebssystem Solaris implementiert. Aufgrund der Komplexität und aus Gründen der Wartbarkeit kehrte man letztlich jedoch zum Modell zurück, bei dem das Betriebssystem die Threads plant.
„长安归来锦衣客,昨日城南起新宅。“ („In prächtigen Gewändern kehrt der Reisende aus Chang’an heim; gestern entstand im Süden der Stadt ein neues Haus.“) Wenn ich auf den bisherigen Weg zurückblicke, wirken die vielen Begriffe rund um Multithreading schier überwältigend. Auch im Internet gibt es unzählige Erklärungen dazu, doch mir fehlt dabei immer wieder eine übergreifende Perspektive. Deshalb habe ich die Entwicklungsgeschichte des Multithreadings in Java systematisch aufgearbeitet und hoffe, dass dir dies hilft, die entsprechenden Kenntnisse zu erwerben.
Was dieser Artikel nicht behandelt:
1 Die detaillierten Implementierungsprinzipien einzelner Techniken werden nicht erläutert, der Quellcode wird nicht analysiert und es werden keine Diagramme gezeichnet. Wenn Sie in diesem Artikel auf interessante Konzepte und Techniken stoßen, können Sie selbst danach suchen 2 Bibliotheken und Frameworks zur Unterstützung von Nebenläufigkeit wie Quasar、Akka、Guava werden nicht behandelt
Worum es in diesem Artikel geht
1 Die Entwicklungsgeschichte von Multithreading im JDK erläutern 2 Funktionsprinzipien und Hintergründe bestimmter technischer Aspekte dieser Entwicklung sowie die dadurch gelösten Probleme erläutern 3 Meine Ansichten zu bestimmten technischen Aspekten darlegen; abweichende Meinungen sind in den Kommentaren zur Diskussion willkommen
Meilenstein
Wie üblich beginnen wir mit einer Übersichtstabelle. Sie fasst die zentralen Konzepte rund um Threads in den verschiedenen JDK-Versionen zusammen. Hier lässt sich eine vielleicht nicht ganz passende Analogie ziehen: Die Entwicklung von Multithreading kann mit der Entwicklung des Autos verglichen werden. Sie durchlief das Zeitalter der Handschaltung (JDK1.4 und älter), das Zeitalter der Automatik (JDK5-JDK18) und das Zeitalter des autonomen Fahrens (JDK19 und neuer). Dieser Vergleich soll den Lesern lediglich vermitteln, dass sich Multithreading seit JDK5 komfortabler beherrschen lässt. Seit JDK19 geht es weit über bloßen Komfort hinaus: Bei E/A-intensiven Diensten ermöglicht es einen qualitativen Leistungssprung.
| Epoche | Version | Veröffentlichungszeitpunkt | Zentrale Konzepte |
|---|---|---|---|
| Handschaltung | JDK1.0 | 1996-01-23 | Thread und Runnable |
| Handschaltung | JDK1.2 | 1998-12-04 | ThreadLocal、Collections |
| Automatikmodus | JDK1.5/5.0 | 2004-09-30 | Präzisierung des Java-Speichermodells, Einführung des Nebenläufigkeitspakets |
| Automatikmodus | JDK1.6/6.0 | 2006-12-11 | Optimierung von synchronized |
| Automatikmodus | JDK1.7/7.0 | 2011-07-28 | Fork/Join-Framework |
| Automatikmodus | JDK1.8/8.0 | 2014-03-18 | CompletableFuture、Stream |
| Automatikmodus | JDK1.9/9.0 | 2014-09-08 | Den Mechanismus bei Sperrkonkurrenz verbessern |
| Automatikmodus | JDK10 | 2018-03-21 | Thread-lokale Steuerung |
| Automatikmodus | JDK15 | 2020-09-15 | Deaktivierung und Abkündigung von Biased Locking |
| Autonomes Fahren | JDK19 | 2022-09-22 | Virtuelle Threads |

Das Zeitalter der Handschaltung
Die Zeit von JDK1.4 und früher bezeichne ich als Zeitalter der „Handschaltung“ beim Multithreading, auch als Zeitalter des nativen Multithreadings bekannt. Der Umgang mit Threads war noch recht elementar, und es standen keine Thread-Pools zur Verfügung. Entwickler mussten eigene Hilfswerkzeuge schreiben, um die Ressourcenverschwendung durch häufiges Erstellen von Threads zu vermeiden, und gemeinsam genutzte Ressourcen manuell sperren. Gerade in dieser Zeit entstanden viele hervorragende Multithreading-Frameworks; das bekannteste wurde in JDK5.0 übernommen.
JDK 1.0 Thread und Runnable
Die im Monat 1 des Jahres 1996 erschienene Version JDK1.0 legte von Anfang an das grundlegende Thread-Modell von Java fest. Dieses Thread-Modell hat sich auch durch spätere Nachbesserungen nicht wesentlich verändert und kann als guter, beständiger Entwurf gelten. Präemptives und kooperatives Scheduling sind zwei verbreitete Verfahren zur Planung von Prozessen beziehungsweise Threads. Für Betriebssysteme eignet sich präemptives Scheduling besonders gut: Sie weisen verschiedenen Prozessen Zeitscheiben zu und können Prozessen, die lange nicht reagieren, Ressourcen entziehen oder sie sogar zwangsweise beenden. Beim kooperativen Verfahren müssen Prozesse ihre Ressourcen selbstständig und aktiv freigeben. Dabei kann ein lange laufender Thread dazu führen, dass alle anderen Threads, die CPU-Zeit benötigen, „verhungern“. Die Java HotSpot VM verwendet präemptives Scheduling; entsprechend setzt Java von Anfang an auf präemptives Thread-Scheduling. In JDK 1.0 werden Threads hauptsächlich durch Ableiten von der Klasse Thread oder Implementieren der Schnittstelle Runnable erstellt. Der Thread wird über die Methode start einer Objektinstanz gestartet; der parallel auszuführende Code steht in der Methode run. Für die Zusammenarbeit und Kommunikation zwischen Threads werden einfache, brachiale Methoden wie stop/resume/suspend verwendet.

Wie lassen sich die Begriffe stop/resume/suspend erklären? Der Hauptthread kann die Methoden zum Beenden, Anhalten und Fortsetzen eines untergeordneten Threads direkt aufrufen. Wenn du als Kind einen Walkman benutzt hast, kennst du dessen drei Tasten:Stopp, Pause und Fortsetzen. Stell dir vor, du hörst gleichzeitig 3 Walkmans. Die drei Walkmans sind drei untergeordnete Threads, und du bist der Hauptthread. Du kannst diese drei Geräte nach Belieben starten und anhalten.
Dieser Mechanismus hat ein schwerwiegendes Problem:Es kann leicht zu einem Deadlock kommen. Der Grund ist, dass Thread A eine Ressource sperrt und vom Hauptthread angehalten wird, bevor er sie wieder freigibt(die Methode suspend gibt die Sperre nicht frei). Wenn Thread B diese Ressource nun beanspruchen möchte, muss er warten, bis Thread A seine Ausführung fortsetzt(resume)und die Ressource freigibt. Andernfalls erhält er sie niemals, und es entsteht ein Deadlock.
JDK 1.2
Der grobe stop/resume/suspend-Mechanismus wurde in dieser Version untersagt. Stattdessen setzte man auf die Zusammenarbeit mehrerer Threads mithilfe von wait/notify/sleep. Erwähnenswert ist, dass die atomaren Objekte AtomicityXXX in dieser Version bereits entworfen waren, vor allem zur Lösung des Problems, dass i++ nicht atomar ist. Die Einführung von ThreadLocal und Collections erweiterte die Möglichkeiten der Arbeit mit mehreren Threads. Wegen dieser beiden Technologien bezeichne ich diese Phase als das Turbozeitalter von Java.
ThreadLocal
ThreadLocal ist ein Ansatz, mit dem mehrere Threads nicht threadsichere Objekte ohne Sperren gemeinsam verwenden können. Probleme wie „Guthaben auf Bankkonten oder Lagerbestände erhöhen und verringern“ kann es nicht lösen. Seine Stärke liegt darin, Methoden von Klassen mit Werkzeugcharakter durch die Verwendung von Kopien sicher auszuführen. Typische Beispiele sind SimpleDateFormat und Datenbankverbindungen. Sein Entwurf ist bemerkenswert ausgeklügelt. Stellen wir uns vor, wir müssten es selbst entwerfen: Ein naheliegender einfacher Ansatz wäre, in ThreadLocal eine globale threadsichere Map zu verwalten, mit dem Thread als Schlüssel und dem gemeinsam verwendeten Objekt als Wert. Dieser Entwurf hat jedoch einen Nachteil: Speicherlecks. Denn mit jedem zusätzlichen Thread würde diese Map unbegrenzt wachsen. Um die Speicherlecks zu beheben, müsste die Map beim Beenden eines Threads bereinigt werden. Dafür müsste wiederum die Leistungsfähigkeit der Garbage Collection erweitert werden, was offensichtlich kein angemessenes Verhältnis von Aufwand und Nutzen ergäbe. Deshalb wurde ThreadLocal so entworfen, dass die Map nicht von ThreadLocal, sondern vom Thread selbst gehalten wird. Der Schlüssel ist die ThreadLocal-Variable, der Wert der zugehörige Wert. Jeder Thread legt darin alle von ihm verwendeten ThreadLocal-Variablen ab (natürlich ergeben sich aus diesem Entwurf weitere Probleme, auf die hier nicht eingegangen wird; Interessierte können selbst danach suchen).
Sammlungen
Die Hilfsklasse Collections wurde in dieser Version entwickelt. Sie kapselt einige threadsichere Collections wie SynchronizedList. In jener Zeit, als es lediglich threadsichere Collections wie Hashtable, Vector und Stack gab, war ihr Erscheinen ebenfalls von epochaler Bedeutung. Der Grundgedanke des Collections-Werkzeugs lautet: Ich verpacke deine nicht threadsicheren Collections so, dass sie threadsicher werden. Dadurch musst du nicht viel Zeit in die Anpassung deines bestehenden Codes investieren, sondern die Collection bei ihrer Erstellung nur mit einer von mir bereitgestellten Methode initialisieren. Das ähnelt der Turboaufladung bei Autos: Bei unverändertem Hubraum steigen Leistung und Drehmoment des Motors. Das Turbozeitalter von Java war angebrochen^\_^
Das Zeitalter der Automatik
JDK 5.0
Einbinden des Nebenläufigkeitspakets
Doug Lea, dessen chinesischer Name 道格·利 lautet, ist ein Hochschullehrer in den USA und eine wahre Koryphäe: J.U.C stammt aus seiner Feder. Vor JDK1.5 konnten wir den gleichzeitigen Zugriff auf synchronisierten Code nur mit synchronized steuern. Damals war dessen Leistung noch nicht optimiert und entsprechend schwach; zur Thread-Steuerung standen lediglich die Methoden wait und notify von Object zur Verfügung. Zu dieser Zeit reichte Doug Lea beim JCP den Vorschlag JSR-166 ein. Bereits vor der Einreichung von JSR-166 hatte er mehr als drei Jahre lang Code mit ähnlichen Funktionen wie das J.U.C-Paket verwendet. Dieser Code bildete den Vorläufer von J.U.C.
J.U.C bietet mehrere große Werkzeugkategorien: atomare Objekte, Sperren und Hilfswerkzeuge, Threadpools sowie threadsichere Container. Entwickler können diese Möglichkeiten flexibel zum Aufbau ihrer eigenen Produkte nutzen: Sie können mit ReentrantLock grundlegende Frameworks aufbauen oder direkt mit fertigen Werkzeugen und Containern Geschäftscode schreiben. Historisch betrachtet konnte J.U.C im Jahr 2004 zweifellos als „technologisches Spitzenprodukt“ bezeichnet werden. Es trug außerordentlich zur Verbreitung von Java bei. Das Zeitalter der Automatik war für Java angebrochen: So wie ein Automatikgetriebe das Autofahren erleichtert, senkte J.U.C die Einstiegshürde für die Verwendung mehrerer Threads erheblich. Dieses Produkt läutete eine neue Ära ein.
Natürlich weist auch J.U.C einige Schwächen auf:
Hoher CPU-Aufwand:Wenn CAS beim aktiven Warten über längere Zeit erfolglos bleibt, verursacht dies einen sehr hohen CPU-Aufwand.
Lösung:An einigen Stellen in JUC wird die Anzahl der CAS-Spinversuche begrenzt, beispielsweise bei SynchronousQueue unter den BlockingQueue-Implementierungen.
ABA-Problem:Wenn ein Wert ursprünglich A war, zu B und anschließend wieder zu A wird, stellt die CAS-Prüfung keine Änderung fest, obwohl sich der Wert tatsächlich geändert hat. Dies ist das ABA-Problem. In den meisten Fällen beeinträchtigt das ABA-Problem die Korrektheit der nebenläufigen Programmausführung nicht.
Lösung:Jede Variable erhält eine Versionsnummer, die bei jeder Änderung um 1 erhöht wird, sodass A —> B —> A zu 1A —> 2B —> 3A wird. Java bietet dafür AtomicStampedReference. AtomicStampedReference kapselt ein Tupel [E,Integer], um Objekte mit einem Versionsstempel (stamp) zu kennzeichnen und so das ABA-Problem zu vermeiden.
Kann atomare Operationen nur für eine einzelne gemeinsam genutzte Variable gewährleisten:Der CAS-Mechanismus gewährleistet lediglich atomare Operationen auf einer einzelnen Variablen, nicht jedoch die Atomarität eines ganzen Codeblocks.
Lösung: Wenn beispielsweise gewährleistet werden muss, dass 3 Variablen gemeinsam atomar aktualisiert werden, kommt man um Synchronized nicht herum. Man kann auch erwägen, mehrere Variablen in AtomicReference zu kapseln und auf diese Weise mit mehreren gemeinsam genutzten Variablen umzugehen.
Das Java-Speichermodell klar verstehen
Diese JDK-Version präzisierte das Java-Speichermodell erneut. Zuvor gehörten das Modell der sequenziellen Konsistenz und das Happens-before-Modell zu den gängigen Speichermodellen. Beim Modell der sequenziellen Konsistenz stimmt die Ausführungsreihenfolge eines Programms vollständig mit der im Code angegebenen Reihenfolge überein. Bei modernen Mehrkern-CPUs mit optimierter Befehlsausführung lässt sich das nur schwer gewährleisten. Außerdem schränkt die Gewährleistung sequenzieller Konsistenz die Laufzeitoptimierung des Codes durch die JVM stark ein.
Die in der Spezifikation JSR 133 für diese Version festgelegte Happens-before-Beziehung ermöglicht jedoch eine flexiblere Reihenfolge der Befehlsausführung:
Innerhalb desselben Threads geschieht gemäß der Ausführungsreihenfolge des Codes(also der semantischen Reihenfolge des Codes)eine vorherige Operation vor einer nachfolgenden Operation Das Entsperren eines Monitorobjekts geschieht vor einem späteren Sperren desselben Monitorobjekts Ein Schreibzugriff auf ein volatile-Feld geschieht vor einem späteren Lesezugriff auf dieses Feld Die start-Operation eines Threads(der Aufruf der start()-Methode des Threadobjekts)geschieht vor jeder anderen Operation dieses Threads Alle Operationen eines Threads geschehen vor dem Aufruf der join()-Methode für diesen Thread durch einen beliebigen anderen Thread Wenn Operation A vor B und Operation B vor C geschieht, dann geschieht Operation A vor C
Bei der Speicherzuweisung schafft die Trennung des jeweiligen Arbeitsspeichers jedes Threads vom Hauptspeicher für die JVM zudem großen Spielraum, um die Ausführung von Anweisungen innerhalb eines Threads zu optimieren. Variablen aus dem Hauptspeicher können in den Arbeitsspeicher eines Threads kopiert und dort unabhängig verarbeitet werden. Nach Abschluss der Ausführung können die Ergebnisse zu einem bestimmten Zeitpunkt in den Hauptspeicher zurückgeschrieben werden: Aber wie lässt sich die Datenkonsistenz zwischen den einzelnen Threads gewährleisten?Die Lösung der JLS(Java Language Specification)lautet: Standardmäßig kann die Datenkonsistenz nicht zu jedem beliebigen Zeitpunkt garantiert werden, doch durch die Verwendung der Schlüsselwörter synchronized, volatile und final, deren Semantik verstärkt wurde, lässt sich Datenkonsistenz erreichen.
Optimierung von synchronized in JDK 6.0
Das Schlüsselwort synchronized, gewissermaßen der „älteste Sohn der Republik“, wurde in Version 5.0 von ReentrantLock in den Schatten gestellt. In dieser Version musste es daher wieder aufholen. Deshalb wurden in JDK 6.0 einige Optimierungen an Sperren vorgenommen, etwa Lock-Spinning, Sperreneliminierung, Sperrenzusammenführung, leichtgewichtige Sperren und bevorzugte Sperren. Diese Optimierungen veranschaulichen das Konzept der „fein abgestimmten Verwaltung“. Vor der Optimierung konnten mit synchronized gesperrte Objekte nur zwei Zustände haben:ungesperrt oder gesperrt(schwergewichtige Sperre). Nach der Optimierung gibt es insgesamt 4 Sperrzustände, in aufsteigender Stufenfolge:ungesperrt, bevorzugte Sperre, leichtgewichtige Sperre, schwergewichtige Sperre. Je nach Konkurrenzsituation steigen diese Zustände schrittweise auf, können aber nicht zurückgestuft werden. Ziel ist es, das Erlangen und Freigeben von Sperren effizienter zu machen(meiner Ansicht nach war es in Wirklichkeit zu komplex, sodass die JVM-Entwickler davor zurückschreckten).
Diese Optimierung verschaffte synchronized endlich Genugtuung:Von da an durfte niemand mehr behaupten, seine Leistung sei schlechter als die von ReentrantLock. Doch das Beste kommt noch:In JDK 15 wurden Bias-Sperren als veraltet eingestuft(─.─||). Meiner Ansicht nach ist synchronized dadurch im Nachteil, dass es lediglich ein Schlüsselwort ist. Die JVM übernimmt die zugrunde liegenden Abläufe und muss „erraten“, welche Art der Sperrverwendung für die Anwendung am besten passt. Bei ReentrantLock ist das anders:Hier wird diese Entscheidung direkt den Programmierern überlassen. Wer Fairness möchte, verwendet eine faire Sperre;wer keine Fairness möchte, nimmt eine unfaire Sperre. Auf Entwurfsebene ist das eine gewisse Bequemlichkeit, bietet aber zugleich Flexibilität.
JDK 7.0 Fork/Join-Framework
Auch Fork/Join war bei seiner Einführung eine recht fortschrittliche Entwicklung. Seine entscheidende Stärke besteht darin, Aufgaben rekursiv zu zerlegen und die Ergebnisse der einzelnen Aufgaben anschließend zusammenzuführen. Dennoch ist diese Technologie zugleich vertraut und fremd. Vertraut ist sie, weil sie an vielen Stellen eingesetzt wird, etwa in CompletableFuture und Stream, die wir im Folgenden zu JDK8.0 behandeln werden; fremd ist sie, weil wir sie bei der Entwicklung von Geschäftsanwendungen offenbar nur selten selbst verwenden.
Manche halten die Technik sogar für wenig nützlich. Meine Ansicht ist: Wenn Sie an der Umsetzung fachlicher Anforderungen arbeiten, bietet sie wenig Nutzen, weil sie praktisch nie gebraucht wird. Für große Datenmengen stehen die Werkzeuge aus dem Data-Warehouse-Umfeld zur Verfügung, in anderen Szenarien können Threadpools sie ersetzen; wenn Sie hingegen Middleware-Frameworks entwickeln, ist sie durchaus nützlich und könnte zum Einsatz kommen. Im chinesischsprachigen Internet stellen nur wenige diese Technik infrage, im Ausland wird bereits darüber diskutiert. Wer sich dafür interessiert, kann direkt zur entsprechenden Diskussion springen Ist das Fork-Join-Framework in Java defekt?
JDK 8.0
Die Veröffentlichung dieser Version war für Java epochal, sodass sie heute bei mehr als der Hälfte aller weltweit laufenden Java-Programme eingesetzt wird. Die Neuerungen im Bereich Multithreading waren jedoch weniger umwälzend als bei JDK5.0. Neben einigen zusätzlichen atomaren Objekten stechen bei dieser Version vor allem die folgenden beiden Neuerungen hervor.
CompletableFuture
Im Internet gibt es viele Einführungen zu CompletableFuture, die meist dessen Funktionsweise und Verwendung erläutern. Eine Frage blieb mir jedoch immer unklar: Warum wurde CompletableFuture eingeführt, obwohl es bereits so viele Threadpool-Werkzeuge gibt, welches konkrete Problem löst es? Was genau ist seine entscheidende Stärke? Wenn du darüber nachgedacht hast, hast du dir diese Frage vermutlich ebenfalls gestellt. Keine Sorge, ich habe die Antwort bereits für dich gefunden.
Fazit:Die entscheidende Stärke von CompletableFuture ist Aufgabenorchestrierung。CompletableFuture übernimmt die Eigenschaften der Future-Schnittstelle und ermöglicht unter anderem die nebenläufige Ausführung von Aufgaben. Für all diese Fähigkeiten gibt es Alternativen. Seine Fähigkeiten zur Aufgabenorchestrierung sind jedoch unersetzlich. Zu seinen zentralen APIs gehören der Aufbau von Aufgabenketten und das Zusammenführen von Aufgabenergebnissen, die alle für die Aufgabenorchestrierung konzipiert wurden. Der Hauptgrund für die Einführung dieses Frameworks in dieser JDK-Version war daher der zunehmend dringliche Bedarf an Aufgabenorchestrierung in der Anwendungsentwicklung.
Zum Schluss noch eine Anmerkung: CompletableFuture ist intern mithilfe des Fork/Join-Frameworks implementiert.
Stream
In „Clean Architecture“ werden drei Programmierparadigmen erwähnt:strukturierte Programmierung(prozedurale Programmierung), objektorientierte Programmierung und funktionale Programmierung. Stream ist eine Ausprägung funktionaler Programmierung in Java. Meiner Ansicht nach führt der Weg vom Anfänger zum fortgeschrittenen Programmierer unweigerlich darüber, Stream zu beherrschen. Beim ersten Kontakt mit Stream fühlt man sich sicherlich sehr ungewohnt damit, doch sobald man damit vertraut ist, eröffnet sich eine neue Sicht auf das Programmieren. Entwickler verwechseln häufig drei Begriffe:funktionale Programmierung, Stream und Lambda-Ausdrücke. Sie meinen oft, alle drei bezeichneten dasselbe. Im Folgenden erläutere ich mein Verständnis:
•Funktionale Programmierung ist ein Programmierparadigma, das in verschiedenen Programmiersprachen umgesetzt wird
•Stream ist eine neue Funktion von JDK8.0. Man kann es auch als neu geschaffenes Konzept verstehen, das dem Gedanken der funktionalen Programmierung entgegenkommt: Mit Streams lässt sich funktionale Programmierung auf Collection-Klassen anwenden
•Ein Lambda-Ausdruck (lambda expression) ist eine anonyme Funktion, mit der sich funktionale Programmierung knapper und effizienter ausdrücken lässt
Nach all diesen Erläuterungen stellt sich die Frage: Was haben Streams mit Multithreading zu tun? Die entsprechenden parallelen Methoden von Stream sind intern mithilfe des Fork/Join-Frameworks implementiert. In „Effective Java“ findet sich die Empfehlung „Stream-Parallelität mit Bedacht einsetzen“. Der Grund dafür ist, dass sämtliche parallelen Ausführungen in einem gemeinsamen Fork/Join-Pool laufen. Läuft eine Pipeline nicht ordnungsgemäß, kann dies die Leistung anderer, nicht damit zusammenhängender Teile beeinträchtigen.
JDK 9.0
Den Mechanismus bei Sperrkonkurrenz verbessern
Konkurrenz um Sperren begrenzt die Leistung vieler Java-Anwendungen mit mehreren Threads. Der neue Mechanismus für die Konkurrenz um Sperren verbessert die Leistung von Java-Objektmonitoren, wie verschiedene Benchmarks, etwa Volano, bestätigt haben. Solche Tests können den maximalen Durchsatz der JVM abschätzen. In der Praxis erzielte der neue Mechanismus in 22 verschiedenen Benchmarks hervorragende Ergebnisse. Sollte er in Java 9 zum Einsatz kommen, würde sich die Leistung der Anwendungen erheblich verbessern. Einfach erklärt: Wenn mehrere Threads um eine Sperre konkurrieren, gilt vor der Optimierung: Später eintreffende Threads warten alle nach demselben Standardverfahren auf die Sperre. Nach der Optimierung: Erkennt die JVM bestimmte optimierbare Situationen, lässt sie später eintreffende Threads direkt über eine Art „VIP-Zugang“ um die Sperre konkurrieren.
Eine ausführliche Erklärung findet sich unter: Umkämpfte Sperren erklärt – ein leistungsorientierter Ansatz
Reaktive Datenströme
Reactive Streams ist ein Standard zur Verarbeitung asynchroner Datenströme mit nicht blockierendem Gegendruck. Er stellt einen minimalen Satz von Schnittstellen, Methoden und Protokollen bereit, um die erforderlichen Operationen und Entitäten zu beschreiben.
Was bedeutet nicht blockierender Rückstau? 背压 ist die Kurzform von back pressure. Einfach gesagt: Ein Produzent sendet Daten an einen Konsumenten. Wenn dieser mit der Verarbeitung nicht mehr nachkommt, teilt er es dem Produzenten mit, woraufhin dieser seine Produktionsrate senkt. Am einfachsten lässt sich dieser Mechanismus blockierend umsetzen, indem beim Senden unmittelbar Angaben zum Verarbeitungsdruck zurückgegeben werden. Eine nicht blockierende Umsetzung erhöht die Komplexität des Entwurfs, verbessert zugleich aber die Leistung. PS: Ich finde, 背压 ist keine gute Übersetzung; aus den Schriftzeichen allein erschließt sich die Bedeutung nicht. Wäre 反压, also „Gegendruck“, nicht besser?^\_^
Damit ein Konsument unter enormem Ressourcendruck (pressure) nicht möglicherweise zusammenbricht, muss die Geschwindigkeit des Datenstroms geregelt werden. Diese Flusskontrolle (flow control) verhindert, dass ein schneller Datenstrom das Ziel überlastet. Deshalb ist Gegendruck beziehungsweise Rückstau (back pressure) erforderlich: Produzent und Konsument müssen über einen entsprechenden Mechanismus zusammenarbeiten. Dieser Mechanismus muss asynchron und nicht blockierend umgesetzt werden. Bei einer synchronen, blockierenden Umsetzung müsste der Produzent warten, während der Konsument Daten verarbeitet, was Leistungsprobleme verursachen würde.
Reactive Streams definiert eine Reihe von Entitäten, Schnittstellen und Interoperabilitätsmethoden und schafft damit einen Standard für die Implementierung nicht blockierenden Gegendrucks. Drittanbieter setzen diesen Standard in konkreten Lösungen um; verbreitete Beispiele sind Reactor, RxJava, Akka Streams und Ratpack.
JDK 10 Threadlokale Steuerung
Safepoint und seine Schwächen:
Ein Safepoint ist ein Mechanismus in der HotSpot JVM, der alle Anwendungsprogramme zum Anhalten bringt. Für bestimmte Arbeiten auf niedriger Ebene muss die JVM ein Stop The World durchführen und alle Anwendungsthreads anhalten. Sie kann diese jedoch nicht einfach gewaltsam stoppen, sondern sendet ihnen ein Anweisungssignal, das ihnen mitteilt: Du musst jetzt anhalten. Sobald ein Anwendungsthread dann einen Safepoint erreicht, befolgt er die Anweisung und reagiert darauf. Daher der Name Safepoint. Der Zusatz safe betont, dass die JVM nun bestimmte globale Arbeiten sicher durchführen muss; deshalb wurde dieser Punkt mit safe bezeichnet.
Zu den globalen Sicherheitsvorgängen gehören folgende: 1、Pausen zur Speicherbereinigung 2、Deoptimierung von Code(Code deoptimization). 3、Leeren des Code-Caches. 4、Neudefinition von Klassendateien(Class redefinition, etwa bei Hot-Updates oder Instrumentierung). 5、Aufhebung bevorzugter Sperren(Biased lock revocation). 6、Verschiedene Debugging-Vorgänge(zum Beispiel: Prüfung auf Deadlocks oder Ausgabe eines Stacktraces usw.).
Allerdings dauert es schon recht lange, alle Threads am jeweils nächstgelegenen Safepoint anzuhalten. Und wirkt es nicht etwas zu rabiat und willkürlich, sämtliche Threads anzuhalten? Deshalb führte Java10 ein Verfahren ein, bei dem nicht alle Threads gestoppt werden müssen:die threadlokale Steuerung(Thread Local Handshake).
Beispielsweise lassen sich folgende Szenarien bewältigen, ohne alle Threads anzuhalten: 1, Widerruf einer voreingenommenen Sperre (Biased Lock). Dafür muss nur ein einzelner Thread angehalten werden, nicht sämtliche Threads. 2, Verringerung der gesamten Auswirkungen verschiedener Arten von Diagnoseabfragen auf die VM-Latenz. Beispielsweise kann das Abrufen der Stacktraces aller Threads einer VM mit sehr vielen Java-Threads langsam sein. 3, Sicherere Stichproben von Stacktraces durch eine geringere Abhängigkeit von Signalen (signals). 4, Beseitigung einiger Speicherbarrieren durch Handshakes mit Java-Threads mithilfe der sogenannten asymmetrischen Dekker-Synchronisation. Beispielsweise benötigt der in G1 und CMS verwendete „Code zur bedingten Kartenmarkierung“ (conditional card mark code) dann keine „Speicherbarriere“ mehr. Dadurch lässt sich die von G1 ausgegebene „Schreibbarriere (write barrier)“ optimieren, und auch die Verzweigungen, die eine „Speicherbarriere“ zu umgehen versuchen, können entfernt werden.
JDK 15 deaktiviert Biased Locking und stuft es als veraltet ein
Warum sollen bevorzugte Sperren abgeschafft werden? Die Leistungssteigerungen, die sie früher brachten, fallen heute weniger deutlich aus. Anwendungen, die von bevorzugten Sperren profitieren, verwenden häufig frühe Java-Collection-APIs (JDK 1.1). Diese APIs (Hashtable und Vector) synchronisieren jeden Zugriff. JDK 1.2 führte nicht synchronisierte Collections für Einzelthread-Szenarien ein (HashMap und ArrayList), und JDK 1.5 brachte leistungsfähigere nebenläufige Datenstrukturen für Multithreading-Szenarien. Das bedeutet, dass Anwendungen, die aufgrund unnötiger Synchronisierung von bevorzugten Sperren profitieren, möglicherweise erhebliche Leistungssteigerungen erzielen, wenn ihr Code auf neuere Klassen umgestellt wird. Außerdem laufen Anwendungen, die auf Threadpool-Warteschlangen und Arbeitsthreads aufbauen, in der Regel besser, wenn bevorzugte Sperren deaktiviert sind.
Im Folgenden eine Implementierung mithilfe der APIs von Hashtable und Vector: java.lang.Classloader verwendet Vector java.util.Properties extends Hashtable java.security.Provider extends Properties java.net.URL verwendet Hashtable java.net.URConnection verwendet Hashtable java.util.ZipOutputStream verwendet Vector javax.management.timer.TimerMBean hat Vector in der Schnittstelle
Das Zeitalter des autonomen Fahrens
Virtuelle Threads lassen Java in das Zeitalter des autonomen Fahrens eintreten. Viele Sprachen verfügen über ähnliche Technologien wie „virtuelle Threads“, etwa Go, C#, Erlang und Lua; dort werden sie als „Koroutinen“ bezeichnet. Diesmal wurden in Java keinerlei neue Schlüsselwörter und nicht einmal neue Konzepte eingeführt. Virtuelle Threads sind viel leichter zu verstehen als goroutine und Koroutinen: Schon der Name lässt ungefähr erkennen, was sie tun.
JDK 19 Virtuelle Threads
Im herkömmlichen Java-Threadmodell entsprechen Threads den Betriebssystem-Threads im Verhältnis 1:1. Das Erstellen und Wechseln von Threads ist aufwendig, und aufgrund der Beschränkungen des Betriebssystems lässt sich nur eine begrenzte Anzahl erstellen. Bei sehr hoher Parallelität kann nicht für jede Anfrage ein eigener Thread erstellt werden. Threadpools können das Problem abmildern, indem sie den Aufwand für die Threaderstellung reduzieren, sie können jedoch die Anzahl der Threads nicht erhöhen. Liegen 2000 gleichzeitige Anfragen vor und enthält der Threadpool nur 1000 Threads, können zum selben Zeitpunkt nur 1000 Anfragen bearbeitet werden. Die übrigen 1000 Anfragen können nicht bearbeitet werden. Sie können abgewiesen werden oder warten, bis ein Thread frei wird.
Vor virtuellen Threads bestand der Lösungsansatz in einem asynchronen Programmierstil. Viele Frameworks haben bereits asynchrone nebenläufige Programmierung umgesetzt, etwa Reactor in Spring5, und erreichen durch die gemeinsame Nutzung von Threads eine höhere Verfügbarkeit. Das Prinzip besteht darin, durch gemeinsam genutzte Threads die Anzahl der Thread-Wechsel und damit den Ressourcenverbrauch zu verringern und zugleich Blockierungen zu vermeiden. Threads werden nur während der Programmausführung genutzt; muss das Programm warten, belegt es keinen Thread. Der asynchrone Stil bringt tatsächlich einige Verbesserungen, hat aber auch Nachteile. Die meisten asynchronen Frameworks verwenden verkettete Aufrufe und teilen das Programm in viele Schritte auf, die jeweils in unterschiedlichen Threads ausgeführt werden können. Vertraute APIs für nebenläufige Programmierung wie ThreadLocal lassen sich dann nicht mehr ohne Weiteres verwenden, da sonst Fehler auftreten können. Auch der Programmierstil verändert sich stark: Er ist deutlich komplexer als beim herkömmlichen Modell, erfordert einen hohen Lernaufwand und kann es nötig machen, viele bestehende Module eines Projekts an das asynchrone Modell anzupassen.
Das Funktionsprinzip virtueller Threads ähnelt dem einiger asynchroner Frameworks: Auch hier werden Threads gemeinsam genutzt, weshalb natürlich kein Pooling erforderlich ist. Bei der Verwendung können Sie davon ausgehen, dass virtuelle Threads unbegrenzt verfügbar sind, und beliebig viele erstellen, ohne sich über Probleme sorgen zu müssen. Darüber hinaus unterstützen virtuelle Threads das Debugging und werden von den einschlägigen Java-Überwachungswerkzeugen unterstützt, was sehr wichtig ist. Virtuelle Threads verringern den Speicherbedarf Ihres Programms erheblich. Alle E/A-intensiven Anwendungen, etwa Webserver, können dadurch bei gleicher Hardware ihren E/A-Durchsatz deutlich steigern. Wo zuvor mit 1 GB Arbeitsspeicher 1000 gleichzeitige Zugriffe bedient werden konnten, lassen sich nach offiziellen Angaben mit virtuellen Threads problemlos 100 mal zehntausend gleichzeitige Zugriffe verarbeiten. Ob dies im konkreten Geschäftsszenario tragfähig ist, muss ein Lasttest zeigen. Aber selbst mit einem Abschlag sollten 10 mal zehntausend problemlos erreichbar sein, ohne dass Sie dafür etwas aufwenden müssen; möglicherweise müssen Sie nicht einmal den Code ändern. Denn virtuelle Threads ermöglichen es, den traditionellen Programmierstil beizubehalten, also einen Thread pro Anfrage. Sie verwenden virtuelle Threads wie gewöhnliche Threads, sodass am Programm nur sehr wenige Änderungen erforderlich sind. Virtuelle Threads führen auch keine neue Syntax ein; der Lern- und Migrationsaufwand ist somit äußerst gering.
Erwähnenswert ist, dass virtuelle Threads intern weiterhin das Fork/Join-Framework verwenden.