Java 101: Grundlegendes zu Java-Threads, Teil 2: Thread-Synchronisation

Letzten Monat habe ich Ihnen gezeigt, wie einfach es ist, Thread-Objekte zu erstellen, Threads, die diesen Objekten zugeordnet sind, durch Aufrufen Threadder start()Methode zu starten und einfache Thread-Operationen durch Aufrufen anderer ThreadMethoden wie der drei überladenen join()Methoden auszuführen . Diesen Monat nehmen wir jedoch komplexere Multithread-Java-Programme auf.

Grundlegendes zu Java-Threads - Lesen Sie die gesamte Serie

  • Teil 1: Einführung in Threads und Runnables
  • Teil 2: Thread-Synchronisation
  • Teil 3: Thread-Planung, Warten / Benachrichtigen und Thread-Unterbrechung
  • Teil 4: Thread-Gruppen, Volatilität, Thread-lokale Variablen, Timer und Thread-Tod

Multithread-Programme funktionieren häufig fehlerhaft oder erzeugen aufgrund der fehlenden Thread- Synchronisation fehlerhafte Werte . Bei der Synchronisierung wird der Threadzugriff auf die Codesequenzen serialisiert (oder einzeln angeordnet), mit denen mehrere Threads Klassen- und Instanzfeldvariablen sowie andere gemeinsam genutzte Ressourcen bearbeiten können. Ich nenne diese Codesequenzen kritische Codeabschnitte. . In der Kolumne dieses Monats geht es darum, mithilfe der Synchronisierung den Threadzugriff auf wichtige Codeabschnitte in Ihren Programmen zu serialisieren.

Ich beginne mit einem Beispiel, das zeigt, warum einige Multithread-Programme die Synchronisation verwenden müssen. Als nächstes untersuche ich Javas Synchronisationsmechanismus in Bezug auf Monitore und Sperren sowie das synchronizedSchlüsselwort. Da die falsche Verwendung des Synchronisationsmechanismus seine Vorteile zunichte macht, untersuche ich abschließend zwei Probleme, die sich aus einem solchen Missbrauch ergeben.

Tipp: Im Gegensatz zu Klassen- und Instanzfeldvariablen können Threads keine lokalen Variablen und Parameter gemeinsam nutzen. Der Grund: Lokale Variablen und Parameter werden dem Methodenaufrufstapel eines Threads zugewiesen. Infolgedessen erhält jeder Thread eine eigene Kopie dieser Variablen. Im Gegensatz dazu können Threads Klassenfelder und Instanzfelder gemeinsam nutzen, da diese Variablen nicht auf dem Methodenaufrufstapel eines Threads zugeordnet sind. Stattdessen werden sie im gemeinsam genutzten Heapspeicher zugewiesen - als Teil von Klassen (Klassenfeldern) oder Objekten (Instanzfeldern).

Die Notwendigkeit der Synchronisation

Warum brauchen wir Synchronisation? Betrachten Sie für eine Antwort das folgende Beispiel: Sie schreiben ein Java-Programm, das zwei Threads verwendet, um die Auszahlung / Einzahlung von Finanztransaktionen zu simulieren. In diesem Programm führt ein Thread Einzahlungen durch, während der andere Auszahlungen durchführt. Jeder Thread bearbeitet ein Paar gemeinsam genutzter Variablen, Klassen- und Instanzfeldvariablen, die den Namen und den Betrag der Finanztransaktion identifizieren. Für eine korrekte Finanztransaktion muss jeder Thread beenden Werte die Zuordnung nameund amountVariablen (und diese Werte drucken, Speichern der Transaktion zu simulieren) , bevor die anderen Thread beginnt Werte zuweisen nameund amount(und auch den Druck , diese Werte). Nach einiger Arbeit erhalten Sie einen Quellcode, der Listing 1 ähnelt:

Listing 1. NeedForSynchronizationDemo.java

// NeedForSynchronizationDemo.java class NeedForSynchronizationDemo { public static void main (String [] args) { FinTrans ft = new FinTrans (); TransThread tt1 = new TransThread (ft, "Deposit Thread"); TransThread tt2 = new TransThread (ft, "Withdrawal Thread"); tt1.start (); tt2.start (); } } class FinTrans { public static String transName; public static double amount; } class TransThread extends Thread { private FinTrans ft; TransThread (FinTrans ft, String name) { super (name); // Save thread's name this.ft = ft; // Save reference to financial transaction object } public void run () { for (int i = 0; i < 100; i++) { if (getName ().equals ("Deposit Thread")) { // Start of deposit thread's critical code section ft.transName = "Deposit"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 2000.0; System.out.println (ft.transName + " " + ft.amount); // End of deposit thread's critical code section } else { // Start of withdrawal thread's critical code section ft.transName = "Withdrawal"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 250.0; System.out.println (ft.transName + " " + ft.amount); // End of withdrawal thread's critical code section } } } }

