Zusammenfassung von „Ein praktischer Leitfaden zur Multithread-Programmierung“

Für Java-Parallelität und Multithread-Programmierung empfehlen wir „Java Concurrent Programming in Practice“ und „Multi-Threaded Programming in Practice“. Ersteres ist eine Übersetzung eines sehr beliebten ausländischen Buches und letzteres ist ein Buch, das von einem Chinesen geschrieben wurde und entspricht dem chinesischen Denkmodell.

Prozesse, Threads und Aufgaben

Im Betriebssystem werden mehrere Programme ausgeführt. Ein laufendes Programm ist ein Prozess. Beispielsweise ist eine laufende Idee ein Prozess und eine Java Virtual Machine ist ein Prozess. Ein Prozess ist die Grundeinheit für ein Programm, um Ressourcen vom Betriebssystem zu beantragen.

Ein Prozess kann mehrere Threads enthalten, und alle Threads im selben Prozess teilen sich Ressourcen im Prozess, wie z. B. Speicherplatz, Dateihandles usw. Ein Thread ist die kleinste Einheit in einem Prozess, die unabhängig ausgeführt werden kann.

Die vom Thread auszuführende Berechnung wird als Aufgabe bezeichnet. Ein bestimmter Thread führt immer eine bestimmte Aufgabe aus.

Ein Programm muss oft viele unabhängige Aufgaben erledigen, das heißt, es enthält mehrere Threads. Das Betriebssystem abstrahiert die Änderungen von Prozessen und Threads. Auf diese Weise muss das Betriebssystem nur den Prozess verwalten, und der Prozess verwaltet viele Threads. Dadurch können die Kosten für die direkte Verwaltung durch das Betriebssystem gesenkt werden. Thread-Komplexität.

Implementierung von Threads in Java

Java ist eine objektorientierte Designsprache, daher ist Thread auch ein Objekt in Java. Es handelt sich um die Java-Standardklassenbibliothek java.lang.Thread. Eine Instanz der Thread-Klasse oder ihrer Unterklassen ist ein Thread.

Zwei häufig verwendete Konstruktoren der Thread-Klasse sind: Thread() und Thread(Runnable target). Entspricht den beiden Möglichkeiten, Threads in Java zu erstellen.Eine besteht darin, den ersten Konstruktor zu verwenden, die Thread-Klasse zu erben und die start()-Methode aufzurufen, und die zweite besteht darin, die Runnable-Schnittstelle zu implementieren und Übergeben Sie es dann an den 2. Konstruktor und rufen Sie die start()-Methode auf.

Die Verarbeitungslogik des Threads muss in die Methode run () geschrieben werden, der Thread kann jedoch nicht durch Aufrufen der Methode run () gestartet werden. Stattdessen muss die Methode start () aufgerufen werden. Aus dem Laufzeitdatenbereich der JVM verfügt der Thread über einen Programmzähler, einen Stapel für virtuelle Maschinen und einen Stapel für lokale Methoden, die von der JVM zugewiesen werden müssen. Die Methode start () fordert die JVM auf, diese Ressourcen zuzuweisen und eine zu beantragen Thread vom Betriebssystem, und der Aufruf der run()-Methode führt nur die Verarbeitungslogik im aktuellen Thread aus. Es ist ersichtlich, dass die Kosten für die Erstellung von Thread-Objekten höher sind als für gewöhnliche Objekte.

Threads in Java haben eine hierarchische Beziehung. Andere im aktuellen Thread erstellte Threads werden als untergeordnete Threads des aktuellen Threads bezeichnet. Untergeordnete Threads können auch untergeordnete Threads haben. Die Standardwerte einiger Attribute des untergeordneten Threads erben das Attribut Werte im übergeordneten Thread, wie Thread-Priorität, ob es sich um einen Daemon-Thread handelt usw. Obwohl zwischen Threads eine hierarchische Eltern-Kind-Beziehung besteht, besteht keine notwendige Verbindung zwischen den Lebenszyklen des übergeordneten Threads und des untergeordneten Threads. Das heißt, nach dem Ende des übergeordneten Threads kann der untergeordnete Thread weiter ausgeführt werden und endet des untergeordneten Threads verhindert nicht, dass der übergeordnete Thread weiter ausgeführt wird. .

Thread-Klasse

Attribute

Zu den Attributen des Threads gehören die Thread-Nummer (ID), der Name (Name), ob es sich um einen Daemon-Thread (Daemon) handelt, und die Priorität (Priority).

Die Thread-Nummer wird von der JVM zugewiesen. Zu einem Zeitpunkt, zu dem die JVM ausgeführt wird, ist die Thread-Nummer eindeutig, was bedeutet, dass die JVM die Nummer des vorherigen Threads, der beendet wurde, wiederverwendet. Daher kann dieses Attribut nicht zur eindeutigen Identifizierung eines Threads verwendet werden.

Der Name des Threads kann vom Programmierer festgelegt werden. Der Standard-Thread-Name bezieht sich auf die Thread-Nummer. Der Thread-Definitionsname ist hilfreich für das Code-Debugging und die Problemlokalisierung.

Threads werden in Daemon-Threads und Nicht-Daemon-Threads unterteilt. Der Standardwert dieses Attributs ist derselbe wie der Wert dieses Attributs des übergeordneten Threads. Nicht-Daemon-Threads verhindern die Beendigung des Prozesses, Daemon-Threads jedoch nicht. Das heißt, wenn nur noch der Daemon im Prozess-Thread verbleibt, wird der Prozess beendet, selbst wenn der Daemon-Thread noch läuft. Daemon-Threads eignen sich für die Ausführung unwichtiger Aufgaben, beispielsweise die Überwachung der Ausführung anderer Threads.

Java definiert 10 Prioritäten von 1 bis 10, der Standardwert ist 5. Wenn der Thread einen übergeordneten Thread hat, entspricht die Standardpriorität dem Prioritätswert des übergeordneten Threads. Die in Java definierte Anzahl der Prioritäten unterscheidet sich von der im Betriebssystem. Eine falsche Einstellung dieser Eigenschaft kann zu einem Thread-Aushungern führen. Daher wird eine Änderung dieser Eigenschaft nicht empfohlen.

Methode

  • statischer Thread currentThread(): Diese Methode gibt den aktuellen Thread zurück
  • void run(): wird verwendet, um die Aufgabenverarbeitungslogik des Threads zu definieren
  • void start(): Starten Sie den entsprechenden Thread
  • void join(): Warten Sie, bis die Ausführung des entsprechenden Threads abgeschlossen ist. Wenn Thread A die Join-Methode von Thread B aufruft, wird die Ausführung von Thread A angehalten, bis die Ausführung von Thread B beendet ist.
  • static void yield(): Mit freundlicher Genehmigung des Threads verliert der aktuelle Thread die CPU-Zeitscheibe, aber der aktuelle Thread hat die Möglichkeit, die CPU-Zeitscheibe wieder zu belegen, sodass diese Methode unzuverlässig ist
  • static void sleep(long millis): Bewirkt, dass der aktuelle Thread für eine bestimmte Zeit angehalten wird

Thread-Lebenszyklus

In Java wird der Status eines Threads in der State-Enumerationsklasse innerhalb der Thread-Klasse definiert. Die in der State-Enumerationsklasse enthaltenen Werte sind:

  • NEU: Ein Thread, der erstellt, aber nicht gestartet wurde, befindet sich in diesem Status. Da eine Thread-Instanz nur einmal gestartet werden kann, darf sich ein Thread nur einmal in diesem Zustand befinden.
  • RUNNABLE: In diesem Zustand befinden sich laufende Threads und Threads, die auf CPU-Zeitscheiben warten (Bereitschaftsstatus).
  • BLOCKIERT: Wenn ein Thread einen blockierenden E/A-Vorgang initiiert, befindet sich der Thread in diesem Zustand. Der Thread in diesem Status belegt keine Prozessorressourcen. Wenn der blockierende E/A-Vorgang abgeschlossen ist, kann der Status des Threads in den Status RUNNABLE konvertiert werden.
  • WAITING: Wenn ein Thread bestimmte Methoden ausführt, wartet er darauf, dass andere Threads andere bestimmte Vorgänge ausführen. Zu den Methoden, die seinen Ausführungsthread in den Status WAITING ändern können, gehören: Object.wait(), Thread.join() und LockSupport.park(Object). Zu den entsprechenden Methoden, die den entsprechenden Thread vom Status WAITING in den Status RUNNABLE ändern können, gehören: Object.notify() und LockSupport.unpark(Object).
  • TIMED_WAITING: Dieser Zustand ähnelt WAITING. Der Unterschied besteht darin, dass der Thread in diesem Zustand nicht unbegrenzt darauf wartet, dass andere Threads bestimmte Vorgänge ausführen, sondern sich in einem zeitlich begrenzten Wartezustand befindet. Der Status des Threads wechselt automatisch zu RUNNABLE, wenn andere Threads einen bestimmten, vom Thread erwarteten Vorgang nicht innerhalb der angegebenen Zeit ausführen.
  • BEENDET: Der Thread, dessen Ausführung abgeschlossen ist, befindet sich in diesem Status. Da eine Thread-Instanz nur einmal gestartet werden kann, kann sich ein Thread auch nur einmal in diesem Zustand befinden.

