22 Stimmen

Werden endgültige Felder wirklich bei der Gewährleistung der Thread-Sicherheit nützlich sein?

Ich arbeite seit einigen Jahren täglich mit dem Java Memory Model. Ich denke, ich habe ein gutes Verständnis von dem Konzept der Datenrennen und den verschiedenen Möglichkeiten, sie zu vermeiden (z.B. synchronisierte Blöcke, volatile Variablen usw.). Allerdings gibt es immer noch etwas, das ich nicht vollständig verstehe über das Memory Model, nämlich die Art und Weise, wie finale Felder von Klassen angeblich threadsicher sind, ohne weitere Synchronisierung.

Also gemäß der Spezifikation, wenn ein Objekt ordnungsgemäß initialisiert ist (das heißt, keine Referenz auf das Objekt aus dem Konstruktor entweicht, so dass die Referenz von einem anderen Thread gesehen werden kann), dann wird nach der Konstruktion garantiert, dass jeder Thread, der das Objekt sieht, die Referenzen auf alle finalen Felder des Objekts (im Zustand, in dem sie beim Konstruieren waren) ohne weitere Synchronisierung sieht.

Insbesondere heißt es im Standard (http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.4):

Das Nutzungsmodell für finale Felder ist einfach: Setze die finalen Felder für ein Objekt in den Konstruktor dieses Objekts; und schreibe keine Referenz auf das in Konstruktion befindliche Objekt an einen Ort, an dem ein anderer Thread es sehen kann, bevor der Konstruktor des Objekts beendet ist. Wenn dies befolgt wird, wird ein anderer Thread, der das Objekt sieht, immer die richtig konstruierte Version der finalen Felder des Objekts sehen. Er wird auch Versionen von Objekten oder Arrays, auf die diese finalen Felder verweisen, sehen, die mindestens genauso aktuell sind wie die finalen Felder.

Es wird sogar das folgende Beispiel gegeben:

class FinalFieldExample { 
    final int x;
    int y; 
    static FinalFieldExample f;

    public FinalFieldExample() {
        x = 3; 
        y = 4; 
    } 

    static void writer() {
        f = new FinalFieldExample();
    } 

    static void reader() {
        if (f != null) {
            int i = f.x;  // garantiert 3 zu sehen
            int j = f.y;  // könnte 0 sehen
        } 
    } 
}

Ein Thread A soll "reader()" ausführen und ein Thread B soll "writer()" ausführen.

Bisher scheint alles gut zu sein.

Meine Hauptbedenken haben mit... ist dies in der Praxis wirklich nützlich? Soweit ich weiß, müssen wir, um Thread A (der "reader()" ausführt) die Referenz zu "f" sehen zu lassen, einen Synchronisierungsmechanismus verwenden, wie beispielsweise f als volatile zu kennzeichnen oder Locks zu verwenden, um den Zugriff auf f zu synchronisieren. Wenn wir das nicht tun, ist nicht einmal garantiert, dass "reader()" ein initialisiertes "f" sehen wird, das heißt, da wir keinen synchronisierten Zugriff auf "f" haben, wird der Leser möglicherweise "null" anstelle des vom Writer-Thread konstruierten Objekts sehen. Dieses Problem wird in http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#finalWrong , einer der Hauptreferenzen für das Java Memory Model, erläutert [fette Hervorhebung von mir]:

Nun, nachdem ein Thread ein unveränderliches Objekt konstruiert hat (das heißt, ein Objekt, das nur finale Felder enthält), besteht immer noch typischerweise die Notwendigkeit, Synchronisation zu verwenden, um sicherzustellen, dass es korrekt von allen anderen Threads gesehen wird. Es gibt keine andere Möglichkeit, zum Beispiel sicherzustellen, dass die Referenz auf das unveränderliche Objekt vom zweiten Thread gesehen wird. Die Garantien, die das Programm von finalen Feldern erhält, sollten mit einem tiefen und sorgfältigen Verständnis darüber, wie die Concurrent-Programmierung in Ihrem Code verwaltet wird, sorgfältig abgewogen werden.