NeedForSynchronizationDemoDer Quellcode des Quellcodes enthält zwei wichtige Codeabschnitte: einen für den Einzahlungsthread und einen für den Auszahlungsthread. Innerhalb des kritischen Codeabschnitts des Einzahlungsthreads weist dieser Thread die DepositStringReferenz des Objekts der gemeinsam genutzten Variablen transNameund die 2000.0gemeinsam genutzte Variable zu amount. In ähnlicher Weise weist dieser Thread innerhalb des kritischen Codeabschnitts des Rückzugsthreads WithdrawalStringdie Referenz des Objekts zu transNameund weist sie 250.0zu amount. Nach den Zuweisungen der einzelnen Threads wird der Inhalt dieser Variablen gedruckt. Wenn Sie ausgeführt werden NeedForSynchronizationDemo, können Sie eine Ausgabe erwarten, die einer Liste von eingestreuten Zeilen Withdrawal 250.0und Deposit 2000.0Zeilen ähnelt . Stattdessen erhalten Sie eine Ausgabe, die der folgenden ähnelt:

Withdrawal 250.0 Withdrawal 2000.0 Deposit 2000.0 Deposit 2000.0 Deposit 250.0

Das Programm hat definitiv ein Problem. Der Auszahlungsthread sollte keine Auszahlungen von 2000 USD simulieren, und der Einzahlungsthread sollte keine Einzahlungen von 250 USD simulieren. Jeder Thread erzeugt eine inkonsistente Ausgabe. Was verursacht diese Inkonsistenzen? Folgendes berücksichtigen:

  • Auf einem Computer mit einem Prozessor teilen sich Threads den Prozessor. Infolgedessen kann ein Thread nur für einen bestimmten Zeitraum ausgeführt werden. Zu diesem Zeitpunkt unterbricht die JVM / das Betriebssystem die Ausführung dieses Threads und ermöglicht die Ausführung eines anderen Threads - eine Manifestation der Thread-Planung, ein Thema, das ich in Teil 3 diskutiere. Auf einem Multiprozessor-Computer hängt jeder Thread von der Anzahl der Threads und Prozessoren ab kann einen eigenen Prozessor haben.
  • Auf einem Computer mit einem Prozessor dauert die Ausführungsdauer eines Threads möglicherweise nicht lange genug, damit dieser Thread die Ausführung seines Abschnitts für kritischen Code beendet, bevor ein anderer Thread mit der Ausführung seines eigenen Abschnitts für kritischen Code beginnt. Auf einem Multiprozessor-Computer können Threads gleichzeitig Code in ihren kritischen Codeabschnitten ausführen. Sie können jedoch ihre kritischen Codeabschnitte zu unterschiedlichen Zeiten eingeben.
  • Auf Einzelprozessor- oder Multiprozessor-Computern kann das folgende Szenario auftreten: Thread A weist der gemeinsam genutzten Variablen X in ihrem kritischen Codeabschnitt einen Wert zu und entscheidet sich für eine Eingabe- / Ausgabeoperation, die 100 Millisekunden erfordert. Thread B gibt dann seinen kritischen Codeabschnitt ein, weist X einen anderen Wert zu, führt eine 50-Millisekunden-Eingabe- / Ausgabeoperation durch und weist den gemeinsam genutzten Variablen Y und Z Werte zu. Die Eingabe- / Ausgabeoperation von Thread A wird abgeschlossen, und dieser Thread weist seinen eigenen zu Werte für Y und Z. Da X einen B-zugewiesenen Wert enthält, während Y und Z A-zugewiesene Werte enthalten, ergibt sich eine Inkonsistenz.

Wie entsteht eine Inkonsistenz in NeedForSynchronizationDemo? Angenommen, der Einzahlungsthread wird ausgeführt ft.transName = "Deposit";und ruft dann auf Thread.sleep(). Zu diesem Zeitpunkt gibt der Einzahlungs-Thread die Kontrolle über den Prozessor für den Zeitraum ab, in dem er schlafen muss, und der Auszahlungs-Thread wird ausgeführt. Angenommen, der Einzahlungsthread schläft 500 Millisekunden lang (ein zufällig ausgewählter Wert dank Math.random()des inklusive Bereichs von 0 bis 999 Millisekunden; ich untersuche Mathund seine random()Methode in einem zukünftigen Artikel). Während der Ruhezeit des Einzahlungsthreads wird der Auszahlungsthread ausgeführt ft.transName = "Withdrawal";, 50 Millisekunden lang in den Ruhezustand versetzt (der zufällig ausgewählte Ruhezustand des Auszahlungsthreads), aktiviert, ausgeführt ft.amount = 250.0;und ausgeführt System.out.println (ft.transName + " " + ft.amount);- alles bevor der Einzahlungsthread aktiviert wird. Infolgedessen wird der Rückzugsfaden gedrucktWithdrawal 250.0, welches ist richtig. Wenn der Einzahlungsthread erwacht, wird er ausgeführt ft.amount = 2000.0;, gefolgt von System.out.println (ft.transName + " " + ft.amount);. Diesmal Withdrawal 2000.0druckt, was nicht korrekt ist. Obwohl der Einzahlungsthread zuvor die "Deposit"Referenz der zugewiesen transNamehatte, verschwand diese Referenz später, als der Auszahlungsthread die "Withdrawal"Referenz der gemeinsamen Variablen zuordnete . Als der Einzahlungsthread aufwachte, konnte er den korrekten Verweis auf nicht wiederherstellen transName, setzte jedoch seine Ausführung durch Zuweisen 2000.0zu fort amount. Obwohl keine der Variablen einen ungültigen Wert hat, stellen die kombinierten Werte beider Variablen eine Inkonsistenz dar. In diesem Fall stellen ihre Werte einen Versuch dar, 000 zurückzuziehen.

