Häufige Fallstricke bei der Parallelität in Java

1. Einleitung

In diesem Tutorial werden wir einige der häufigsten Parallelitätsprobleme in Java sehen. Wir werden auch lernen, wie man sie und ihre Hauptursachen vermeidet.

2. Thread-sichere Objekte verwenden

2.1. Objekte teilen

Threads kommunizieren hauptsächlich, indem sie den Zugriff auf dieselben Objekte gemeinsam nutzen. Das Lesen von einem Objekt, während es sich ändert, kann zu unerwarteten Ergebnissen führen. Wenn Sie ein Objekt gleichzeitig ändern, kann es auch beschädigt oder inkonsistent sein.

Die Hauptmethode, mit der wir solche Parallelitätsprobleme vermeiden und zuverlässigen Code erstellen können, besteht darin, mit unveränderlichen Objekten zu arbeiten . Dies liegt daran, dass ihr Status nicht durch die Interferenz mehrerer Threads geändert werden kann.

Wir können jedoch nicht immer mit unveränderlichen Objekten arbeiten. In diesen Fällen müssen wir Wege finden, um unsere veränderlichen Objekte threadsicher zu machen.

2.2. Sammlungen threadsicher machen

Wie jedes andere Objekt behalten Sammlungen den Status intern bei. Dies kann durch mehrere Threads geändert werden, die gleichzeitig die Sammlung ändern. So eine Art und Weise wir sicher mit Sammlungen in einer Multithread - Umgebung arbeiten können , ist , sie zu synchronisieren :

Map map = Collections.synchronizedMap(new HashMap()); List list = Collections.synchronizedList(new ArrayList());

Im Allgemeinen hilft uns die Synchronisation, gegenseitigen Ausschluss zu erreichen. Insbesondere kann auf diese Sammlungen jeweils nur ein Thread zugreifen. Auf diese Weise können wir vermeiden, dass Sammlungen in einem inkonsistenten Zustand belassen werden.

2.3. Spezialisierte Multithread-Sammlungen

Betrachten wir nun ein Szenario, in dem mehr Lese- als Schreibvorgänge erforderlich sind. Durch die Verwendung einer synchronisierten Sammlung kann unsere Anwendung erhebliche Auswirkungen auf die Leistung haben. Wenn zwei Threads die Sammlung gleichzeitig lesen möchten, muss einer warten, bis der andere fertig ist.

Aus diesem Grund bietet Java gleichzeitige Sammlungen wie CopyOnWriteArrayList und ConcurrentHashMap , auf die mehrere Threads gleichzeitig zugreifen können:

CopyOnWriteArrayList list = new CopyOnWriteArrayList(); Map map = new ConcurrentHashMap();

Die CopyOnWriteArrayList erreicht Thread-Sicherheit, indem eine separate Kopie des zugrunde liegenden Arrays für mutative Operationen wie Hinzufügen oder Entfernen erstellt wird. Obwohl es eine schlechtere Leistung für Schreibvorgänge aufweist als eine Collections.synchronizedList, bietet es uns eine bessere Leistung, wenn wir wesentlich mehr Lese- als Schreibvorgänge benötigen.

ConcurrentHashMap ist grundsätzlich threadsicher und leistungsfähiger als der Collections.synchronizedMap- Wrapper für eine nicht threadsichere Map . Tatsächlich handelt es sich um eine thread-sichere Karte von thread-sicheren Karten, mit der verschiedene Aktivitäten gleichzeitig in den untergeordneten Karten ausgeführt werden können.

2.4. Arbeiten mit nicht threadsicheren Typen

Wir verwenden häufig integrierte Objekte wie SimpleDateFormat , um Datumsobjekte zu analysieren und zu formatieren. Die SimpleDateFormat- Klasse ändert ihren internen Status während der Ausführung ihrer Operationen.

Wir müssen sehr vorsichtig mit ihnen sein, weil sie nicht threadsicher sind. Ihr Zustand kann in einer Multithread-Anwendung aufgrund von Dingen wie Rennbedingungen inkonsistent werden.

Wie können wir das SimpleDateFormat sicher verwenden? Wir haben mehrere Möglichkeiten:

  • Erstellen Sie bei jeder Verwendung eine neue Instanz von SimpleDateFormat
  • Beschränken Sie die Anzahl der Objekte, die mit einem ThreadLocal- Objekt erstellt wurden. Es garantiert, dass jeder Thread eine eigene Instanz von SimpleDateFormat hat
  • Synchronisieren Sie den gleichzeitigen Zugriff mehrerer Threads mit dem synchronisierten Schlüsselwort oder einer Sperre

SimpleDateFormat ist nur ein Beispiel dafür. Wir können diese Techniken mit jedem nicht threadsicheren Typ verwenden.

3. Rennbedingungen

Eine Race-Bedingung tritt auf, wenn zwei oder mehr Threads auf gemeinsam genutzte Daten zugreifen und gleichzeitig versuchen, diese zu ändern. Daher können Rennbedingungen Laufzeitfehler oder unerwartete Ergebnisse verursachen.

3.1. Beispiel für eine Rennbedingung