Wenn uns nicht einmal garantiert wird, dass die Referenz zu "f" sichtbar ist, und wir daher typische Synchronisierungsmechanismen (volatile, Locks usw.) verwenden müssen, und diese Mechanismen bereits Datenrennen verhindern, ist die Notwendigkeit von final etwas, über das ich nicht einmal nachdenken würde. Ich meine, wenn wir "f" für andere Threads sichtbar machen müssen, müssen wir bereits volatile oder synchronisierte Blöcke verwenden, und diese machen bereits interne Felder für andere Threads sichtbar... worin liegt also der Sinn (in puncto Thread-Sicherheit), ein Feld überhaupt final zu machen?

11voto

Stephen C Punkte 665668

Ich denke, dass Sie missverstehen, was das JLS-Beispiel zeigen soll:

static void reader() {
    if (f != null) {
        int i = f.x;  // garantiert 3 zu sehen  
        int j = f.y;  // könnte 0 sehen
    } 
}

Dieser Code garantiert nicht, dass der neueste Wert von f vom Thread, der reader() aufruft, gesehen wird. Aber was damit gemeint ist, dass, wenn Sie f als nicht null sehen, dann ist gewährleistet, dass f.x 3 ist ... trotz der Tatsache, dass wir eigentlich keine explizite Synchronisierung gemacht haben.

Nun, ist diese implizite Synchronisierung für Finals in Konstruktoren nützlich? Sicherlich ist es ... meiner Meinung nach. Das bedeutet, dass wir keine zusätzliche Synchronisierung benötigen, jedes Mal wenn wir auf den Status eines unveränderlichen Objekts zugreifen. Das ist positiv, denn Synchronisierung beinhaltet typischerweise Cache-Read-Through oder Write-Through und verlangsamt Ihr Programm.

Aber was Pugh sagt, ist, dass Sie in der Regel synchronisieren müssen, um den Verweis auf das unveränderliche Objekt überhaupt zu erhalten. Er macht die Feststellung, dass die Verwendung von unveränderlichen Objekten (implementiert mit final) Sie nicht davon befreit, synchronisieren zu müssen ... oder die Concurrency-/Synchronisationsimplementierung Ihrer Anwendung zu verstehen.


Das Problem ist, dass wir immer noch sicherstellen müssen, dass reader ein nicht-null "f" sehen wird, und das ist nur möglich, wenn wir einen anderen Synchronisierungsmechanismus verwenden, der bereits die Semantik bereitstellt, uns 3 für f.x sehen zu lassen. Und wenn das der Fall ist, warum dann überhaupt final für Thread-Sicherheitsdinge verwenden?

Es gibt einen Unterschied zwischen der Synchronisierung, um den Verweis zu erhalten und der Synchronisierung, um den Verweis zu verwenden. Die erste muss ich vielleicht nur einmal tun. Die zweite muss ich vielleicht viele Male tun ... mit dem gleichen Verweis. Und selbst wenn es eins zu eins ist, habe ich die Anzahl der Synchronisierungsoperationen halbiert ... wenn ich (hypothetisch) das unveränderliche Objekt als threadsicher implementiere.

8voto

nosid Punkte 47014

Zusammenfassung: Die meisten Softwareentwickler sollten die speziellen Regeln bezüglich final Variablen im Java Memory Model ignorieren. Sie sollten sich an die allgemeine Regel halten: Wenn ein Programm frei von Datenrennen ist, werden alle Ausführungen sequentiell konsistent erscheinen. In den meisten Fällen können final Variablen die Leistung von nebenläufigem Code nicht verbessern, denn die spezielle Regel im Java Memory Model schafft zusätzliche Kosten für final Variablen, was volatile überlegen macht gegenüber final Variablen für fast alle Anwendungsfälle.

Die spezielle Regel bezüglich final Variablen verhindert in einigen Fällen, dass eine final Variable unterschiedliche Werte anzeigen kann. Leistungsmäßig ist die Regel jedoch irrelevant.


Nichtsdestotrotz folgt hier eine detailiertere Antwort. Aber ich muss dich warnen. Die folgende Beschreibung könnte einige heikle Informationen enthalten, die die meisten Softwareentwickler nie interessieren sollten und es besser ist, wenn sie nichts davon wissen.

