Insidie ​​comuni della concorrenza in Java

1. Introduzione

In questo tutorial, vedremo alcuni dei problemi di concorrenza più comuni in Java. Impareremo anche come evitarli e le loro cause principali.

2. Utilizzo di oggetti thread-safe

2.1. Condivisione di oggetti

I thread comunicano principalmente condividendo l'accesso agli stessi oggetti. Quindi, leggere da un oggetto mentre cambia può dare risultati inaspettati. Inoltre, la modifica simultanea di un oggetto può lasciarlo in uno stato danneggiato o incoerente.

Il modo principale per evitare tali problemi di concorrenza e creare codice affidabile è lavorare con oggetti immutabili . Questo perché il loro stato non può essere modificato dall'interferenza di più thread.

Tuttavia, non possiamo sempre lavorare con oggetti immutabili. In questi casi, dobbiamo trovare modi per rendere i nostri oggetti mutabili thread-safe.

2.2. Rendere le raccolte thread-safe

Come ogni altro oggetto, le raccolte mantengono lo stato internamente. Ciò potrebbe essere modificato da più thread che cambiano la raccolta contemporaneamente. Quindi, un modo in cui possiamo lavorare in sicurezza con le raccolte in un ambiente multithread è sincronizzarle :

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

In generale, la sincronizzazione ci aiuta a raggiungere l'esclusione reciproca. Più specificamente, è possibile accedere a queste raccolte da un solo thread alla volta. Pertanto, possiamo evitare di lasciare le raccolte in uno stato incoerente.

2.3. Collezioni multithread specialistiche

Consideriamo ora uno scenario in cui abbiamo bisogno di più letture che scritture. Utilizzando una raccolta sincronizzata, la nostra applicazione può subire importanti conseguenze sulle prestazioni. Se due thread vogliono leggere la raccolta contemporaneamente, uno deve attendere fino al termine dell'altro.

Per questo motivo, Java fornisce raccolte simultanee come CopyOnWriteArrayList e ConcurrentHashMap a cui è possibile accedere contemporaneamente da più thread:

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

Il CopyOnWriteArrayList realizza thread safety creando una copia separata dell'array sottostante per operazioni mutative come aggiungere o rimuovere. Sebbene abbia prestazioni inferiori per le operazioni di scrittura rispetto a Collections.synchronizedList, ci fornisce prestazioni migliori quando abbiamo bisogno di molte più letture che scritture.

ConcurrentHashMap è fondamentalmente thread-safe ed è più performante rispetto alla Collections.synchronizedMap wrapper per un non-thread-safe Map . In realtà è una mappa thread-safe di mappe thread-safe, che consente a diverse attività di svolgersi simultaneamente nelle sue mappe figlio.

2.4. Lavorare con tipi non thread-safe

Spesso usiamo oggetti incorporati come SimpleDateFormat per analizzare e formattare gli oggetti data. La classe SimpleDateFormat modifica il proprio stato interno durante le operazioni.

Dobbiamo stare molto attenti con loro perché non sono thread-safe. Il loro stato può diventare incoerente in un'applicazione multithread a causa di cose come le race condition.

Quindi, come possiamo utilizzare il SimpleDateFormat in modo sicuro? Abbiamo diverse opzioni:

  • Crea una nuova istanza di SimpleDateFormat ogni volta che viene utilizzato
  • Limita il numero di oggetti creati utilizzando un oggetto ThreadLocal . Garantisce che ogni thread avrà la propria istanza di SimpleDateFormat
  • Sincronizza l'accesso simultaneo da più thread con la parola chiave sincronizzata o un blocco

SimpleDateFormat è solo un esempio di questo. Possiamo usare queste tecniche con qualsiasi tipo non thread-safe.

3. Condizioni di gara

Una condizione di competizione si verifica quando due o più thread accedono ai dati condivisi e provano a modificarli contemporaneamente. Pertanto, le condizioni di competizione possono causare errori di runtime o risultati imprevisti.

3.1. Esempio di condizioni di gara

Consideriamo il seguente codice:

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

La classe Counter è progettata in modo che ogni invocazione del metodo increment aggiunga 1 al contatore . Tuttavia, se si fa riferimento a un oggetto Counter da più thread, l'interferenza tra i thread può impedire che ciò avvenga come previsto.

Possiamo scomporre l' istruzione counter ++ in 3 passaggi:

  • Recupera il valore corrente del contatore
  • Incrementa il valore recuperato di 1
  • Memorizza il valore incrementato di nuovo nel contatore

Ora, supponiamo che due thread, thread1 e thread2 , invocino il metodo increment contemporaneamente. Le loro azioni interfogliate potrebbero seguire questa sequenza:

  • thread1 legge il valore corrente di counter ; 0
  • thread2 legge il valore corrente di counter ; 0
  • thread1 incrementa il valore recuperato; il risultato è 1
  • thread2 incrementa il valore recuperato; il risultato è 1
  • thread1 memorizza il risultato in counter ; il risultato è ora 1
  • thread2 memorizza il risultato in counter ; il risultato è ora 1

Ci aspettavamo che il valore del contatore fosse 2, ma era 1.

3.2. Una soluzione basata sulla sincronizzazione

Possiamo correggere l'incongruenza sincronizzando il codice critico:

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

Solo un thread può utilizzare i metodi sincronizzati di un oggetto in qualsiasi momento, quindi questo impone la coerenza nella lettura e nella scrittura del contatore .

3.3. Una soluzione integrata

Possiamo sostituire il codice precedente con un oggetto AtomicInteger incorporato . Questa classe offre, tra gli altri, metodi atomici per incrementare un numero intero ed è una soluzione migliore rispetto alla scrittura del nostro codice. Pertanto, possiamo chiamare i suoi metodi direttamente senza la necessità di sincronizzazione:

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

In questo caso, l'SDK risolve il problema per noi. Altrimenti, avremmo anche potuto scrivere il nostro codice, incapsulando le sezioni critiche in una classe thread-safe personalizzata. Questo approccio ci aiuta a ridurre al minimo la complessità e a massimizzare la riusabilità del nostro codice.

4. Condizioni di gara intorno alle collezioni

4.1. Il problema

Un'altra trappola in cui possiamo cadere è pensare che le raccolte sincronizzate ci offrano più protezione di quanto non facciano effettivamente.

Esaminiamo il codice di seguito:

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 questo articolo, abbiamo esaminato diversi esempi di problemi di concorrenza che probabilmente incontreremo nelle nostre applicazioni multithread.

In primo luogo, abbiamo imparato che dovremmo optare per oggetti o operazioni che sono immutabili o thread-safe.

Quindi, abbiamo visto diversi esempi di condizioni di gara e come possiamo evitarle utilizzando il meccanismo di sincronizzazione. Inoltre, abbiamo imparato a conoscere le condizioni di gara legate alla memoria e come evitarle.

Sebbene il meccanismo di sincronizzazione ci aiuti a evitare molti problemi di concorrenza, possiamo facilmente utilizzarlo in modo improprio e creare altri problemi. Per questo motivo, abbiamo esaminato diversi problemi che potremmo incontrare quando questo meccanismo è usato male.

Come al solito, tutti gli esempi utilizzati in questo articolo sono disponibili su GitHub.