Vor langer Zeit haben Informatiker einen Begriff erfunden, um das kombinierte Verhalten mehrerer Threads zu beschreiben, das zu Inkonsistenzen führt. Dieser Begriff ist eine Racebedingung - der Vorgang, bei dem jeder Thread seinen kritischen Codeabschnitt vervollständigt, bevor ein anderer Thread denselben kritischen Codeabschnitt betritt. WieNeedForSynchronizationDemozeigt, dass die Ausführungsreihenfolgen von Threads nicht vorhersehbar sind. Es gibt keine Garantie dafür, dass ein Thread seinen kritischen Codeabschnitt vervollständigen kann, bevor ein anderer Thread diesen Abschnitt betritt. Daher haben wir eine Rennbedingung, die zu Inkonsistenzen führt. Um Race-Bedingungen zu vermeiden, muss jeder Thread seinen kritischen Codeabschnitt vervollständigen, bevor ein anderer Thread entweder denselben kritischen Codeabschnitt oder einen anderen verwandten kritischen Codeabschnitt eingibt, der dieselben gemeinsam genutzten Variablen oder Ressourcen manipuliert. Ohne die Möglichkeit, den Zugriff auf einen kritischen Codeabschnitt zu serialisieren, dh nur auf jeweils einen Thread zuzugreifen, können Sie Rennbedingungen oder Inkonsistenzen nicht verhindern. Glücklicherweise bietet Java eine Möglichkeit, den Thread-Zugriff zu serialisieren: über seinen Synchronisationsmechanismus.

Hinweis : Von den Java-Typen sind nur Gleitkommavariablen mit langer Ganzzahl und doppelter Genauigkeit anfällig für Inkonsistenzen. Warum? Eine 32-Bit-JVM greift normalerweise in zwei benachbarten 32-Bit-Schritten auf eine 64-Bit-Ganzzahlvariable oder eine 64-Bit-Gleitkommavariable mit doppelter Genauigkeit zu. Ein Thread führt möglicherweise den ersten Schritt aus und wartet, während ein anderer Thread beide Schritte ausführt. Dann kann der erste Thread aufwachen und den zweiten Schritt abschließen, wobei eine Variable mit einem Wert erzeugt wird, der sich entweder vom Wert des ersten oder des zweiten Threads unterscheidet. Wenn mindestens ein Thread entweder eine lange Ganzzahlvariable oder eine Gleitkommavariable mit doppelter Genauigkeit ändern kann, müssen daher alle Threads, die diese Variable lesen und / oder ändern, die Synchronisation verwenden, um den Zugriff auf die Variable zu serialisieren.

Javas Synchronisationsmechanismus

Java provides a synchronization mechanism for preventing more than one thread from executing code in one or more critical code sections at any point in time. That mechanism bases itself on the concepts of monitors and locks. Think of a monitor as a protective wrapper around a critical code section and a lock as a software entity that a monitor uses to prevent multiple threads from entering the monitor. The idea is this: When a thread wishes to enter a monitor-guarded critical code section, that thread must acquire the lock associated with an object that associates with the monitor. (Each object has its own lock.) If some other thread holds that lock, the JVM forces the requesting thread to wait in a waiting area associated with the monitor/lock. When the thread in the monitor releases the lock, the JVM removes the waiting thread from the monitor's waiting area and allows that thread to acquire the lock and proceed to the monitor's critical code section.

To work with monitors/locks, the JVM provides the monitorenter and monitorexit instructions. Fortunately, you do not need to work at such a low level. Instead, you can use Java's synchronized keyword in the context of the synchronized statement and synchronized methods.

The synchronized statement

Some critical code sections occupy small portions of their enclosing methods. To guard multiple thread access to such critical code sections, you use the synchronized statement. That statement has the following syntax:

'synchronized' '(' objectidentifier ')' '{' // Critical code section '}'

Die synchronizedAnweisung beginnt mit dem Schlüsselwort synchronizedund wird mit einer Objektkennung fortgesetzt, die zwischen zwei runden Klammern steht. Die Objektkennung verweist auf ein Objekt, dessen Sperre dem Monitor zugeordnet ist, den die synchronizedAnweisung darstellt. Schließlich wird der kritische Codeabschnitt der Java-Anweisungen zwischen zwei geschweiften Zeichen angezeigt. Wie interpretieren Sie die synchronizedAussage? Betrachten Sie das folgende Codefragment:

synchronized ("sync object") { // Access shared variables and other shared resources }