Die spezielle Regel bezüglich final Variablen im Java Memory Model bedeutet irgendwie, dass es einen Unterschied für den Java-VM und den Java-JIT-Compiler macht, ob eine Member-Variable final ist oder nicht.

public class Int {
    public /* final */ int value;
    public Int(int value) {
        this.value = value;
    }
}

Wenn du einen Blick in den Hotspot Quellcode wirfst, wirst du sehen, dass der Compiler überprüft, ob der Konstruktor einer Klasse mindestens eine final Variable schreibt. Wenn dies der Fall ist, wird der Compiler zusätzlichen Code für den Konstruktor ausgeben, genauer gesagt eine Memory-Release-Barriere. Du wirst auch den folgenden Kommentar im Quellcode finden:

Diese Methode (die nach den Regeln von Java ein Konstruktor sein muss) hat ein final geschrieben. Die Auswirkungen aller Initialisierungen müssen im Speicher festgehalten werden, bevor Code nach dem Konstruktor die Referenz auf das neu konstruierte Objekt veröffentlicht. Anstatt auf die Veröffentlichung zu warten, blockieren wir einfach die Schreibvorgänge hier. Anstatt eine Barriere nur für diejenigen Schreibvorgänge zu setzen, die zum Abschluss erforderlich sind, zwingen wir alle Schreibvorgänge zum Abschluss.

Das bedeutet, dass die Initialisierung einer final Variable ähnlich ist wie eine Schreibung einer volatile Variable. Es impliziert eine Art von Memory-Release-Barriere. Allerdings, wie aus dem zitierten Kommentar hervorgeht, könnten final Variablen noch teurer sein. Und was noch schlimmer ist, die zusätzlichen Kosten für final Variablen entstehen unabhängig davon, ob sie in nebenläufigem Code verwendet werden oder nicht.

Das ist schrecklich, denn wir möchten, dass Softwareentwickler final Variablen verwenden, um die Lesbarkeit und Wartbarkeit des Quellcodes zu verbessern. Leider kann die Verwendung von final Variablen die Leistung eines Programms erheblich beeinträchtigen.


Die Frage bleibt: Gibt es Anwendungsfälle, bei denen die spezielle Regel bezüglich final Variablen hilft, die Leistung von nebenläufigem Code zu verbessern?

Das ist schwer zu sagen, denn es hängt von der konkreten Implementierung der Java-VM und der Speicherarchitektur der Maschine ab. Ich habe bis jetzt keine solchen Anwendungsfälle gesehen. Ein schneller Blick auf den Quellcode des Pakets java.util.concurrent hat ebenfalls nichts ergeben.

Das Problem ist: Die Initialisierung einer final Variable ist ungefähr genauso teuer wie eine Schreibung einer volatile oder atomic Variable. Wenn du eine volatile Variable für die Referenz auf das neu erstellte Objekt verwendest, erhältst du das gleiche Verhalten und die gleichen Kosten mit der Ausnahme, dass die Referenz sofort auch veröffentlicht wird. Also, es gibt im Grunde genommen keinen Vorteil bei der Verwendung von final Variablen für nebenläufige Programmierung.

4voto

Sergey Kalinichenko Punkte 694383

Du hast recht, da das Sperren stärkere Garantien bietet, ist die Garantie bezüglich der Verfügbarkeit von finals in Anwesenheit von Sperren nicht besonders nützlich. Allerdings ist Sperren nicht immer notwendig, um einen zuverlässigen simultanen Zugriff zu gewährleisten.

Soweit ich weiß, um Thread A (der "reader()" ausführt) die Referenz auf "f" sehen zu lassen, müssen wir einen Synchronisierungsmechanismus verwenden, wie zum Beispiel "f" volatil zu machen oder Sperren zu verwenden, um den Zugriff auf "f" zu synchronisieren.