Flussdiagramm des Thread-Lebenszyklus:
Fügen Sie hier eine Bildbeschreibung ein

Thread-Abschluss

Die Thread-Klasse verfügt über einige Methoden zum Stoppen von Threads: stop(), suspend(), diese Methoden sind jedoch veraltet.

Die Java-Plattform verfügt für jeden Thread über eine einzigartige boolesche Zustandsvariable namens Interrupt-Flag, die angibt, ob der entsprechende Thread einen Interrupt empfangen hat. Ein Interrupt-Flag-Wert von „true“ zeigt an, dass der entsprechende Thread einen Interrupt empfangen hat. Der Ziel-Thread kann den Interrupt-Markierungswert des Threads durch Aufrufen von Thread.currentThread().isInterrupted() abrufen oder den Interrupt-Markierungswert durch Aufrufen von Thread.interrupted() abrufen und zurücksetzen, d. h. Thread.interrupted() wird zurückgegeben den aktuellen Thread. Der Wert des Interrupt-Flags und setzt das Interrupt-Flag des aktuellen Threads auf „false“ zurück. Der Aufruf von interrupt() für einen Thread entspricht dem Setzen des Interrupt-Flags des Threads auf true.

Die vom Ziel-Thread nach Überprüfung der Interrupt-Markierung ausgeführten Vorgänge werden als Antwort des Ziel-Threads auf den Interrupt oder kurz Interrupt-Antwort bezeichnet. Bei einem initiierenden Thread-Urheber und einem Ziel-Thread-Ziel umfasst die Reaktion des Ziels auf Interrupts im Allgemeinen Folgendes:

  • Kein Effekt. Der Aufruf von target.interrupt() durch den Urheber hat keine Auswirkungen auf die Ausführung von target. Diese Situation kann auch als Versäumnis des Zielthreads bezeichnet werden, auf den Interrupt zu reagieren. Blockierende Methoden/Vorgänge wie InputStream.read(), ReentrantLock.lock() und die Beantragung interner Sperren fallen in diese Kategorie.
  • Brechen Sie die Ausführung der Aufgabe ab. Der Aufruf von target.interrupt() durch den Urheber führt dazu, dass die vom Ziel zum Zeitpunkt der Erkennung des Interrupts ausgeführte Aufgabe abgebrochen wird. Dies hat jedoch keinen Einfluss auf die weitere Verarbeitung anderer Aufgaben durch das Ziel.
  • Der Arbeitsthread wird gestoppt. Der Aufruf von target.interrupt() durch den Urheber führt dazu, dass das Ziel beendet wird, d. h. der Lebenszyklusstatus des Ziels ändert sich in TERMINATED.

Viele Blockierungsmethoden in der Java-Standardbibliothek, wie Object.wait(), Object.notify(), Thread.sleep() usw., reagieren auf Unterbrechungen, indem sie Ausnahmen wie InterruptedException auslösen. Es gibt auch einige blockierende Methoden wie InputStream.read() und Lock.lock(), die Ausnahmen nicht unterbrechen können.

Die Methode, die auf Interrupts reagieren kann, besteht normalerweise darin, die Interrupt-Markierung vor dem Blockieren zu überprüfen. Wenn der Wert der Interrupt-Markierung wahr ist, wird eine InterruptedException-Ausnahme ausgelöst. Konventionell setzt eine Methode, die eine InterruptedException auslöst, normalerweise das Thread-Interrupt-Flag des aktuellen Threads auf „false“ zurück, wenn sie die Ausnahme auslöst.

Wenn festgestellt wird, dass zu dem Zeitpunkt, an dem der Thread einen Interrupt an den Ziel-Thread sendet, der Ziel-Thread aufgrund der Ausführung einiger Blockierungsmethoden angehalten wurde (der Lebenszyklusstatus ist WAITING oder BLOCKED), wird die JVM möglicherweise auf Wake gesetzt Der Thread wird zu diesem Zeitpunkt hochgefahren, wodurch der Zielthread eine Chance erhält, auf den Interrupt zu reagieren. Daher kann das Senden eines Interrupts an den Ziel-Thread auch dazu führen, dass der Ziel-Thread aufgeweckt wird.

Zu den Gründen für das Stoppen des Threads gehören das normale Stoppen, wenn die run()-Methode die Ausführung beendet, und das abnormale Stoppen, wenn während der Ausführung eine Ausnahme ausgelöst wird. Daher können wir für den Thread ein boolesches Thread-Stopp-Flag setzen. Der Ziel-Thread erkennt, dass das Flag wahr ist, und veranlasst die Rückkehr seiner run()-Methode, wodurch die Beendigung des Threads realisiert wird.

Die zuvor erwähnte Thread-Unterbrechungsmarke ist ebenfalls vom booleschen Typ. Kann sie als Thread-Stoppmarke verwendet werden?

Da die Fadenunterbrechungsmarkierung durch einige Methoden des Zielfadens gelöscht werden kann, kann die Fadenunterbrechungsmarkierung aus Gründen der Vielseitigkeit nicht als Fadenstoppmarkierung verwendet werden! Und wenn Sie nur ein boolesches Thread-Stopp-Flag verwenden, wird das Thread-Stopp-Flag nicht überprüft, wenn der Thread einige blockierende Methoden ausführt. Daher müssen wir das Thread-Stopp-Flag und das Thread-Unterbrechungs-Flag in Kombination verwenden.

Wenn Sie den Ziel-Thread stoppen müssen, müssen Sie nicht nur das Thread-Stopp-Flag auf „True“ ändern, sondern auch ein Thread-Interrupt-Flag an den Ziel-Thread senden.

Seriell, parallel und gleichzeitig

Seriell bedeutet, jeweils eine Aufgabe auszuführen, und mehrere Aufgaben werden nacheinander ausgeführt, Aufgabe1 -> Aufgabe2 -> Aufgabe3. Dann ist die Ausführungszeit die Summe der von allen Aufgaben benötigten Zeit.

Parallelität bezieht sich auf die gleichzeitige Ausführung mehrerer Aufgaben. Wenn mehrere Aufgaben gleichzeitig ausgeführt werden, ist die Ausführungszeit die Ausführungszeit der Aufgabe mit der längsten Zeit.

Parallelität bezieht sich auf die Ausführung jeweils einer Aufgabe. Führen Sie beispielsweise zuerst Aufgabe 1 aus, unterbrechen Sie die Ausführung von Aufgabe 1, nachdem Sie Aufgabe 1 für einen bestimmten Zeitraum ausgeführt haben, und führen Sie dann Aufgabe 2 aus. Nachdem Sie Aufgabe 2 für einen bestimmten Zeitraum ausgeführt haben, pausieren Sie Aufgabe 2 und Führen Sie Aufgabe 3 aus und so weiter, bis alle Aufgaben abgeschlossen sind.

Parallelität beschreibt die Situation, in der mehrere Threads auf einer CPU ausgeführt werden. Die Thread-Aufgabe nutzt die Prozessorressourcen möglicherweise vorübergehend nicht, da die CPU-Zeitscheibe abläuft oder E/A blockiert wird. Zu diesem Zeitpunkt ist die Thread-Aufgabe noch nicht abgeschlossen. Um sie vollständig auszunutzen CPU-Ressourcen verbessern die Programmleistung. Das Betriebssystem wartet nicht auf den aktuellen Thread, sondern speichert Ressourcen wie den Aufrufstapel und die aktuellen Anweisungen des aktuellen Threads und führt dann eine andere Thread-Aufgabe aus.

