Suche…


Fallstricke: falsche Verwendung von wait () / notify ()

Die Methoden object.wait() , object.notify() und object.notifyAll() sollen auf ganz bestimmte Weise verwendet werden. (siehe http://stackoverflow.com/documentation/java/5409/wait-notify#t=20160811161648303307 )

Das Problem "Lost Notification"

Ein häufiger Anfängerfehler ist der bedingungslose Aufruf von object.wait()

private final Object lock = new Object();

public void myConsumer() {
    synchronized (lock) {
        lock.wait();     // DON'T DO THIS!!
    }
    doSomething();
}

Dies ist falsch, weil es von einem anderen Thread abhängt, der lock.notify() oder lock.notifyAll() , aber nichts garantiert, dass der andere Thread diesen Aufruf nicht vor dem Consumer-Thread namens lock.wait() .

lock.notify() und lock.notifyAll() führen nichts aus, wenn ein anderer Thread nicht bereits auf die Benachrichtigung wartet. Der Thread, der myConsumer() in diesem Beispiel aufruft, myConsumer() für immer hängen, wenn es zu spät ist, um die Benachrichtigung myConsumer() .

Der "Illegal Monitor State" Fehler

Wenn Sie wait() oder notify() für ein Objekt aufrufen wait() ohne die Sperre zu halten, IllegalMonitorStateException die JVM die IllegalMonitorStateException .

public void myConsumer() {
    lock.wait();      // throws exception
    consume();
}

public void myProducer() {
    produce();
    lock.notify();    // throws exception
}

(Der Entwurf für wait() / notify() erfordert das Halten der Sperre, da dies zur Vermeidung systembedingter Race-Bedingungen erforderlich ist. Wenn wait() oder notify() ohne Sperren aufgerufen werden konnte, ist die Implementierung unmöglich der primäre Anwendungsfall für diese Grundelemente: Warten auf das Auftreten einer Bedingung.)

Warten / Benachrichtigen ist zu niedrig

Probleme mit wait() und notify() vermeiden Sie am besten , notify() sie nicht verwenden. Die meisten Synchronisationsprobleme können durch die Verwendung von Synchronisationsobjekten auf höherer Ebene (Warteschlangen, Barrieren, Semaphoren usw.) gelöst werden, die im Paket java.utils.concurrent verfügbar sind.

Pitfall - Erweiterung 'java.lang.Thread'

Der Javadoc für die Thread Klasse zeigt zwei Möglichkeiten zum Definieren und Verwenden eines Threads:

Verwenden einer benutzerdefinierten Thread-Klasse:

 class PrimeThread extends Thread {
     long minPrime;
     PrimeThread(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeThread p = new PrimeThread(143);
 p.start();

Mit einem Runnable :

 class PrimeRun implements Runnable {
     long minPrime;
     PrimeRun(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeRun p = new PrimeRun(143);
 new Thread(p).start();

(Quelle: java.lang.Thread javadoc .)

Der Ansatz der benutzerdefinierten Thread-Klasse funktioniert, hat jedoch einige Probleme:

  1. Es ist umständlich, PrimeThread in einem Kontext zu verwenden, der einen klassischen Threadpool, einen Executor oder das ForkJoin-Framework verwendet. (Es ist nicht unmöglich, weil PrimeThread indirekt implementiert Runnable , aber mit einer benutzerdefinierten Thread Klasse als ein Runnable sicherlich ungeschickt ist und nicht lebensfähig sein ... in Abhängigkeit von anderen Aspekten der Klasse.)

  2. Es gibt mehr Möglichkeiten für Fehler in anderen Methoden. Wenn Sie beispielsweise ein PrimeThread.start() deklariert PrimeThread.start() ohne an Thread.start() zu Thread.start() , erhalten Sie am Ende einen "Thread", der im aktuellen Thread ausgeführt wurde.

Durch den Ansatz, die Thread-Logik in ein Runnable diese Probleme vermieden. Wenn Sie eine anonyme Klasse (Java 1.1 und höher) verwenden, um Runnable zu implementieren, Runnable das Ergebnis prägnanter und lesbarer als in den obigen Beispielen.

 final long minPrime = ...
 new Thread(new Runnable() {
     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }.start();

Mit einem Lambda-Ausdruck (ab Java 8) würde das obige Beispiel noch eleganter werden:

 final long minPrime = ...
 new Thread(() -> {
    // compute primes larger than minPrime
     . . .
 }).start();

Pitfall - Zu viele Threads machen eine Anwendung langsamer.

Viele Leute, die sich mit Multi-Threading noch nicht auskennen, denken, dass die Verwendung von Threads die Anwendung automatisch beschleunigt. In der Tat ist es viel komplizierter. Wir können jedoch mit Sicherheit sagen, dass für jeden Computer die Anzahl der Threads, die gleichzeitig ausgeführt werden können, begrenzt ist:

  • Ein Computer hat eine feste Anzahl von Kernen (oder Hyperthreads ).
  • Ein Java - Thread muss auf einen Kern oder Hyperthread geplant , um zu laufen.
  • Wenn mehr ausführbare Java-Threads als (verfügbare) Kerne / Hyperthreads verfügbar sind, müssen einige von ihnen warten.

Dies sagt uns , dass nur die Schaffung von mehr und mehr Java - Threads der Anwendung nicht schneller und schneller gehen machen. Es gibt aber auch andere Überlegungen:

  • Jeder Thread benötigt einen Off-Heap-Speicherbereich für seinen Threadstapel. Die typische (Standard-) Thread-Stack-Größe beträgt 512 KB oder 1 MB. Wenn Sie über eine große Anzahl von Threads verfügen, kann die Speicherauslastung erheblich sein.

  • Jeder aktive Thread verweist auf eine Anzahl von Objekten im Heap. Dies erhöht die Anzahl der erreichbaren Objekte, was sich auf die Speicherbereinigung und die physische Speicherauslastung auswirkt.

  • Der Aufwand beim Wechseln zwischen Threads ist nicht trivial. In der Regel ist ein Wechsel in den Betriebssystemkernel erforderlich, um eine Thread-Scheduling-Entscheidung zu treffen.

  • Die Overheads der Thread-Synchronisierung und der Signalisierung zwischen Threads (z. B. wait (), notify () / notifyAll) können von Bedeutung sein.

Abhängig von den Details Ihrer Anwendung bedeuten diese Faktoren im Allgemeinen, dass es einen "Sweet Spot" für die Anzahl der Threads gibt. Darüber hinaus führt das Hinzufügen weiterer Threads zu einer minimalen Leistungsverbesserung und kann die Leistung verschlechtern.

Wenn Ihre Anwendung für jede neue Aufgabe erstellt, kann eine unerwartete Erhöhung der Arbeitslast (z. B. eine hohe Anforderungsrate) zu katastrophalem Verhalten führen.

Ein besserer Weg, um damit umzugehen, ist die Verwendung eines gebundenen Thread-Pools, dessen Größe Sie (statisch oder dynamisch) steuern können. Wenn zu viel Arbeit besteht, muss die Anwendung die Anforderungen in die Warteschlange stellen. Wenn Sie einen ExecutorService , kümmert sich dieser um die Verwaltung des Thread-Pools und die Warteschlange für Aufgaben.

Pitfall - Thread-Erstellung ist relativ teuer

Betrachten Sie diese beiden Mikro-Benchmarks:

Der erste Benchmark erstellt, startet und verbindet Threads. Das Runnable des Runnable funktioniert nicht.

public class ThreadTest {
    public static void main(String[] args) throws Exception {
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Thread t = new Thread(new Runnable() {
                        public void run() {
                }});
                t.start();
                t.join();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ThreadTest 
34627.91355
33596.66021
33661.19084
33699.44895
33603.097
33759.3928
33671.5719
33619.46809
33679.92508
33500.32862
33409.70188
33475.70541
33925.87848
33672.89529
^C

Bei einem typischen modernen PC mit Linux mit 64-Bit-Java 8 u101 zeigt dieser Benchmark eine durchschnittliche Zeit für das Erstellen, Starten und Verbinden eines Threads zwischen 33,6 und 33,9 Mikrosekunden.

Der zweite Benchmark entspricht dem ersten, verwendet jedoch einen ExecutorService zum Übergeben von Aufgaben und einen Future zum Rendezvous mit dem Ende der Aufgabe.

import java.util.concurrent.*;

public class ExecutorTest {
    public static void main(String[] args) throws Exception {
        ExecutorService exec = Executors.newCachedThreadPool();
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Future<?> future = exec.submit(new Runnable() {
                    public void run() {
                    }
                });
                future.get();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ExecutorTest
6714.66053
5418.24901
5571.65213
5307.83651
5294.44132
5370.69978
5291.83493
5386.23932
5384.06842
5293.14126
5445.17405
5389.70685
^C

Wie Sie sehen, liegen die Durchschnittswerte zwischen 5,3 und 5,6 Mikrosekunden.

Während die tatsächlichen Zeiten von einer Vielzahl von Faktoren abhängen, ist der Unterschied zwischen diesen beiden Ergebnissen erheblich. Die Verwendung eines Thread-Pools zum Recyceln von Threads ist eindeutig schneller als das Erstellen neuer Threads.

Fallstricke: Gemeinsame Variablen erfordern eine korrekte Synchronisation

Betrachten Sie dieses Beispiel:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (!stop) {
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        tt.stop = true;            // Tell child thread to stop.
    }
}

Das Programm beabsichtigt, einen Thread zu starten, ihn für 1000 Millisekunden laufen zu lassen und dann durch Setzen des stop Flags zu stop .

Funktioniert es wie beabsichtigt?

Vielleicht ja vielleicht nein.

Eine Anwendung wird nicht notwendigerweise beendet, wenn die main wird. Wenn ein anderer Thread erstellt wurde und dieser Thread nicht als Daemon-Thread markiert wurde, wird die Anwendung nach dem Ende des Haupt-Threads weiter ausgeführt. In diesem Beispiel bedeutet dies, dass die Anwendung so lange ausgeführt wird, bis der untergeordnete Thread endet. tt.stop sollte tt.stop , wenn tt.stop auf true .

Das ist aber eigentlich nicht richtig. In der Tat wird das Kind Thread zu stoppen , nachdem er beobachtete stop mit dem Wert true . Wird das passieren? Vielleicht ja vielleicht nein.

Die Java-Sprachspezifikation garantiert, dass in einem Thread erstellte Lese- und Schreibvorgänge für den Thread in der Reihenfolge der Anweisungen im Quellcode sichtbar sind. Im Allgemeinen ist dies jedoch NICHT garantiert, wenn ein Thread schreibt und ein anderer Thread (anschließend) liest. Um eine garantierte Sichtbarkeit zu erhalten, muss eine Kette von Ereignissen vor dem Schreiben und einem nachfolgenden Lesen vorhanden sein. Im obigen Beispiel gibt es keine solche Kette für die Aktualisierung des stop Flags. Daher kann nicht garantiert werden, dass der untergeordnete Thread stop in true ändert.

(Anmerkung für Autoren: Es sollte ein separates Thema zum Java-Speichermodell geben, um auf die detaillierten technischen Details einzugehen.)

Wie lösen wir das Problem?

In diesem Fall gibt es zwei einfache Möglichkeiten, um sicherzustellen, dass das stop Update sichtbar ist:

  1. Deklarieren Sie stop als volatile ; dh

     private volatile boolean stop = false;
    

    Bei einer volatile Variablen gibt die JLS an, dass eine Vor-Ereignis- Beziehung zwischen einem Schreiben eines Threads und einem späteren Lesen eines zweiten Threads besteht.

  2. Verwenden Sie einen Mutex zum Synchronisieren wie folgt:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (true) {
            synchronize (this) {
                if (stop) {
                    break;
                }
            }
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        synchronize (tt) {
            tt.stop = true;        // Tell child thread to stop.
        }
    }
}

Neben der Sicherstellung eines gegenseitigen Ausschlusses gibt die JLS an, dass es eine zufällige Beziehung zwischen dem Freigeben eines Mutex in einem Thread und dem Erhalten des gleichen Mutex in einem zweiten Thread gibt.

Aber ist die Zuordnung nicht atomar?

Ja, so ist es!

Dies bedeutet jedoch nicht, dass die Auswirkungen der Aktualisierung für alle Threads gleichzeitig sichtbar sind. Nur eine richtige Kette von Geschehnissen vor den Ereignissen garantiert das.

Warum haben sie das gemacht?

Programmierer, die zum ersten Mal Multithreading-Programmierung in Java durchführen, finden, dass das Speichermodell eine Herausforderung darstellt. Programme verhalten sich nicht intuitiv, weil die natürliche Erwartung ist, dass Schreibvorgänge einheitlich sichtbar sind. Warum also die Java-Designer das Speichermodell so gestalten.

Es geht eigentlich um einen Kompromiss zwischen Leistung und Benutzerfreundlichkeit (für den Programmierer).

Eine moderne Computerarchitektur besteht aus mehreren Prozessoren (Kernen) mit individuellen Registersätzen. Der Hauptspeicher ist entweder für alle Prozessoren oder für Prozessorgruppen zugänglich. Eine andere Eigenschaft moderner Computerhardware besteht darin, dass der Zugriff auf Register normalerweise um Größenordnungen schneller als der Zugriff auf den Hauptspeicher ist. Wenn die Anzahl der Kerne zunimmt, kann man leicht erkennen, dass das Lesen und Schreiben in den Hauptspeicher zu einem Hauptleistungsengpass eines Systems werden kann.

Diese Nichtübereinstimmung wird durch Implementieren einer oder mehrerer Ebenen des Zwischenspeichers zwischen den Prozessorkernen und dem Hauptspeicher behoben. Jeder Kern greift über seinen Cache auf Speicherzellen zu. Normalerweise findet ein Hauptspeicherlesevorgang nur statt, wenn ein Cache-Fehler vorliegt, und ein Hauptspeicherzugriff erfolgt nur, wenn eine Cachezeile geleert werden muss. Bei einer Anwendung, bei der die Arbeitsspeicherbereiche jedes Kerns in seinen Cache passen, ist die Kerngeschwindigkeit nicht mehr durch die Hauptspeichergeschwindigkeit / Bandbreite begrenzt.

Das gibt uns jedoch ein neues Problem, wenn mehrere Kerne gemeinsam genutzte Variablen lesen und schreiben. Die neueste Version einer Variablen kann sich im Cache eines Cores befinden. Wenn der betreffende Kern die Cache-Zeile nicht in den Hauptspeicher schreibt UND ANDERE Kerne ihre zwischengespeicherte Kopie älterer Versionen ungültig machen, kann es vorkommen, dass einige von ihnen veraltete Versionen der Variablen sehen. Wenn die Caches jedoch jedes Mal in den Speicher geschrieben werden, wird ein Cache-Schreibvorgang ausgeführt ("nur für den Fall", dass ein anderer Kern gelesen wurde), der die Hauptspeicherbandbreite unnötig verbrauchen würde.

Die Standardlösung, die auf der Ebene der Hardwarebefehlssätze verwendet wird, besteht darin, Anweisungen für die Cache-Ungültigmachung und einen Cache-Durchschreibvorgang bereitzustellen und es dem Compiler zu überlassen, zu entscheiden, wann er verwendet wird.

Rückkehr zu Java Das Speichermodell ist so konzipiert, dass die Java-Compiler keine Anweisungen zum Ungültigmachen des Cache und Durchschreibbefehle ausgeben müssen, wenn sie nicht wirklich benötigt werden. Es wird davon ausgegangen, dass der Programmierer einen geeigneten Synchronisationsmechanismus (z. B. primitive Mutexe, volatile , übergeordnete Parallelitätsklassen usw.) verwendet, um anzuzeigen, dass der Arbeitsspeicher sichtbar ist. Wenn keine Ereignis-vor- Beziehung vorliegt, können die Java-Compiler davon ausgehen, dass keine Cache-Operationen (oder ähnliches) erforderlich sind.

Dies hat erhebliche Leistungsvorteile für Multithread-Anwendungen, aber der Nachteil ist, dass das Schreiben korrekter Multithread-Anwendungen keine einfache Angelegenheit ist. Der Programmierer muss verstehen, was er tut.

Warum kann ich das nicht reproduzieren?

Es gibt eine Reihe von Gründen, warum solche Probleme schwer reproduzierbar sind:

  1. Wie oben erläutert, ist die Folge des Problems, Probleme mit der Sichtbarkeit des Speichers nicht richtig zu behandeln, in der Regel, dass Ihre kompilierte Anwendung die Speicher-Caches nicht korrekt verarbeitet. Wie bereits erwähnt, werden Speichercaches jedoch ohnehin geleert.

  2. Wenn Sie die Hardwareplattform ändern, können sich die Eigenschaften der Speichercaches ändern. Dies kann zu einem anderen Verhalten führen, wenn Ihre Anwendung nicht ordnungsgemäß synchronisiert wird.

  3. Möglicherweise beobachten Sie die Auswirkungen einer zufälligen Synchronisation. Wenn Sie beispielsweise Traceprints hinzufügen, geschieht dies normalerweise im Hintergrund in den E / A-Streams mit einer Synchronisierung, die Cache-Flushen verursacht. Durch das Hinzufügen von Traceprints verhält sich die Anwendung oft anders.

  4. Wenn Sie eine Anwendung unter einem Debugger ausführen, wird sie vom JIT-Compiler anders kompiliert. Haltepunkte und Einzelschritte verstärken dies. Diese Effekte ändern häufig das Verhalten einer Anwendung.

Diese Dinge machen Fehler, die auf eine unzureichende Synchronisierung zurückzuführen sind, besonders schwer zu lösen.



Modified text is an extract of the original Stack Overflow Documentation
Lizenziert unter CC BY-SA 3.0
Nicht angeschlossen an Stack Overflow