Concurrency in Java – das Kreuz mit der Parallelität
Mein bester Freund John Green erhielt an der Uni drei Jahre in Folge einen Preis für Informatik. Bis dahin hatte das niemand zuvor geschafft. Ich bin mir auch nicht sicher, ob sein Rekord bisher gebrochen wurde. Entweder war John außergewöhnlich begabt oder der Rest von uns war einfach nicht so schlau. Ich hoffe, es ist Ersteres. Einmal, und nur einmal, habe ich es geschafft, in einer Prüfung eine höhere Punktzahl als John zu erreichen. In einem Kurs ging es um paralleles und nebenläufiges Programmieren. Ein Thema, das ich sehr interessant fand. Ich habe mich also besser vorbereitet als für die meisten Tests zuvor, während John kaum zu irgendeiner der Vorlesungen ging. Als der Tag der Abrechnung kam, begannen wir beide wie wild zu schreiben.
Die Prüfung bestand aus vier Aufgaben, und unser Professor MacGregor wies uns an, drei auszuwählen, die wir bearbeiten wollten. Ich tat mein Bestes, und am Ende erreichte ich etwa 92 Prozent der möglichen Punkte. Bei der anschließenden Diskussion des Tests fragte mich John: „Welche zwei Abschnitte hast du beantwortet?“ Ich war sicher nicht der beste Student. Ich war auch nicht Nummer Zwei oder Drei. Aber Concurrency und Parallelität faszinierten mich. Es war das einzige Mal, dass ich die Nummer Eins war.
Concurrency ist ein Thema, das nicht jedem liegt. Es erfordert, dass man mit vielen Unwägbarkeiten arbeiten muss, was den eigenen Code betrifft. Eine Single-Thread-Routine kann man einfach von oben nach unten lesen. Und abgesehen von ein paar kniffligen if–else–Konstruktionen und -Schleifen, ist es normalerweise ziemlich offensichtlich, was der Code macht. Besonders bei Java-Code. Denn dort gilt „What you see is what you get“. Bei Concurrency ist alles offen. Schauen Sie sich z. B. den Code aus Listing 1 an.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
import java.lang.ref.*; import java.util.concurrent.atomic.*; class Resource { private static ExternalResource[] externalResourceArray = new ExternalResource[ 128 ]; private static final AtomicInteger next = new AtomicInteger(); private final int myIndex; Resource(ExternalResource resource) { myIndex = next.getAndIncrement() & 127 ; externalResourceArray[myIndex] = resource; // ... } protected void finalize() { externalResourceArray[myIndex] = null ; } public void action() { int i = myIndex; Resource.update(externalResourceArray[i]); } private static void update(ExternalResource ext) { ext.status = 42 ; } } |
Was könnte hier schiefgehen? Das ist leider überhaupt nicht offensichtlich. In Java können Objekte manchmal vorzeitig finalisiert werden. Da die Methode finalize() von einem Hintergrundthread aufgerufen wird, könnte es passieren, dass ein anderer Thread immer noch action() aufruft. Der obige Code könnte also eine NullPointerException verursachen.
Java-9-Tipp
In Java 9 haben wir die Möglichkeit, das vorzeitige Finalisieren von Objekte durch einen Barriere zu verhindern, den reachabilityFence:
1
2
3
4
5
6
7
8
|
public void action() { try { int i = myIndex; Resource.update(externalResourceArray[i]); } finally { Reference.reachabilityFence( this ); } } |
Das ist leider wenig intuitiv. Die meisten Entwickler würden reachabilityFence so schreiben:
1
2
3
4
5
|
public void action() { int i = myIndex; Reference.reachabilityFence( this ); Resource.update(externalResourceArray[i]); } |
Das wäre jedoch nicht korrekt, da es den Finalizer-Thread nicht davon abhalten würde, direkt nach dem Aufruf von reachabilityFence() und vor unserem Aufruf von update loszulegen. Und wieder käme es zu einer NullPointerException.
Wir machen auch in einem weiteren Bereich Fehler: Wir überlegen, was für andere Threads sichtbar sein sollte, ohne über das Java Memory Model nachzudenken. Bevor ich hier jedoch in die Tiefe gehe, möchte ich einen Gang runterschalten und ein paar grundlegende Tipps geben, wie man korrekten Code für mehrere Threads schreibt.
Vermeiden Sie Threading: Machen Sie alles Single-Thread-fähig und verwenden Sie nur serielle Java-8-Streams. In vielen Fällen wird deren Leistung vollkommen ausreichen und manchmal sogar besser sein als ein System mit vielen Threads. Dieser Tipp funktioniert jedoch nicht immer. Stellen Sie sich vor, die JVM würde sich so verhalten! Die Stop-the-World-Pausen wären noch schlimmer, da nur ein einziger Thread die gesamte Arbeit der Garbage Collection und HotSpot-Kompilierung machen müsste.
Vermeiden Sie gemeinsame genutzte veränderliche Daten: Um es gleich vorweg zu nehmen: Java hat kein Threading-Modell. Stattdessen verfügt es über das Java Memory Model, das detailliert beschreibt, wie Threads mit den gemeinsam genutzten Ressourcen des Heap-Speichers umgehen. Das sollte uns deutlich machen, dass es bei der Threadsicherheit um Speicherzugriff und nicht um Code geht.
Bevorzugen Sie synchronized: Wenn Sie die erweiterten Funktionen von ReentrantLock oder StampedLocknicht benötigen, verwenden sie synchronized. Es ist durch die aggressivere Optimierung immer besser als ReentrantLock. Das Debuggen ist auch einfacher, da die JVM über Funktionen verfügt, um nach Fehlern wie Deadlocks oder Ressourcenkämpfen zu suchen.
Nutzen Sie solide Klassen: Welche dieser Klassen enthält am ehesten Fehler: Vector, ConcurrentHashMapoder LinkedTransferQueue? Die wahrscheinlichsten Kandidaten sind Klassen mit kompliziertem Threading-Code, die nicht in vielen Projekten verwendet werden. Deswegen würde ich bei der höchsten Fehlerwahrscheinlichkeit auf LinkedTransferQueue wetten.
Schauen wir uns den Tipp „Vermeiden Sie gemeinsame genutzte veränderliche Daten“ ein wenig genauer an. Jedes Mal, wenn wir mehrere Threads haben, die vom gleichen Speicherort lesen oder auf ihm schreiben, benötigen wir irgendeine Art der Synchronisation. Dies kann in Form von synchronisierten Methoden, volatilen Feldern, VarHandles, Atomics, ReentrantLocks oder StampedLocks passieren; zusätzlich zu einer Vielzahl an anderen Mechanismen für die Verwaltung des Zustands. Unser Ziel ist zweierlei: Erstens gilt es, Wettrennen zwischen Code zu vermeiden, und zweitens sicherzustellen, dass alle Änderungen für alle interessierten Parteien sichtbar sind.
Wenn wir veränderliche Daten gemeinsam genutzt haben, z. B. eine HashMap, die von mehreren Threads genutzt wird, laufen wir Gefahr, die Struktur zu beschädigen. Das klingt vielleicht harmlos, ist es aber nicht. Wenn ein Threading-Fehler zu einem Deadlock führt, bleibt zumindest ein Teil des Systems stehen. Wenn aber verfälschte Daten auftauchen, könnten wir das Pech haben, dass es lediglich subtil verfälschte Daten sind, die sich immer noch innerhalb des Erlaubten bewegen. Deadlocks sind leicht zu erkennen, wenn man sich den Thread Dump ansieht. Datenverfälschung kann jedoch äußerst schwierig zu entdecken sein. Das Gleiche gilt für die Fälle, in denen in ein Feld geschrieben wird, aber ein anderer Thread diese Änderung nicht sehen kann. Dies kann zu sehr subtilen Fehlern führen, die verschwinden, wenn Sie Ihren Code debuggen.
Unveränderbarkeit ist kniffelig
Wenn man veränderliche Daten gemeinsam nutzt, ist der erste Schritt zu überlegen, ob man diese unveränderbar machen kann. Die meisten Anforderungen an die Unveränderbarkeit (Immutability) von Objekten sind relativ einfach.
Ein unveränderbares Objekt erfüllt drei Anforderungen:
- Alle Felder sind als final gekennzeichnet.
- Der Zustand kann nicht mehr geändert werden, nachdem der Konstruktor fertig ist.
- this kann während der Konstruktion nicht von anderen Objekten referenziert werden.
Lassen Sie uns Ihr Verständnis der Unveränderbarkeit testen. Ist die Klasse in Listing 2 unveränderlich?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
import java.math.*; import java.util.*; public class IsThisImmutable1 { private final Map<Integer, BigInteger> factorials = new HashMap<>(); public IsThisImmutable1() { factorials.put( 0 , BigInteger.ONE); for ( int n = 1 ; n <= 100 ; n++) { factorials.put(n, BigInteger.valueOf(n).multiply(factorials.get(n- 1 ))); } } // Returns factorial of n, between 0 and 100. public BigInteger factorial( int n) { if (n < 0 || n > 100 ) throw new IllegalArgumentException(); return factorials.get(n); } } |
- Es gibt nur ein Feld (Factorials) und es ist als final markiert. Check.
- Die Map wird nirgendwo in der Klasse modifiziert, außer im Konstruktor. Check.
- Wir übergeben keinen Pointer auf this an eine andere Klasse. Check.
Wir können also sagen, dass diese Klasse unveränderbar ist, auch wenn sie ein Objekt wie HashMapenthält, das weder unveränderbar noch threadsicher ist. Es spielt keine Rolle, wie viele Threads gleichzeitig dieses Objekt nutzen, wir werden nie Datenwettrennen oder Fehler bei der Sichtbarkeit haben.
Machen wir nun ein kleines Refactoring mit IntelliJ IDEA. Wir klicken mit der rechten Maustaste auf die for-Schleife im Konstruktor und die IDE empfiehlt uns, dass wir for durch forEach ersetzen. Das Ergebnis sieht man in Listing 3.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
import java.math.*; import java.util.*; import java.util.stream.*; public class IsThisImmutable2 { private final Map<Integer, BigInteger> factorials = new HashMap<>(); public IsThisImmutable2() { factorials.put( 0 , BigInteger.ONE); IntStream.rangeClosed( 1 , 100 ) .forEach(n -> factorials.put(n, BigInteger.valueOf(n) .multiply(factorials.get(n - 1 ))) ); } // Returns factorial of n, between 0 and 100. public BigInteger factorial( int n) { if (n < 0 || n > 100 ) throw new IllegalArgumentException(); return factorials.get(n); } } |
Ist das immer noch unveränderbar? Das Einzige, das wir geändert haben, ist, dass wir Java-8-Streams anstelle von for-Schleifen verwenden. Überraschenderweise ist die Antwort Nein. Wir haben die dritte Bedingung verletzt, nämlich dass ein Verweis auf this nicht passieren sollte. Das ist aber schwieriger zu erkennen. Wir greifen auf das Factorial HashMap von innerhalb des Lambdas zu. Das Lambda verhält sich ähnlich wie eine anonyme innere Klasse, in der wir eine magische Klasse erschaffen haben, die den Lambda-Code enthält. Da Factorials Instanzfelder sind, müssen wir eine Instanz an unser IsThisImmutable2zum Stream weitergeben. Voilà, this wird referenziert. Wir können überprüfen, ob this wirklich referenziert wird, indem wir die Klasse auseinanderbauen:
1
2
3
|
aload_0 // load "this" onto the stack invokedynamic # 9 , 0 // call factory method for IntConsumer // creation, passing in "this" as a parameter |
Im Gegensatz zu anonymen Klassen können Lambdas lediglich mit this arbeiten, wenn wir auf Felder oder Methoden des umschließenden Objekts zugreifen. Die Klasse in Listing 3 ist genauso unveränderbar wie zuvor. Dieses Mal geben wir die temporäre HashMap an das Lambda anstatt an das Feld weiter.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
import java.math.*; import java.util.*; import java.util.stream.*; public class IsThisImmutable3 { private final Map<Integer, BigInteger> factorials; public IsThisImmutable3() { Map<Integer, BigInteger> temp = new HashMap<>(); temp.put( 0 , BigInteger.ONE); IntStream.rangeClosed( 1 , 100 ) .forEach(n -> temp.put(n, BigInteger.valueOf(n) .multiply(temp.get(n - 1 ))) ); factorials = temp; } // Returns factorial of n, between 0 and 100. public BigInteger factorial( int n) { if (n < 0 || n > 100 ) throw new IllegalArgumentException(); return factorials.get(n); } } |
Beschränkungen aufbauen
Eine andere Möglichkeit, unser Problem mit gemeinsam genutzten veränderlichen Daten zu vermeiden, ist es, die Daten einfach nicht mehr verteilt zu nutzen. Wir haben verschiedene Techniken, dies zu tun. Für das nächste Beispiel in Listing 4 müssen Sie den Code in Ihre IDE eingeben und ausprobieren. Lesen Sie nicht weiter, bevor Sie das getan haben, sonst lernen Sie dabei nicht so viel.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import java.util.stream.*; public class LotsOfRandoms { public static void main(String... args) { long time = System.currentTimeMillis(); System.out.println( LongStream.range( 0 , 100_000_000) .parallel() .mapToDouble(l -> random()) .sum() ); time = System.currentTimeMillis() - time; System.out.println(time); } private static double random() { return Math.random(); } } |
Führen Sie den Code auf einer Maschine mit mehreren Prozessoren aus. Die meisten Handys haben mittlerweile mehrere Kerne, eine passende Maschine sollte also nicht zu schwer zu finden sein. Auf meinem MacBook Pro mit acht hyper-threaded Cores dauerte das vollständige Ausführen rund 24 Sekunden. Probieren Sie es auf Ihrer Maschine aus. Denken Sie darüber nach, wie lange es dauern würde, wenn Sie die random()-Methode synchronisiert nutzen würden, etwa so:
1
2
3
|
private static synchronized double random() { return Math.random(); } |
Wir alle wissen, dass synchronized langsam ist. Deshalb wurde die synchronisierte Vector-Klasse durch die unsynchronisierte Klasse ArrayList ersetzt. Aber wie viel langsamer ist es? Viermal? Zehnmal? Hundertmal? Wenn ich auf meiner Maschine random() synchronisiert nutze, dauert es etwa 8,4 Sekunden. Es ist also nicht langsamer, es ist fast dreimal schneller! Der Grund dafür ist leicht zu erklären. Math.random() ist bereits threadsicher, nutzt aber zwei Aufrufe zu einem nicht blockierenden Algorithmus mit AtomicLong. Da es sich um ein häufig angefordertes Objekt handelt, ist die Wahrscheinlichkeit hoch, dass Threads das Rennen verlieren und wiederholt CompareAndSwap-(CAS-)Operationen ausführen müssen. Durch die Synchronisierung werden wir diese Zusammenstöße los und der Code ist schneller. Natürlich ist keine der beiden Möglichkeiten wirklich großartig. Wir sollten lieber „thread confined“ oder „stack confined“ arbeiten. Stack Confinement bedeutet, dass das Objekt nur auf dem Aufrufstapel sichtbar ist. In unserem Fall könnte das bedeuten, dass wir eine Random-Instanz innerhalb der random()-Methode aufbauen und dann darauf nextDouble() aufrufen. Probieren Sie zum Beispiel Folgendes aus:
1
2
3
|
private static double random() { return new Random().nextDouble(); } |
Erstaunlicherweise ist dieser Code auf meiner Maschine in etwa zehn Sekunden erledigt, also auch viel schneller als die threadsichere Version von Math.random(), trotz des Erstellens von einer Milliarde java.util.Random-Objekten, die jeweils System.nanoTime() in ihren Konstruktoren aufrufen.
Ein anderer Ansatz ist es, jedem Thread sein eigenes Exemplar von Random zu geben. Zum Beispiel könnten wir ThreadLocal<Random> so verwenden:
1
2
3
4
5
|
private static final ThreadLocal<Random> tlr = ThreadLocal.withInitial(Random:: new ); private static double random() { return tlr.get().nextDouble(); } |
Das ist auf meiner Maschine in nur 700 Millisekunden fertig, also über dreißigmal schneller als der erste Aufruf von Math.random(). Beachten Sie, dass die Random-Objekte, die wir verwenden, immer noch threadsicher sind. Es könnte also sein, dass wir für den Schutz des Zustands vor Verfälschung später noch einen Preis zahlen müssen, wenn dann eine Verfälschung nicht mehr möglich ist. Es könnte besser sein, jetzt zu einer Random-Klasse zu wechseln, die nicht threadsicher ist. Eine solche kam als ThreadLocalRandom-Klasse mit Java 7. Aus unserem Code wird also:
1
2
3
|
private static double random() { return ThreadLocalRandom.current().nextDouble(); } |
Java 7 verwendet den Standard-ThreadLocal-Mechanismus, um die Daten für die Zufallszahlengenerierung zu speichern. In Java 8 sind dies nun Felder in Thread direkt. Es dauert jetzt nur noch 200 Millisekunden. Es ist also mehr als hundertmal schneller als unser Originalcode. Wir sind von einem gemeinsam genutzten, veränderlichen Objekt zu einer Thread-confined-Version gewechselt und erhielten eine 100fache Leistungssteigerung.
Fazit
Zu diesem Thema gibt es noch viel mehr zu sagen. Für einen tieferen Einstieg in das Thema schlage ich vor, dass Sie Brian Goetz‘ Buch „Java Concurrency in Practice“ sorgfältig lesen. Wir führen auch Concurrency-Kurse in Zusammenarbeit mit S&S Media durch, wo Sie diese und viele andere Tricks lernen können, um die Performance Ihrer Systeme deutlich zu steigern.