Wettbewerb

Bei der Multithread-Programmierung ist die Ausgabe des Programms für dieselbe Eingabe manchmal korrekt und manchmal falsch. Dieses Phänomen, bei dem die Korrektheit eines Berechnungsergebnisses zeitabhängig ist, wird Race Condition genannt. Race-Bedingungen sind das Ergebnis der Multithread-Programmierung. Selbst wenn die CPU, auf der das Programm ausgeführt wird, ein Single-Core-Prozessor ist, treten Race-Bedingungen auf.

Bedingungen für das Auftreten von Rennbedingungen

Die Generierung von Race-Bedingungen geht mit dem Zugriff auf gemeinsam genutzte Variablen unter Multithreads einher. Die Bedingung für die Generierung von Race-Bedingungen istEin Thread liest die gemeinsam genutzten Variablen und führt darauf basierende Operationen aus Die gemeinsam genutzten Variablen. Während der Berechnung hat ein anderer Thread den Wert der gemeinsam genutzten Variablen aktualisiert, was dazu führte, dass fehlerhafte Daten gelesen wurden oder die aktualisierten Ergebnisse verloren gingen.

Bei lokalen Variablen greifen verschiedene Threads auf ihre eigenen Kopien zu und es findet keine gemeinsame Nutzung statt. Die Verwendung lokaler Variablen führt also nicht zu einer Race-Bedingung!

Rennmodus

  • Lesen-Ändern-Schreiben

Das in diesem Modus beschriebene Szenario besteht darin, dass Thread A einen Satz gemeinsam genutzter Variablen liest und den Wert der gemeinsam genutzten Variablen aktualisiert. Bevor der geänderte Wert wieder mit dem Hauptspeicher synchronisiert wird, liest Thread B den alten Wert der gemeinsam genutzten Variablen aus dem Hauptspeicher .Wert, was zum Lesen schmutziger Daten führt. Thread B verlässt sich weiterhin auf den alten Wert der gemeinsam genutzten Variablen, um das Ergebnis der Aktualisierung der gemeinsam genutzten Variablen zu berechnen. Dieses Ergebnis ist ein falsches Ergebnis. Anschließend synchronisiert Thread B das falsche Ergebnis mit dem Hauptwert Speicher. Schließlich wird Thread A aktualisiert. Bei der Synchronisierung mit dem Hauptspeicher überschreibt Thread A die Aktualisierungen von Thread B, was zu verlorenen Aktualisierungen führt.

Die zweidimensionale Tabelle zum Lesen, Ändern und Schreiben lautet wie folgt:

Zeit/Thread Thread A Thread B
t1 Liest die gemeinsam genutzte Variable var aus dem Hauptspeicher
t2 Ändern Sie die gemeinsam genutzte Variablenvariable in der CPU
t3 Liest die gemeinsam genutzte Variable var aus dem Hauptspeicher
t4 Ändern Sie die gemeinsam genutzte Variablenvariable in der CPU
t5 Synchronisieren Sie aktualisierte gemeinsam genutzte Variablen mit dem Hauptspeicher
t6 Synchronisieren Sie aktualisierte gemeinsam genutzte Variablen mit dem Hauptspeicher
  • Prüfen und dann handeln

Das in diesem Modus beschriebene Szenario besteht darin, dass Thread A den Wert der gemeinsam genutzten Variablen liest und den Wert in der bedingten Beurteilungsanweisung verwendet, um die nachfolgende Ausführung von Codeblock C1 zu bestimmen (z. B. die Verwendung einer bedingten Beurteilung), nachdem Thread A die Bedingung ausgeführt hat Urteilsanweisung, C1 wird ausgeführt. Vor dem Codeblock änderte ein anderer Thread B den Wert der gemeinsam genutzten Variablen, was dazu führte, dass die auf der gemeinsam genutzten Variablen basierende Beurteilung Codeblock C2 ausführte, Thread A jedoch weiterhin Codeblock C1 ausführte.

Die zweidimensionale Tabelle von Check-Then-Act lautet wie folgt:

Zeit/Thread Thread A Thread B
t1 Liest die gemeinsam genutzte Variable var aus dem Hauptspeicher
t2 Bestimmen Sie die gemeinsam genutzte Variable var und den Ausführungscodeblock C1
t3 Liest die gemeinsam genutzte Variable var aus dem Hauptspeicher
t4 Ändern Sie die gemeinsam genutzte Variable var in der CPU (Codeblock C2 wird basierend auf dem neuen gemeinsam genutzten Wert ausgeführt).
t5 Synchronisieren Sie aktualisierte gemeinsam genutzte Variablen mit dem Hauptspeicher
t6 Codeblock C1 ausführen

Thread-Sicherheit

Wenn in der Java-Multithread-Programmierung eine Klasse normal in einer Single-Thread-Umgebung und in einer Multi-Thread-Umgebung normal ausgeführt werden kann, ohne dass ihre Benutzer Änderungen vornehmen müssen, dann nennen wir sie Thread-sicher. Wenn andererseits eine Klasse in einer Single-Thread-Umgebung normal ausgeführt wird, in einer Multi-Thread-Umgebung jedoch nicht normal ausgeführt werden kann, ist die Klasse nicht threadsicher.

Wenn eine Klasse nicht Thread-sicher ist, spricht man von Thread-Sicherheitsproblemen, wenn sie direkt in einer Multithread-Umgebung verwendet wird. Die Java-Standardbibliothek definiert threadsichere Klassen wie Vector, CopyOnWriteArrayList und HashTable sowie nicht threadsichere Klassen wie ArrayList, HashMap usw.

Thread-Sicherheitsprobleme werden im Allgemeinen in drei Aspekten ausgedrückt: Atomizität, Sichtbarkeit und Ordnung.

Atomizität

Atomar bedeutet wörtlich unteilbar. Bei Operationen mit Zugriff auf gemeinsam genutzte Variablen gilt:Wenn die Operation aus der Perspektive eines anderen Threads als seines Ausführungsthreads unteilbar ist, dann ist die Operation atomar. . Dementsprechend sagen wir, dass diese Operation atomar ist.

In der Java-Sprache sind Schreib- und Leseoperationen für Variablen atomare Operationen, die von der JVM garantiert werden.

Java bietet verschiedene Möglichkeiten, die Atomizität einer Reihe von Operationen zu erreichen, z. B. die Verwendung von Sperren und die Verwendung von CAS. Dies zeigt auch, dass Sperren und CAS die Atomizität von Operationen garantieren.

Um das Konzept atomarer Operationen zu verstehen, müssen Sie die folgenden zwei Punkte beachten:

  • Atomare Operationen sind für Operationen, die auf gemeinsam genutzte Variablen zugreifen. Mit anderen Worten, es spielt keine Rolle, ob Operationen, die nur den Zugriff auf lokale Variablen beinhalten, atomar sind oder diese Art von Operationen einfach als atomare Operationen behandeln.
  • Eine atomare Operation wird von einem anderen Thread als dem Ausführungsthread der Operation beschrieben, was bedeutet, dass sie nur in einer Multithread-Umgebung sinnvoll ist.

Sichtweite

In einer Multithread-Umgebung können nachfolgende Threads, die auf die Variable zugreifen, das aktualisierte Ergebnis möglicherweise nicht sofort oder sogar nie lesen, nachdem ein Thread eine gemeinsam genutzte Variable aktualisiert hat. Dies ist eine weitere Manifestation von Thread-Sicherheitsproblemen: Sichtbarkeit.

Da zwischen der CPU-Verarbeitungsgeschwindigkeit und der Speicherzugriffsgeschwindigkeit eine Größenordnungslücke besteht, greift die CPU nicht direkt auf den Hauptspeicher zu, sondern indirekt über Register und Caches. Variablen werden zunächst aus dem Hauptspeicher in den Cache geladen. und die CPU greift indirekt aus dem Hauptspeicher darauf zu. Lesen Sie den Wert einer Variablen aus dem Cache. Beim Update schreibt die CPU das Update zunächst in den Cache und synchronisiert es irgendwann in der Zukunft mit dem Hauptspeicher. Der zusätzliche Cache führt zu Sichtbarkeitsproblemen zwischen mehreren Threads.

