Un'introduzione alle variabili atomiche in Java

1. Introduzione

In poche parole, uno stato mutevole condiviso porta molto facilmente a problemi quando è coinvolta la concorrenza. Se l'accesso agli oggetti modificabili condivisi non viene gestito correttamente, le applicazioni possono diventare rapidamente soggette ad alcuni errori di concorrenza difficili da rilevare.

In questo articolo, rivisiteremo l'uso dei blocchi per gestire l'accesso simultaneo, esploreremo alcuni degli svantaggi associati ai blocchi e, infine, introdurremo variabili atomiche come alternativa.

2. Serrature

Diamo uno sguardo alla classe:

public class Counter { int counter; public void increment() { counter++; } }

Nel caso di un ambiente a thread singolo, questo funziona perfettamente; tuttavia, non appena permettiamo a più di un thread di scrivere, iniziamo a ottenere risultati incoerenti.

Ciò è dovuto alla semplice operazione di incremento ( counter ++ ), che può sembrare un'operazione atomica, ma in realtà è una combinazione di tre operazioni: ottenere il valore, incrementare e riscrivere il valore aggiornato.

Se due thread tentano di ottenere e aggiornare il valore contemporaneamente, potrebbe verificarsi la perdita degli aggiornamenti.

Uno dei modi per gestire l'accesso a un oggetto è utilizzare i blocchi. Ciò può essere ottenuto utilizzando la parola chiave synchronized nella firma del metodo increment . La parola chiave sincronizzata garantisce che un solo thread possa accedere al metodo alla volta (per saperne di più su Blocco e sincronizzazione, fare riferimento a - Guida alla parola chiave sincronizzata in Java):

public class SafeCounterWithLock { private volatile int counter; public synchronized void increment() { counter++; } }

Inoltre, è necessario aggiungere la parola chiave volatile per garantire un'adeguata visibilità di riferimento tra i thread.

L'uso dei lucchetti risolve il problema. Tuttavia, la performance subisce un colpo.

Quando più thread tentano di acquisire un blocco, uno di essi vince, mentre il resto dei thread viene bloccato o sospeso.

Il processo di sospensione e ripresa di un thread è molto costoso e influisce sull'efficienza complessiva del sistema.

In un piccolo programma, come il contatore , il tempo impiegato nel cambio di contesto può diventare molto più dell'effettiva esecuzione del codice, riducendo così notevolmente l'efficienza complessiva.

3. Operazioni atomiche

Esiste un ramo di ricerca incentrato sulla creazione di algoritmi non bloccanti per ambienti concorrenti. Questi algoritmi sfruttano le istruzioni della macchina atomica di basso livello come il confronto e scambio (CAS), per garantire l'integrità dei dati.

Una tipica operazione CAS funziona su tre operandi:

  1. La posizione di memoria su cui operare (M)
  2. Il valore atteso esistente (A) della variabile
  3. Il nuovo valore (B) che deve essere impostato

L'operazione CAS aggiorna atomicamente il valore in M ​​in B, ma solo se il valore esistente in M ​​corrisponde ad A, altrimenti non viene intrapresa alcuna azione.

In entrambi i casi, viene restituito il valore esistente in M. Questo combina tre passaggi - ottenere il valore, confrontare il valore e aggiornare il valore - in un'unica operazione a livello di macchina.

Quando più thread tentano di aggiornare lo stesso valore tramite CAS, uno di essi vince e aggiorna il valore. Tuttavia, a differenza del caso dei lock, nessun altro thread viene sospeso ; vengono invece semplicemente informati di non essere riusciti ad aggiornare il valore. I thread possono quindi procedere per eseguire ulteriori operazioni e si evitano completamente i cambi di contesto.

Un'altra conseguenza è che la logica del programma principale diventa più complessa. Questo perché dobbiamo gestire lo scenario in cui l'operazione CAS non è riuscita. Possiamo riprovare ancora e ancora finché non riesce, oppure non possiamo fare nulla e andare avanti a seconda del caso d'uso.

4. Variabili atomiche in Java

Le classi di variabili atomiche più comunemente utilizzate in Java sono AtomicInteger, AtomicLong, AtomicBoolean e AtomicReference. Queste classi rappresentano rispettivamente un riferimento int , long , booleano e oggetto che possono essere aggiornati atomicamente. I metodi principali esposti da queste classi sono:

  • get () - ottiene il valore dalla memoria, in modo che le modifiche apportate da altri thread siano visibili; equivale a leggere una variabile volatile
  • set () - scrive il valore in memoria, in modo che la modifica sia visibile ad altri thread; equivale a scrivere una variabile volatile
  • lazySet () - alla fine scrive il valore in memoria, forse riordinato con successive operazioni di memoria rilevanti. Un caso d'uso è l'annullamento dei riferimenti, per il bene della garbage collection, a cui non si accederà mai più. In questo caso, si ottengono prestazioni migliori ritardando la scrittura volatile nullo
  • compareAndSet () - come descritto nella sezione 3, restituisce true quando ha successo, altrimenti false
  • weakCompareAndSet () - come descritto nella sezione 3, ma più debole nel senso che non crea ordinamenti prima del verificarsi. Ciò significa che potrebbe non vedere necessariamente gli aggiornamenti effettuati ad altre variabili. A partire da Java 9, questo metodo è stato deprecato in tutte le implementazioni atomiche a favore di weakCompareAndSetPlain () . Gli effetti sulla memoria di weakCompareAndSet () erano chiari ma i suoi nomi implicavano effetti sulla memoria volatile. Per evitare questa confusione, hanno deprecato questo metodo e aggiunto quattro metodi con diversi effetti di memoria come weakCompareAndSetPlain () o weakCompareAndSetVolatile ()

Un contatore thread-safe implementato con AtomicInteger è mostrato nell'esempio seguente:

public class SafeCounterWithoutLock { private final AtomicInteger counter = new AtomicInteger(0); public int getValue() { return counter.get(); } public void increment() { while(true) { int existingValue = getValue(); int newValue = existingValue + 1; if(counter.compareAndSet(existingValue, newValue)) { return; } } } }

Come puoi vedere, ripetiamo l' operazione compareAndSet e di nuovo in caso di errore, poiché vogliamo garantire che la chiamata al metodo increment aumenti sempre il valore di 1.

5. conclusione

In questo breve tutorial, abbiamo descritto un modo alternativo di gestire la concorrenza in cui è possibile evitare gli svantaggi associati al blocco. Abbiamo anche esaminato i principali metodi esposti dalle classi di variabili atomiche in Java.

Come sempre, gli esempi sono tutti disponibili su GitHub.

Per esplorare più classi che utilizzano internamente algoritmi non bloccanti fare riferimento a una guida a ConcurrentMap.