Betrachten wir den folgenden Code:

class Counter { private int counter = 0; public void increment() { counter++; } public int getValue() { return counter; } }

Die Counter- Klasse ist so konzipiert, dass bei jedem Aufruf der Inkrementierungsmethode dem Zähler 1 hinzugefügt wird . Wenn jedoch von mehreren Threads auf ein Counter- Objekt verwiesen wird, kann die Interferenz zwischen Threads verhindern, dass dies wie erwartet geschieht.

Wir können die Anweisung counter ++ in drei Schritte zerlegen:

  • Rufen Sie den aktuellen Wert des Zählers ab
  • Erhöhen Sie den abgerufenen Wert um 1
  • Speichern Sie den inkrementierten Wert wieder im Zähler

Nehmen wir nun an, zwei Threads, thread1 und thread2 , rufen gleichzeitig die Inkrementierungsmethode auf. Ihre verschachtelten Aktionen könnten dieser Reihenfolge folgen:

  • thread1 liest den aktuellen Wert des Zählers ; 0
  • thread2 liest den aktuellen Wert des Zählers ; 0
  • thread1 erhöht den abgerufenen Wert; das Ergebnis ist 1
  • thread2 erhöht den abgerufenen Wert; das Ergebnis ist 1
  • thread1 speichert das Ergebnis im Zähler ; das Ergebnis ist jetzt 1
  • thread2 speichert das Ergebnis im Zähler ; das Ergebnis ist jetzt 1

Wir haben erwartet, dass der Wert des Zählers 2 ist, aber er war 1.

3.2. Eine synchronisierte Lösung

Wir können die Inkonsistenz beheben, indem wir den kritischen Code synchronisieren:

class SynchronizedCounter { private int counter = 0; public synchronized void increment() { counter++; } public synchronized int getValue() { return counter; } }

Es darf immer nur ein Thread die synchronisierten Methoden eines Objekts verwenden, wodurch die Konsistenz beim Lesen und Schreiben des Zählers erzwungen wird .

3.3. Eine integrierte Lösung

Wir können den obigen Code durch ein integriertes AtomicInteger- Objekt ersetzen . Diese Klasse bietet unter anderem atomare Methoden zum Inkrementieren einer Ganzzahl und ist eine bessere Lösung als das Schreiben unseres eigenen Codes. Daher können wir seine Methoden direkt aufrufen, ohne dass eine Synchronisierung erforderlich ist:

AtomicInteger atomicInteger = new AtomicInteger(3); atomicInteger.incrementAndGet();

In diesem Fall löst das SDK das Problem für uns. Andernfalls hätten wir auch unseren eigenen Code schreiben können, der die kritischen Abschnitte in einer benutzerdefinierten thread-sicheren Klasse zusammenfasst. Dieser Ansatz hilft uns, die Komplexität zu minimieren und die Wiederverwendbarkeit unseres Codes zu maximieren.

4. Rennbedingungen rund um Sammlungen

4.1. Das Problem

Eine weitere Gefahr, in die wir geraten können, ist die Annahme, dass synchronisierte Sammlungen uns mehr Schutz bieten als sie tatsächlich tun.

Lassen Sie uns den folgenden Code untersuchen:

List list = Collections.synchronizedList(new ArrayList()); if(!list.contains("foo")) { list.add("foo"); }

Every operation of our list is synchronized, but any combinations of multiple method invocations are not synchronized. More specifically, between the two operations, another thread can modify our collection leading to undesired results.

For example, two threads could enter the if block at the same time and then update the list, each thread adding the foo value to the list.

4.2. A Solution for Lists

We can protect the code from being accessed by more than one thread at a time using synchronization:

synchronized (list) { if (!list.contains("foo")) { list.add("foo"); } }

Rather than adding the synchronized keyword to the functions, we've created a critical section concerning list, which only allows one thread at a time to perform this operation.

We should note that we can use synchronized(list) on other operations on our list object, to provide a guarantee that only one thread at a time can perform any of our operations on this object.

4.3. A Built-In Solution for ConcurrentHashMap

Now, let's consider using a map for the same reason, namely adding an entry only if it's not present.

The ConcurrentHashMap offers a better solution for this type of problem. We can use its atomic putIfAbsent method:

Map map = new ConcurrentHashMap(); map.putIfAbsent("foo", "bar");

Or, if we want to compute the value, its atomic computeIfAbsent method:

map.computeIfAbsent("foo", key -> key + "bar");

We should note that these methods are part of the interface to Map where they offer a convenient way to avoid writing conditional logic around insertion. They really help us out when trying to make multi-threaded calls atomic.

5. Memory Consistency Issues

Memory consistency issues occur when multiple threads have inconsistent views of what should be the same data.

In addition to the main memory, most modern computer architectures are using a hierarchy of caches (L1, L2, and L3 caches) to improve the overall performance. Thus, any thread may cache variables because it provides faster access compared to the main memory.

5.1. The Problem

Let's recall our Counter example:

class Counter { private int counter = 0; public void increment() { counter++; } public int getValue() { return counter; } }