Java stellt das Schlüsselwort Volatile bereit, um die Thread-Sichtbarkeit gemeinsamer Variablen sicherzustellen. Variablen, die mit dem Schlüsselwort „Volatile“ gekennzeichnet sind, aktualisieren den Wert der Variablen aus dem Hauptspeicher, bevor der Thread ihn aus dem Cache liest. Nachdem der Thread die Variable in den Cache geschrieben hat, wird der aktualisierte Wert sofort mit dem Hauptspeicher synchronisiert.

Gleichzeitig sorgt die JVM für die Sichtbarkeit folgender Szenarien:

  • Aktualisierungen gemeinsamer Variablen durch den übergeordneten Thread vor dem Start des untergeordneten Threads sind für den untergeordneten Thread sichtbar.
  • Aktualisierungen gemeinsam genutzter Variablen durch einen Thread nach dessen Beendigung sind für den Thread sichtbar, der die Join-Methode des Threads aufruft.

Diese beiden Szenarien werden jedoch höchstwahrscheinlich nicht in der Arbeit verwendet, da jeder, der new zum Erstellen von Threads verwendet, jetzt Thread-Pools (Hundekopf) verwendet.

Ordentlichkeit

Ordentlichkeit bezieht sich auf die Situation, in der Speicherzugriffsvorgänge, die von einem Thread ausgeführt werden, der auf einem Prozessor ausgeführt wird, für andere Threads, die auf einem anderen Prozessor ausgeführt werden, nicht in der richtigen Reihenfolge erscheinen.

Neuordnung ist eine Optimierung von Vorgängen im Zusammenhang mit dem Speicherzugriff, die die Programmleistung verbessern kann, ohne die Single-Thread-Korrektheit zu beeinträchtigen. Es kann jedoch Auswirkungen auf die Korrektheit von Multithread-Programmen haben, d. h. es kann zu Thread-Sicherheitsproblemen führen.

Die Neuordnung erfolgt an zwei Stellen in einem Java-Programm, wenn Javac zum Kompilieren des Quellcodes verwendet wird und wenn der JIT-Compiler den Bytecode übersetzt.

Zum Beispiel die Operation zum Instanziieren eines Objekts in Java:Object obj = new Object, die drei Operationen im Prozessor entspricht, nämlich:

  1. Weisen Sie den für die Objektinstanz erforderlichen Speicherplatz zu und erhalten Sie eine Referenzreferenz, die auf den Speicherplatz verweist
  2. Rufen Sie den Object-Konstruktor auf, um die Object-Instanz zu initialisieren
  3. Kopiert die Objektinstanzreferenzreferenz in die Instanzvariable obj

Diese drei Operationen werden möglicherweise nicht nacheinander im Prozessor ausgeführt, und Operation 3 wird möglicherweise vor Operation 2 ausgeführt, sodass die Instanz, auf die obj zeigt, möglicherweise noch nicht initialisiert wurde und die Verwendung dieser nicht initialisierten Instanz unvorhersehbare Fehler verursacht.

Die Neuordnung von Anweisungen kann die Leistung verbessern, sodass die Neuordnung von Anweisungen nicht vollständig verboten werden kann. Das Betriebssystem stellt jedoch Anweisungen bereit, um die Neuordnung von Anweisungen teilweise zu verhindern, dh Codefragmente, die mehreren Threads den Zugriff auf gemeinsam genutzte Variablen ermöglichen, verhindern die Neuordnung von Anweisungen.

Die Möglichkeiten zur Gewährleistung der Ordnung in Java sind: Sperren und flüchtige Schlüsselwörter.

Schlösser unterteilen den Code in drei Bereiche: vor dem Erwerb des Schlosses, kritischer Abschnitt und nach dem Erwerb des Schlosses. Die Neuordnung von Anweisungen ist innerhalb dieser drei Bereiche weiterhin zulässig, eine Neuordnung von Anweisungen zwischen Bereichen ist jedoch nicht zulässig.

Das Schlüsselwort volatile verhindert mithilfe einer Speicherbarriere die Neuordnung von Regionsanweisungen vor dem Zugriff auf eine gemeinsam genutzte Variable.

Thread-Synchronisationsmechanismus

Zu den von der Java-Plattform bereitgestellten Thread-Synchronisationsmechanismen gehören Sperren, flüchtige Schlüsselwörter, CAS, endgültige Schlüsselwörter, statische Schlüssel und einige verwandte APIs wie Object.wait ()/Object.notify () usw.

Sperren

Sperren in der Java-Plattform werden in interne Sperren und explizite Sperren unterteilt. Interne Sperren werden über das synchronisierte Schlüsselwort implementiert, und explizite Sperren werden über die Implementierungsklasse der java.concurrent.locks.Lock-Schnittstelle implementiert.

Der Code, der von der Sperre während des Zeitraums nach Erhalt der Sperre durch den Thread und vor der Freigabe der Sperre ausgeführt wird, wird alskritischer Abschnitt bezeichnet.

Sperren schützen gemeinsam genutzte Daten, um Thread-Sicherheit zu erreichen, einschließlich der Gewährleistung von Atomizität, Sichtbarkeit und Reihenfolge.

Sperren garantieren Atomizität durch gegenseitigen Ausschluss. Der sogenannte gegenseitige Ausschluss bedeutet, dass eine Sperre jeweils nur von einem Thread gehalten werden kann.

Wir wissen, dass die Sichtbarkeit durch zwei Aktionen gewährleistet wird: Der Schreibthread leert den Prozessorcache und der Lesethread leert den Prozessorcache. Der Erwerb der Sperre impliziert das Leeren des Prozessor-Cache, und die Freigabe der Sperre impliziert das Leeren des Prozessor-Cache. Daher garantieren Schlösser die Sichtbarkeit.

Sperren können auch für Ordnung sorgen. Sperren unterteilen den Code vor dem Erwerb der Sperre, dem kritischen Bereich und nach der Freigabe der Sperre in drei Bereiche. Anweisungen dürfen innerhalb des Bereichs neu angeordnet werden, eine Neuordnung von Anweisungen zwischen Bereichen ist jedoch verboten.

Wiedereintrittssperre

Ein Thread kann eine Sperre erneut erhalten, während er sie hält.

Lese-/Schreibsperre

Lese-/Schreibsperren ermöglichen es mehreren Threads, gemeinsam genutzte Variablen gleichzeitig zu lesen, erlauben jedoch jeweils nur einem Thread, gemeinsam genutzte Variablen zu aktualisieren.

flüchtiges Schlüsselwort

Das Schlüsselwort volatile stellt die Sichtbarkeit und Reihenfolge gemeinsamer Variablen sicher.

CAS

CAS (Compare and Swap) ist der Name einer Prozessoranweisung. CAS ist eine atomare Wenn-Dann-Handlungsoperation.

Das heißt, CAS garantiert Atomizität.

statisches Schlüsselwort und letztes Schlüsselwort

Die Initialisierung von Klassen in Java basiert tatsächlich auf Lazy Loading. Das heißt, nachdem eine Klasse von der JVM geladen wurde, bleiben die Werte aller statischen Variablen der Klasse auf ihren Standardwerten, bis ein Thread darauf zugreift Jede statische Variable der Klasse zum ersten Mal. Variablen ermöglichen die Initialisierung dieser Klasse – der statische Initialisierungsblock („static{}“) der Klasse wird ausgeführt und allen statischen Variablen der Klasse werden Anfangswerte zugewiesen.

public class ClassLazyInitDemo {
    
    
    public staic void main(String[] args) {
    
    
        System.in.out.println(Collaborator.class.hashCode());	//语句1
        System.in.out.println(Collaborator.number);				//语句2
        System.in.out.println(Collaborator.flag);
    }
    
    static class Collaborator {
    
    
        static int number = 1;
        static boolean flag = true;
        static {
    
    
            System.in.out.println("Collaborator initializing...");
        }
    }
}

In der obigen Demo bewirkt Anweisung 1 nur, dass die Klasse von der JVM geladen wird, initialisiert sie jedoch nicht (dh der statische Block wird ausgeführt), sondern wird nur initialisiert, wenn Anweisung 2 ausgeführt wird.