Das Machen von f volatil ist kein Synchronisierungsmechanismus; es zwingt Threads dazu, den Speicher jedes Mal zu lesen, wenn auf die Variable zugegriffen wird, synchronisiert jedoch nicht den Zugriff auf einen Speicherort. Sperren sind ein Weg, den Zugriff zu synchronisieren, aber es ist in der Praxis nicht notwendig, um sicherzustellen, dass die beiden Threads Daten zuverlässig teilen. Zum Beispiel könnten Sie eine ConcurrentLinkedQueue-Klasse verwenden, die eine sperrfreie simultane Sammlung ist, um Daten von einem Lesethread zu einem Schreibthread zu übergeben und Synchronisierung zu vermeiden. Sie könnten auch AtomicReference verwenden, um zuverlässigen simultanen Zugriff auf ein Objekt ohne Sperren sicherzustellen.

Es ist, wenn Sie sperrfreie Simultanverarbeitung verwenden, dass die Garantie bezüglich der Sichtbarkeit von final Feldern nützlich wird. Wenn Sie eine sperrfreie Sammlung erstellen und sie verwenden, um unveränderliche Objekte zu speichern, können Ihre Threads auf den Inhalt der Objekte zugreifen, ohne zusätzliche Sperren.

* ConcurrentLinkedQueue ist nicht nur sperrfrei, sondern auch eine wartefreie Sammlung (d.h. eine sperrfreie Sammlung mit zusätzlichen Garantien, die für diese Diskussion nicht relevant sind).

1voto

Dhana Krishnasamy Punkte 2106

Ja, finale finale Felder sind in Bezug auf die Thread-Sicherheit nützlich. Es mag in Ihrem Beispiel nicht nützlich sein, aber wenn Sie sich die alte ConcurrentHashMap-Implementierung ansehen, wendet die get-Methode keine Sperrung an, während sie nach dem Wert sucht. Es besteht jedoch das Risiko, dass sich die Liste während der Suche ändern kann (denken Sie an ConcurrentModificationException). CHM verwendet jedoch die Liste, die aus dem finalen Feld für das 'next'-Feld besteht, was die Konsistenz der Liste garantiert (die Elemente in der Front/die noch zu sehenden werden nicht wachsen oder schrumpfen). Der Vorteil ist also, dass die Thread-Sicherheit ohne Synchronisation gewährleistet ist.

Aus dem Artikel

Ausnutzung der Unveränderlichkeit

Eine bedeutende Quelle für Inkonsistenz wird vermieden, indem die Entry-Elemente nahezu unveränderlich gemacht werden - alle Felder sind final, außer dem Wertefeld, das volatil ist. Das bedeutet, dass Elemente nicht in der Mitte oder am Ende der Haschkette hinzugefügt oder entfernt werden können - Elemente können nur am Anfang hinzugefügt werden, und das Entfernen erfordert das Klonen des gesamten oder eines Teils der Kette und das Aktualisieren des Listenkopfzeigers. Sobald Sie also eine Referenz in eine Haschkette haben, wissen Sie zwar nicht, ob Sie eine Referenz auf den Anfang der Liste haben, aber Sie wissen, dass der Rest der Liste seine Struktur nicht ändern wird. Da das Wertefeld volatil ist, werden Sie Aktualisierungen des Wertefelds sofort sehen können, was den Prozess des Schreibens einer Map-Implementierung, die mit einer potenziell veralteten Speicheransicht umgehen kann, erheblich vereinfacht.

Während das neue JMM die Initialisierungssicherheit für finale Variablen bereitstellt, tut dies das alte JMM nicht, was bedeutet, dass es möglich ist, dass ein anderer Thread den Standardwert für ein finales Feld sieht, anstelle des Werts, der dort vom Konstruktor des Objekts platziert wurde. Die Implementierung muss darauf vorbereitet sein, dies zu erkennen, was sie tut, indem sie sicherstellt, dass der Standardwert für jedes Feld von Entry kein gültiger Wert ist. Die Liste ist so konstruiert, dass wenn eines der Eintragsfelder ihren Standardwert (null oder null) zu haben scheint, die Suche scheitern wird, was die get()-Implementierung dazu veranlasst, zu synchronisieren und die Kette erneut zu durchlaufen.

Artikel Link: https://www.ibm.com/developerworks/library/j-jtp08223/

CodeJaeger.com

CodeJaeger ist eine Gemeinschaft für Programmierer, die täglich Hilfe erhalten..
Wir haben viele Inhalte, und Sie können auch Ihre eigenen Fragen stellen oder die Fragen anderer Leute lösen.

Powered by:

X