Let's consider the scenario where thread1 increments the counter and then thread2 reads its value. The following sequence of events might happen:

  • thread1 reads the counter value from its own cache; counter is 0
  • thread1 increments the counter and writes it back to its own cache; counter is 1
  • thread2 reads the counter value from its own cache; counter is 0

Of course, the expected sequence of events could happen too and the thread2 will read the correct value (1), but there is no guarantee that changes made by one thread will be visible to other threads every time.

5.2. The Solution

In order to avoid memory consistency errors, we need to establish a happens-before relationship. This relationship is simply a guarantee that memory updates by one specific statement are visible to another specific statement.

There are several strategies that create happens-before relationships. One of them is synchronization, which we've already looked at.

Synchronization ensures both mutual exclusion and memory consistency. However, this comes with a performance cost.

We can also avoid memory consistency problems by using the volatile keyword. Simply put, every change to a volatile variable is always visible to other threads.

Let's rewrite our Counter example using volatile:

class SyncronizedCounter { private volatile int counter = 0; public synchronized void increment() { counter++; } public int getValue() { return counter; } }

We should note that we still need to synchronize the increment operation because volatile doesn't ensure us mutual exclusion. Using simple atomic variable access is more efficient than accessing these variables through synchronized code.

5.3. Non-Atomic long and double Values

So, if we read a variable without proper synchronization, we may see a stale value. For long and double values, quite surprisingly, it's even possible to see completely random values in addition to stale ones.

According to JLS-17, JVM may treat 64-bit operations as two separate 32-bit operations. Therefore, when reading a long or double value, it's possible to read an updated 32-bit along with a stale 32-bit. Consequently, we may observe random-looking long or double values in concurrent contexts.

On the other hand, writes and reads of volatile long and double values are always atomic.

6. Misusing Synchronize

The synchronization mechanism is a powerful tool to achieve thread-safety. It relies on the use of intrinsic and extrinsic locks. Let's also remember the fact that every object has a different lock and only one thread can acquire a lock at a time.

However, if we don't pay attention and carefully choose the right locks for our critical code, unexpected behavior can occur.

6.1. Synchronizing on this Reference

The method-level synchronization comes as a solution to many concurrency issues. However, it can also lead to other concurrency issues if it's overused. This synchronization approach relies on the this reference as a lock, which is also called an intrinsic lock.

We can see in the following examples how a method-level synchronization can be translated into a block-level synchronization with the this reference as a lock.

These methods are equivalent:

public synchronized void foo() { //... }
public void foo() { synchronized(this) { //... } }

When such a method is called by a thread, other threads cannot concurrently access the object. This can reduce concurrency performance as everything ends up running single-threaded. This approach is especially bad when an object is read more often than it is updated.

Moreover, a client of our code might also acquire the this lock. In the worst-case scenario, this operation can lead to a deadlock.

6.2. Deadlock

Deadlock describes a situation where two or more threads block each other, each waiting to acquire a resource held by some other thread.

Let's consider the example:

public class DeadlockExample { public static Object lock1 = new Object(); public static Object lock2 = new Object(); public static void main(String args[]) { Thread threadA = new Thread(() -> { synchronized (lock1) { System.out.println("ThreadA: Holding lock 1..."); sleep(); System.out.println("ThreadA: Waiting for lock 2..."); synchronized (lock2) { System.out.println("ThreadA: Holding lock 1 & 2..."); } } }); Thread threadB = new Thread(() -> { synchronized (lock2) { System.out.println("ThreadB: Holding lock 2..."); sleep(); System.out.println("ThreadB: Waiting for lock 1..."); synchronized (lock1) { System.out.println("ThreadB: Holding lock 1 & 2..."); } } }); threadA.start(); threadB.start(); } }

In the above code we can clearly see that first threadA acquires lock1 and threadB acquires lock2. Then, threadA tries to get the lock2 which is already acquired by threadB and threadB tries to get the lock1 which is already acquired by threadA. So, neither of them will proceed meaning they are in a deadlock.

We can easily fix this issue by changing the order of locks in one of the threads.

We should note that this is just one example, and there are many others that can lead to a deadlock.

7. Conclusion

In diesem Artikel haben wir einige Beispiele für Parallelitätsprobleme untersucht, auf die wir in unseren Multithread-Anwendungen wahrscheinlich stoßen.

Zuerst haben wir gelernt, dass wir uns für Objekte oder Operationen entscheiden sollten, die entweder unveränderlich oder threadsicher sind.

Dann haben wir einige Beispiele für Rennbedingungen gesehen und wie wir sie mithilfe des Synchronisationsmechanismus vermeiden können. Darüber hinaus haben wir etwas über gedächtnisbedingte Rennbedingungen und deren Vermeidung gelernt.

Obwohl der Synchronisationsmechanismus uns hilft, viele Parallelitätsprobleme zu vermeiden, können wir ihn leicht missbrauchen und andere Probleme verursachen. Aus diesem Grund haben wir verschiedene Probleme untersucht, die auftreten können, wenn dieser Mechanismus schlecht genutzt wird.

Wie üblich sind alle in diesem Artikel verwendeten Beispiele auf GitHub verfügbar.