Das Schlüsselwort static hat in einer Multithread-Umgebung eine besondere Bedeutung. Es stellt sicher, dass ein Thread immer den Anfangswert der statischen Variablen einer Klasse anstelle des Standardwerts lesen kann, auch wenn kein anderer Synchronisationsmechanismus verwendet wird. Diese Sichtbarkeitsgarantie ist jedoch auf das erste Mal beschränkt, dass der Thread die Variable liest. In diesem Szenario garantiert das Schlüsselwort static die Sichtbarkeit.

Bei statischen Referenzvariablen kann das Schlüsselwort static auch sicherstellen, dass das Objekt, auf das dieser Wert zeigt, initialisiert wurde, wenn ein Thread den Anfangswert der Variablen liest. In diesem Szenario garantiert das Schlüsselwort static Ordnung.

Das Schlüsselwort final stellt sicher, dass andere Threads beim Zugriff auf gemeinsam genutzte Variablen immer den Anfangswert der Variablen anstelle des Standardwerts lesen können. In diesem Szenario garantiert das letzte Schlüsselwort die Sichtbarkeit.

Bei Referenz-Finalfeldern stellt das Schlüsselwort final außerdem sicher, dass das Objekt, auf das das Feld verweist, initialisiert wurde. In diesem Szenario garantiert das letzte Schlüsselwort Ordnung.

Entdecken Sie mögliche Parallelitätspunkte

Um das Ziel der Multithread-Programmierung – Concurrent Computing – zu erreichen, müssen wir zunächst herausfinden, welche Prozesse im Programm gleichzeitig ablaufen können, also von seriell zu gleichzeitig wechseln können. Diese gleichzeitigen Prozesse werden als Parallelitätspunkte bezeichnet.

Parallelität basierend auf Datensegmentierung

Wenn die Größe der ursprünglichen Eingabedaten des Programms relativ groß ist, kann eine datenbasierte Segmentierung verwendet werden. Die Grundidee besteht darin, die ursprünglichen Eingabedaten nach bestimmten Regeln in mehrere kleinere Untereingaben zu zerlegen und diese Untereingaben mithilfe von Arbeitsthreads zu verarbeiten. Jeder Arbeitsthread bildet nach der Verarbeitung Unterausgaben. Schließlich werden wir alle Untereingaben erstellen -Ausgaben werden kombiniert, um die Ausgabe der gesamten gleichzeitigen Aufgabe zu bilden.

Bei der datenbasierten Segmentierungsmethode teilt der Hauptthread die ursprüngliche Eingabe nach bestimmten Regeln in kleine Unterausgaben auf und erstellt dann einen Arbeitsthread, um jede Unterausgabe zu empfangen. Der Arbeitsthread führt dann alle Verarbeitungsschritte unabhängig aus Um einen Teil des Ergebnisses auszugeben, wartet der Hauptthread darauf, dass alle Arbeitsthreads die Verarbeitung abgeschlossen haben, und führt dann alle Unterergebnisse zusammen, um das Gesamtausgabeergebnis zu erhalten. Der Hauptthread kann beim Aufteilen der Eingabe und Zusammenführen der Ausgabe zu zusätzlichen Leistungseinbußen und Programmkomplexität führen.

Die durch datenbasierte Segmentierung generierten Arbeitsthreads sindhomogene Arbeitsthreads, also Threads mit derselben Aufgabenverarbeitungslogik.

Die Beziehung zwischen Eingabe-, Ausgabe- und Arbeitsthreads:
Fügen Sie hier eine Bildbeschreibung ein

Parallelität basierend auf Aufgabensegmentierung

Die auf der Aufgabensegmentierung basierende Grundidee besteht darin, die ursprüngliche Aufgabe nach bestimmten Regeln in mehrere Unteraufgaben zu zerlegen und diese Unteraufgaben mithilfe dedizierter Arbeitsthreads auszuführen. Zu diesem Zeitpunkt bestehen Abhängigkeiten zwischen den Arbeitsthreads und dem späteren Arbeitsthread Die Eingabe ist oft die Ausgabe des vorherigen Worker-Threads, was zu einer Dateninteraktion zwischen Threads führt und zusätzliche Komplexität hinzufügen kann.

Aufgabenbasierte Segmentierung erzeugtheterogene Arbeitsthreads, also Threads mit unterschiedlicher Aufgabenverarbeitungslogik.

Die Beziehung zwischen Ausgabe-, Ausgabe- und Arbeitsthreads:
Fügen Sie hier eine Bildbeschreibung ein

Dienstprogrammklasse für die Java-Thread-Synchronisierung

Object.wait()/Object.notify()

Auf der Java-Plattform können Object.wait()/Object.wait(long) und Object.notify()/Object.notifyAll() zum Implementieren von Warten und Benachrichtigungen verwendet werden. Object.wait() kann dazu führen, dass der Thread in das WAITING eintritt state.Object.notify() kann einen Thread aufwecken, der in den Status WAITING eingetreten ist. Dementsprechend wird der Ausführungsthread von Object.wait() als Wartethread bezeichnet, der Ausführungsthread von Object.notify() als Benachrichtigungsthread. Da die Object-Klasse die übergeordnete Klasse jedes Objekts in Java ist, können Warten und Benachrichtigungen mit jedem Objekt in Java implementiert werden.

Der Vorlagencode für die Verwendung von Object.wait() zum Implementieren des Thread-Wartens lautet wie folgt:

// 在调用 wait 方法前需获得相应对象的内部锁
synchronized(someObject) {
    
    
    while(保护条件不成立) {
    
    
        // 调用 Object.wait() 暂停当前线程
        someObject.wait();
    }
    
    // 代码执行到这里说明保护条件已经满足
    // 执行目标动作
    doAction();
}

Die Schutzbedingung ist ein boolescher Ausdruck, der gemeinsam genutzte Variablen enthält. Wenn die gemeinsam genutzten Variablen von anderen Threads (Benachrichtigungsthreads) aktualisiert werden und die entsprechenden Schutzbedingungen erfüllt sind, benachrichtigen diese Threads die wartenden Threads. Da ein Thread die Wartemethode eines Objekts nur aufrufen kann, wenn er die interne Sperre des Objekts hält, werden Object.wait()-Aufrufe immer im kritischen Abschnitt platziert, der vom entsprechenden Objekt geleitet wird.

Hinweis: Während der Zeit, in der der wartende Thread aufgeweckt wird und weiterläuft, bis er wieder die interne Sperre des entsprechenden Objekts hält, können andere Threads präventiv die entsprechende interne Sperre erhalten und die relevanten gemeinsam genutzten Variablen aktualisieren, was zu dem von der erforderlichen Schutz führt Thread. Die Bedingung ist nicht erfüllt. Daher sollten die Beurteilung der Schutzbedingung und der Aufruf von Object.wait () in die Schleifenanweisung eingefügt werden, um sicherzustellen, dass die Zielaktion nur ausgeführt werden kann, wenn die Schutzbedingung wahr ist!

Der Ausführungsablauf des obigen Vorlagencodes ist wie folgt:

  1. Der aktuelle Thread erhält die interne Sperre von someObject und tritt in den synchronisierten Codeblock ein.
  2. Stellen Sie fest, ob die Schutzbedingungen erfüllt sind
  3. Wenn die Schutzbedingung nicht erfüllt ist, rufen Sie die Methode someObject.wait() auf, um den aktuellen Thread anzuhalten. Da someObject.notify() in einem synchronisierten Block ausgeführt werden muss, gibt der aktuelle Thread die nach der Unterbrechung gehaltene interne Sperre von someObject frei . Zu diesem Zeitpunkt gibt die Methode wait () noch keine Rückkehr zurück
  4. Andere Threads aktualisieren die Schutzbedingungen im synchronisierten Codeblock und rufen someObject.notify()/someObject.notifyAll() auf, um den auf den Weckvorgang wartenden Thread zu benachrichtigen
  5. Da dieselbe Methode desselben Objekts von mehreren Threads ausgeführt werden kann, gibt es möglicherweise mehrere wartende Threads für das someObject-Objekt. Daher versucht der aktuelle Thread nach dem Aufwachen zunächst, die interne Sperre von someObject zu erhalten. Wenn die interne Wenn die Sperre erworben wird, wird der Körper der while-Anweisung von someObject.wait() zurückgegeben
  6. Stellen Sie erneut fest, ob die Schutzbedingung wahr ist, und führen Sie in diesem Fall die Zielaktion doAction() aus.
  7. Verlassen Sie abschließend den synchronisierten Codeblock und geben Sie die interne Sperre des someObject-Objekts frei

Verwenden Sie Object.notify(), um Benachrichtigungen zu implementieren. Die Codevorlage lautet wie folgt:

// 在调用 notify() 方法前需获得相应对象的内部锁
synchronized(someObject) {
    
    
    // 更新等待线程的保护条件涉及的共享变量
    updateSharedState();
    // 唤醒等待线程
    someObject.notify();
}

Die Methode, die den obigen Vorlagencode enthält, wird als Benachrichtigungsmethode bezeichnet und enthält zwei Elemente: das Aktualisieren gemeinsam genutzter Variablen und das Aufwecken wartender Threads. Da ein Thread die Benachrichtigungsmethode eines Objekts nur ausführen kann, wenn er die interne Sperre des Objekts hält, wird der Aufruf von Object.notify() immer im kritischen Abschnitt platziert, der von der internen Sperre des entsprechenden Objekts geleitet wird. Daher muss Object.wait () die entsprechende interne Sperre aufheben, während sein Ausführungsthread angehalten wird. Andernfalls kann der Benachrichtigungsthread die entsprechende interne Sperre nicht erhalten und die Benachrichtigungsmethode des entsprechenden Objekts nicht ausführen, um den wartenden Thread zu benachrichtigen!

Der Methodenaufruf notify() sollte möglichst nahe am Ende des kritischen Abschnitts platziert werden, damit der wartende Thread nach dem Aufwachen möglichst schnell wieder die entsprechende interne Sperre erhalten kann.

Da wait ()/notify () Probleme wie vorzeitiges Aufwachen, Signalverlust und irreführendes Aufwecken verursachen kann, wird es in der täglichen Arbeit nicht häufig zur Thread-Synchronisierung verwendet. Die Java-Standardklassenbibliothek bietet eine erweiterte Thread-Synchronisierung . Utility-Klassen, wir verwenden diese, um Thread-Synchronisationsprobleme zu lösen, die bei unserer Arbeit auftreten.

CountDownLatch

CountDownLatch kann verwendet werden, um einen oder mehrere Threads zu implementieren, die darauf warten, dass andere Threads einen bestimmten Satz von Vorgängen abschließen, bevor sie mit der Ausführung fortfahren. Dieser Satz von Vorgängen wird als erforderliche Vorgänge bezeichnet.

CountDownLatch verwaltet intern einen Zähler, der die Anzahl der ausstehenden vorausgesetzten Vorgänge darstellt. CountDownLatch.countDown() dekrementiert den Zählerwert der entsprechenden Instanz bei jeder Ausführung um 1. Wenn der Zähler nicht 1 ist, wird der Ausführungsthread von CountDownLatch.await () angehalten, und diese Threads werden als Wartethreads auf dem entsprechenden CountDownLatch bezeichnet. CountDownLatch.countDown() entspricht einer Benachrichtigungsmethode, die alle wartenden Threads auf der entsprechenden Instanz aufweckt, wenn der Zählerwert 0 erreicht. Der Anfangswert des Zählers wird im Konstruktionsparameter von CountDownLatch angegeben, wie in der folgenden Deklaration gezeigt: public CountDownLatch(int count).

Die Verwendung von CountDownLatch ist einmalig. Nachdem der Zählerwert 0 erreicht hat, ändert sich der Zählerwert nicht mehr.

CyclicBarrier

Manchmal müssen mehrere Threads darauf warten, dass der andere Thread irgendwo im Code ausgeführt wird, bevor diese Threads mit der Ausführung fortfahren können.

Threads, die CyclicBarrier zum Implementieren des Wartens verwenden, werden als Teilnehmer bezeichnet. Die Ausführung von CyclicBarrier.await() durch eine andere Partei als die letzte Partei führt dazu, dass der Thread angehalten wird. Der letzte Thread, der CyclicBarrier.await() ausführt, weckt alle anderen Parteien, die die entsprechende CyclicBarrier-Instanz verwenden, aber der letzte Thread selbst wird nicht angehalten.

CyclicBarrier macht es wiederverwendbar.

Blockierungswarteschlange

Die in JDK 1.5 eingeführte Schnittstelle java.util.concurrent.BlockingQueue definiert eine Thread-sichere Warteschlange – Blocking Queue. Blocking Queue kann zum Übertragen von Daten zwischen Threads verwendet werden. Der klassische Anwendungsfall ist ein Producer-Consumer-Modell. Übertragungskanal zwischen Threads .

Blockierungswarteschlangen werden danach unterteilt, ob die Kapazität ihres Speicherplatzes begrenzt ist, und können in begrenzte Warteschlangen und unbegrenzte Warteschlangen unterteilt werden. Die Speicherkapazitätsgrenze begrenzter Warteschlangen wird von der Anwendung angegeben, und die maximale Speicherkapazität unbegrenzter Warteschlangen beträgt Integer.MAX_VALUE.

Wenn eine Methode oder Operation dazu führen kann, dass der Ausführungsthread angehalten wird, nennen wir die entsprechende Methode oder Operation im Allgemeinen eine blockierende Methode oder blockierende Operation. In der Blockierungswarteschlange gibt es sowohl blockierende als auch nicht blockierende Methoden. Das Methodenpaar take ()/put () ist die blockierende Methode und das Methodenpaar poll ()/offer () ist die nicht blockierende Methode.

Semaphor

Die in JDK 1.5 eingeführte Standardbibliotheksklasse java.util.concurrent.Semaphore wird als Semaphor bezeichnet.

Semaphore.acquire()/release() werden verwendet, um Quoten zu beantragen bzw. Quoten zurückzugeben. Semaphore.acquire() kehrt sofort nach dem erfolgreichen Erwerb einer Quote zurück. Wenn die aktuell verfügbare Quote nicht ausreicht, unterbricht Semaphore.acquire() den aktuellen Thread, bis andere Threads die Quote über Semaphore.release() zurückgeben.

Semaphore.acquire() und Semaphore.release() werden immer paarweise verwendet, und der Semaphore.release()-Aufruf sollte immer in einem „finally“-Block platziert werden, um zu vermeiden, dass das vom aktuellen Thread erworbene Kontingent nicht zurückgegeben werden kann. Der Vorlagencode lautet wie folgt:

public void template(){
    
    
    semaphore.acqiure();
    try{
    
    
        doSomething();
    } finally {
    
    
        semaphore.release();
    }
}

PipedInputStream und PipedOutputStream

PipedInputStream und PipedOutputStream sind Unterklassen von InputStream und OutputStream. Sie können verwendet werden, um direkte Ein- und Ausgaben zwischen Threads zu implementieren, ohne dass andere Datenaustauschvermittler wie Dateien, Datenbanken und Netzwerkverbindungen ausgeliehen werden müssen.

PipedInputStream und PipedOutputStream eignen sich für die Verwendung zwischen zwei Threads, dh für Situationen mit einem einzigen Produzenten und einem einzelnen Verbraucher.

Austauscher

Die in JDK 1.5 eingeführte Standardbibliotheksklasse Java.util.concurrent.Exchanger kann zur Implementierung der doppelten Pufferung verwendet werden. Exchanger entspricht einem CyclicBarrier mit nur zwei Teilnehmern.

Wenn der Producer-Thread Exchanger.exchange(V) ausführt, gibt er Parameter x als gefüllten Puffer an. Wenn der Consumer-Thread Exchanger.exchange(V) ausführt, gibt er Parameter x als leeren Puffer an, der verwendet wurde. Bezirk. Nach der Ausführung von Exchanger.exchange(V) wechselt der Producer-Thread in den Wartezustand, bis ein Consumer-Thread die Methode Exchanger.exchange(V) ausführt.

Thread-Lebendigkeitsfehler

Sackgasse

Das Phänomen, dass zwei oder mehr Threads für immer angehalten werden, während sie darauf warten, dass der andere die erforderlichen Sperren freigibt, wird als Deadlock bezeichnet.

Zu den Methoden zur Vermeidung von Deadlocks gehören: Reduzieren der Granularität der Sperre und Festlegen der Reihenfolge, in der mehrere Sperren erworben werden.

Gesperrt

Das Phänomen, dass der Weck-Thread aus irgendeinem Grund beendet wird und der Thread, der geweckt werden muss, für immer in einem Wartezustand bleibt, wird als Deadlock bezeichnet.

Hunger

Es bezieht sich auf das Phänomen, dass Threads die Möglichkeit haben, die erforderlichen Ressourcen abzurufen, aber aufgrund zu großer Parallelität und zu vielen Threads, die darauf warten, die gleichen Ressourcen zu erhalten, sie sich nicht jedes Mal das Recht zum Halten der Ressourcen sichern, was zu einem ständigen Fehler führt um Ressourcen zu erhalten. Dieses Phänomen wird als Thread-Starvation bezeichnet. .

Thread-sicheres Objektdesign

Der Grund für Thread-Sicherheitsprobleme bei der Multithread-Programmierung liegt darin, dass mehrere Threads auf gemeinsam genutzte Variablen desselben Objekts zugreifen. Um die Thread-Sicherheit sicherzustellen, müssen wir Sperren im Codebereich hinzufügen, der auf gemeinsam genutzte Variablen zugreift. Durch das Hinzufügen von Sperren wird sichergestellt, dass mehrere Threads vorhanden sind kann gleichzeitig auf gemeinsam genutzte Variablen zugreifen. Beim Downgrade von der gleichzeitigen Ausführung auf die serielle Ausführung verringert sich auch der Durchsatz des Systems. Durch das Entwerfen einiger Klassen können wir die Thread-Sicherheit auch dann gewährleisten, wenn mehrere Threads auf Objekte zugreifen, ohne einen Thread-Synchronisationsmechanismus zu verwenden. Diese Objekte werden als Thread-sichere Objekte bezeichnet. Dazu gehören zustandslose Objekte, unveränderliche Objekte und threadspezifische Objekte.

zustandsloses Objekt

Ein zustandsloses Objekt enthält keine Instanzvariablen und keine statischen Variablen, oder die darin enthaltenen statischen Variablen sind schreibgeschützt.

unveränderliche Objekte

Ein unveränderliches Objekt ist ein Objekt, dessen Zustand nach seiner Erstellung unverändert bleibt. Unveränderliche Objekte sind von Natur aus threadsicher.

Ein streng unveränderliches Objekt muss alle folgenden Bedingungen erfüllen:

  • Die Klasse selbst ist mit final versehen, um zu verhindern, dass Unterklassen ihr definiertes Verhalten ändern.
  • Alle Felder werden mit final geändert, da die endgültige Änderung nicht nur semantisch angibt, dass der Wert des geänderten Felds nicht geändert werden kann, sondern, was noch wichtiger ist, diese Semantik die Initialisierungssicherheit des geänderten Felds in einer Multithread-Umgebung gewährleistet, d. h. final Modifikation Wenn ein Feld für andere Threads sichtbar ist, muss es initialisiert werden.
  • Das Objekt entkommt während des Initialisierungsprozesses nicht, wodurch andere Klassen daran gehindert werden, seinen Status während des Objektinitialisierungsprozesses zu ändern.
  • Wenn sich ein Feld auf andere veränderbare Objekte wie Sammlungen, Arrays usw. bezieht, müssen diese Felder privat dekoriert werden und die Werte dieser Felder können nicht der Außenwelt zugänglich gemacht werden. Wenn es relevante Methoden gibt, die diese Feldwerte zurückgeben, sollte ein defensives Kopieren durchgeführt werden.

Thread-spezifische Objekte

Jeder Thread erstellt seine eigene Instanz. Ein Objekt, auf das nur ein Thread zugreifen kann, wird als threadspezifisches Objekt bezeichnet. In Java verwenden wir ThreadLocal, um threadspezifische Objekte zu implementieren.

gleichzeitige Sammlung

Das java.util.concurrent-Paket von JDK 1.5 führt einige Thread-sichere Sammlungsobjekte ein, die als gleichzeitige Sammlungen bezeichnet werden. Diese Objekte werden normalerweise als Ersatz für synchronisierte Sammlungen verwendet. Ihre Entsprechung mit häufig verwendeten nicht threadsicheren Sammlungsobjekten Wie in gezeigt die folgende Tabelle:

nicht threadsicheres Objekt Gleichzeitige Sammlungsklasse gemeinsame Schnittstelle Traversal-Implementierung
Anordnungsliste CopyOnWriteArrayList Aufführen Schnappschuss
HashSet CopyOnWriteArraySet Satz Schnappschuss
LinkedList ConcurrentLinkedQueue Warteschlange Quasi in Echtzeit
HashMap ConcurrentHashMap Karte Quasi in Echtzeit
TreeMap ConcurrentSkipListMap SortedMap Quasi in Echtzeit
TreeSet ConcurrentSkipListSet SortedSet Quasi in Echtzeit

Thread-Management

Nicht abgefangene Ausnahme vom Thread

Wenn die Ausführungsmethode des Threads eine nicht abgefangene Ausnahme auslöst, wird der entsprechende Thread vorzeitig beendet, wenn die Ausführungsmethode beendet wird. Für diese abnormale Beendigung von Threads hat JDK 1.5 die Schnittstelle UncaughtExceptionHandler (Handler für nicht erfasste Ausnahmen) eingeführt, um dieses Problem zu lösen. Diese Schnittstelle ist innerhalb der Thread-Klasse definiert. Sie enthält nur eine Methode, es handelt sich also um eine funktionale Schnittstelle:

@FunctionalInterface
public interface UncaughtExceptionHandler {
    
    
    /**
         * Method invoked when the given thread terminates due to the
         * given uncaught exception.
         * <p>Any exception thrown by this method will be ignored by the
         * Java Virtual Machine.
         * @param t the thread
         * @param e the exception
         */
    void uncaughtException(Thread t, Throwable e);
}

In der Thread-Klasse sind zwei UncaughtExceptionHandler definiert, einer ist eine Instanzvariable und der andere eine statische Variable:

// null unless explicitly set
private volatile UncaughtExceptionHandler uncaughtExceptionHandler;

// null unless explicitly set
private static volatile UncaughtExceptionHandler defaultUncaughtExceptionHandler;

Der UncaughtExceptionHandler, auf den die Instanzvariable verweist, ist für jeden Thread eindeutig. Der UncaughtExceptionHandler, auf den die statische Variable verweist, ist allen Threads gemeinsam. Der Thread verwendet zuerst den UncaughtExceptionHandler, auf den die Instanzvariable verweist, nachdem er eine nicht abgefangene Ausnahme ausgelöst hat. Wenn die Instanzvariable null ist ( nicht definiert) wird der UncaughtExceptionHandler der statischen Variablen verwendet. Beide Variablen definieren Getter/Setter-Methoden und Benutzer können den nicht abgefangenen Ausnahmehandler des Threads anpassen.

Bevor die Ausführungsmethode des Threads eine nicht abgefangene Ausnahme auslöst und den Thread beendet, führt die JVM die Methode UncaughtExceptionHandler.uncaughtException() aus. Mit dieser Methode können wir einige sinnvolle Dinge tun, z. B. Informationen über die abnormale Beendigung des Threads in der Protokolldatei aufzeichnen. , sogar einen Ersatzthread für den abnormal beendeten Thread erstellen und starten.

Thread-Pool

Threads sind eine teure Ressource und ihr Overhead umfasst hauptsächlich die folgenden Aspekte.

  • Der Aufwand für die Erstellung und den Start von Threads. Im Vergleich zu gewöhnlichen Objekten belegen Java-Threads außerdem zusätzlichen Speicherplatz – Stapelplatz. Darüber hinaus erzeugt der Start von Threads einen entsprechenden Thread-Planungsaufwand.
  • Thread-Zerstörung.
  • Thread-Planungsaufwand. Die Planung von Threads führt zu einem Kontextwechsel, wodurch der Verbrauch von Prozessorressourcen erhöht und die Prozessorressourcen verringert werden, die die Anwendung selbst nutzen kann.

In Java definieren wir einen Objektpool für große Objekte, um die häufige Erstellung großer Objekte zu vermeiden. Thread ist auch ein Objekt. Wir können den Objektpool auch verwenden, um eine bestimmte Anzahl von Threads zu verwalten. Dies ist der Thread Pool, aber es Die Implementierungsmethode unterscheidet sich vom normalen Objektpool. Eine bestimmte Anzahl von Arbeitsthreads kann im Thread-Pool vorab erstellt werden. Der Clientcode muss keine Threads aus dem Thread-Pool ausleihen, sondern übermittelt die benötigte Aufgabe als Objekt für den Thread-Pool auszuführen. Der Thread-Pool kann diese Aufgaben in der Arbeitswarteschlange zwischenspeichern, und jeder Arbeitsthread innerhalb des Thread-Pools entfernt kontinuierlich Aufgaben aus der Warteschlange und führt sie aus. Daher kann der Thread-Pool als Dienst betrachtet werden, der auf dem Producer-Consumer-Modell basiert. Der intern im Dienst verwaltete Worker-Thread entspricht dem Consumer, der Client-Thread des Thread-Pools entspricht dem Producer-Thread und dem Client Die vom Code an den Thread-Pool übermittelten Aufgaben entsprechen „Produkten“, und die zum Zwischenspeichern von Aufgaben im Thread-Pool verwendete Warteschlange entspricht dem Übertragungskanal.
Fügen Sie hier eine Bildbeschreibung ein
Die Schnittstelle der obersten Ebene des Thread-Pools in Java ist java.util.concurrent.Executor, und die häufig verwendete Implementierungsklasse ist java.util.concurrent.ThreadPoolExecutor. Das Erstellen eines Thread-Pools erfordert eine Definition Sieben Parameter, die sich im Code widerspiegeln, ist der Konstruktor von ThreadPoolExecutor, der sieben Parameter enthält:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
    
    
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.acc = System.getSecurityManager() == null ?
        null :
    AccessController.getContext();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}
  • corePollSize: die Anzahl der Kernthreads im Thread-Pool
  • MaximumPoolSize: Die maximale Anzahl von Threads im Thread-Pool
  • keepAliveTime: die Überlebenszeit inaktiver Threads
  • Einheit: Zeiteinheit
  • workQueue: Arbeitswarteschlange
  • threadFactory: Thread-Fabrik
  • Handler: Ablehnungsrichtlinie

Nach dem Senden einer Aufgabe an den Thread-Pool ist der Lebenszyklus der Aufgabe wie folgt:

  1. Wenn die aktuelle Anzahl der Threads im Thread beim Senden einer Aufgabe geringer ist als die Anzahl der Kern-Threads, werden Threads direkt erstellt, um die Aufgabe auszuführen.
  2. Wenn beim Senden einer Aufgabe die aktuelle Anzahl der Threads im Thread größer ist als die Anzahl der Kernthreads, wird die Aufgabe zur Arbeitswarteschlange hinzugefügt.
  3. Wenn die Arbeitswarteschlange voll ist, erstellen Sie direkt einen Thread, um die Aufgabe auszuführen
  4. Wenn die Arbeitswarteschlange voll ist und die aktuelle Anzahl an Threads der maximalen Anzahl an Threads entspricht, wird die konfigurierte Ablehnungsrichtlinie ausgeführt
senden und ausführen

Im Thread-Pool sind zwei Methoden zum Senden von Aufgaben definiert, nämlich Senden und Ausführen, die wie folgt definiert sind:

void execute(Runnable command);

<T> Future<T> submit(Callable<T> task);

<T> Future<T> submit(Runnable task, T result);

Future<?> submit(Runnable task);

Die Methode „Execute“ hat keinen Rückgabewert. Die mit der Methode „Execute“ übermittelte Aufgabe führt den konfigurierten Handler für nicht abgefangene Ausnahmen aus, nachdem der Thread eine nicht abgefangene Ausnahme ausgelöst hat. Die Methode „Submit“ gibt ein Future-Objekt zurück, das die Abstraktion des nach der Ausführung der Aufgabe zurückgegebenen Ergebnisses darstellt . Verwenden Sie die Submit-Methode. Die übermittelte Aufgabe führt den konfigurierten Handler für nicht abgefangene Ausnahmen nicht aus, nachdem der Thread eine nicht abgefangene Ausnahme ausgelöst hat, da der Thread-Pool die nicht abgefangene Ausnahme an Future übergibt und das Ergebnis der Aufgabe durch Aufrufen von Future.get( abgerufen werden kann. )-Methode. Erhalten einer nicht abgefangenen Ausnahme. Wenn die Future.get()-Methode aufgerufen wird, während der Arbeitsthread die Ausführung noch nicht abgeschlossen hat, wird der aufrufende Thread blockiert und Future.get() kehrt erst zurück, wenn der Arbeitsthread die Ausführung abgeschlossen hat. Mit der Methode Future.isDone() kann festgestellt werden, dass die Ausführung des Arbeitsthreads beendet wurde. Die Methode Future.cancel(boolean mayInterruptIfRunning) kann die Ausführung der Aufgabe abbrechen. Wenn sich die Aufgabe noch in der Warteschlange befindet, wird sie ausgeführt wird entfernt und nicht mehr ausgeführt. Wenn mayInterruptIfRunning für diese Aufgabe wahr ist, wird eine Unterbrechungsanforderung an den Arbeitsthread gesendet.

Hilfswerkzeuge

Die JDK-Standardklassenbibliothek definiert die Thread-Pool-Dienstprogrammklasse java.util.concurrent.Executors, mit der sich schnell ein Thread-Pool erstellen lässt.

  • Executors.newCachedThreadPool()
public static ExecutorService newCachedThreadPool() {
    
    
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

Das heißt, die Anzahl der Kernthreads beträgt 0, die maximale Anzahl von Threads beträgt Integer.MAX_VALUE, der maximal zulässige Leerlaufraum für Arbeitsthreads beträgt 60 Sekunden und SynchronousQueue wird intern als Thread-Pool für die Arbeitswarteschlange verwendet.

Aus der obigen Definition können wir ersehen, dass die Aufgaben im Thread-Pool nicht in die Warteschlange gelangen, sondern direkt einen Thread-Pool zur Ausführung erstellen und die Anzahl der Threads im Thread-Pool als unbegrenzt angesehen werden kann. Der Thread ist im Leerlauf nach 60 Sekunden. Pool-Recycling.

  • Executors.newFixedThreadPool(int nThreads)
public static ExecutorService newFixedThreadPool(int nThreads) {
    
    
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}

Das heißt, bei einem Thread-Pool mit einer unbegrenzten Warteschlange als Arbeitswarteschlange entspricht die Anzahl der Kernthreads der maximalen Anzahl von Threads, die nThreads entspricht, und inaktive Arbeitsthreads werden nicht automatisch bereinigt. Hierbei handelt es sich um einen Thread-Pool mit fester Größe, der die Arbeitsthreads weder erhöht noch verringert, sobald er seine Kern-Thread-Pool-Größe erreicht. Sobald eine solche Thread-Pool-Instanz nicht mehr benötigt wird, müssen wir sie daher aktiv schließen.

  • Executors.newSingleThreadExecutor()
public static ExecutorService newSingleThreadExecutor() {
    
    
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}

Dieser Thread-Pool ähnelt dem von Executors.newFixedThreadPoll(1) zurückgegebenen Thread-Pool. Dieser Thread-Pool erleichtert uns die Implementierung eines Single-Producer-Single-Consumer-Modells.

Debuggen und Testen von Java-Multithread-Programmen

Auf einem echten Java-System laufen oft Hunderte von Threads. Wenn es keine entsprechenden Tools zur Überwachung dieser Threads gibt, sind diese Threads für uns Black Boxes. Die Hauptmethode zum Überwachen von Threads besteht darin, den Thread-Dump des Programms (Thread Dump) abzurufen und anzuzeigen. Ein Thread-Dump enthält Thread-Informationen für das Programm zum Zeitpunkt der Erstellung des Thread-Dumps. Zu diesen Informationen gehören, welche Threads sich im Programm befinden, sowie spezifische Informationen zu diesen Threads.

So erhalten Sie einen Thread-Dump:

  • Führen Sie den Befehl aus: jstack -l PID
  • jvisualvm-Tools
  • JMC-Tools

Ich denke du magst

Origin blog.csdn.net/imonkeyi/article/details/132650692
Empfohlen
Rangfolge