W3docs

Metodi e Blocchi Sincronizzati in Java

Usa metodi e blocchi sincronizzati in Java per proteggere le sezioni critiche e scegli il giusto oggetto lock.

Il capitolo precedente ha spiegato cosa fa synchronized. Questo è il capitolo sintattico — le tre forme che la parola chiave può assumere, quale lock utilizza ciascuna forma e come scegliere quella giusta. La forma scelta ha conseguenze sulle prestazioni e sulla correttezza; "basta mettere synchronized sul metodo" funziona nei casi banali e fallisce quando la classe cresce.

Tre forme, tre oggetti lock

FormaOggetto lockQuando usare
synchronized void method()thisClassi piccole e semplici. Un lock pubblico va bene.
synchronized static void method()ClassName.classModifica dello stato per-classe da qualsiasi istanza.
synchronized (obj) { ... }objQuasi tutto il resto. Usa un lock privato per sicurezza.

La terza forma è la più flessibile. Le prime due sono zucchero sintattico per essa.

synchronized su un metodo d'istanza

public synchronized void deposit(int x) {
  balance += x;
}

Viene compilato in un blocco che acquisisce il lock su this. Solo un thread alla volta può eseguire qualsiasi metodo d'istanza sincronizzato su questo specifico oggetto. (Istanze diverse di Account hanno riferimenti this diversi e quindi monitor diversi.) I metodi statici e i metodi non sincronizzati non sono interessati.

L'insidia. this fa parte del riferimento pubblico. Qualsiasi codice che ha un riferimento all'oggetto può fare synchronized (account) { ... } e acquisire lo stesso lock di account.deposit(). Ciò include harness di test, debugger, codice del framework e qualsiasi altro call site che non controlli. Un chiamante mal funzionante può tenere il tuo lock per tutto il tempo che vuole e tu rimarrai in attesa.

Nelle classi piccole sarai l'unico chiamante — va bene. Nelle librerie, nel codice che altre persone useranno, o nelle classi che potresti in seguito rifattorizzare, preferisci un oggetto lock privato.

synchronized su un metodo statico

public class Counters {
  private static int total;

  public static synchronized void bump() {
    total++;
  }
}

Viene compilato in un blocco che acquisisce il lock su Counters.class. Il monitor è globale per classe — ogni thread, ogni istanza, contende per lo stesso lock quando chiama bump(). Si applica la stessa avvertenza di this: qualsiasi altro codice può anche fare synchronized (Counters.class) { ... } e acquisire il lock.

Per lo stato per-classe, questa forma va bene nelle classi di utilità piccole. Per quelle più grandi, preferisci un lock statico privato:

public class Counters {
  private static final Object LOCK = new Object();
  private static int total;

  public static void bump() {
    synchronized (LOCK) { total++; }
  }
}

synchronized su un oggetto esplicito — la forma da produzione

public class Cache {
  private final Object lock = new Object();
  private final Map<String, String> data = new HashMap<>();

  public String get(String k) {
    synchronized (lock) {
      return data.get(k);
    }
  }

  public void put(String k, String v) {
    synchronized (lock) {
      data.put(k, v);
    }
  }
}

Questa forma ti offre due proprietà:

  • Lock privato. Nessun chiamante può acquisirlo; nessuno può tenerti in attesa.
  • Scope chirurgico. Solo l'interno del blocco acquisisce il lock. Tutto il resto — validazione degli argomenti, formattazione del valore di ritorno, logging — viene eseguito senza contesa.

Per lo stesso motivo per cui mantieni i campi private final privati, mantieni il tuo lock privato. L'oggetto lock fa parte della tua implementazione, non della tua interfaccia.

Regola: mantieni la sezione critica ridotta al minimo

Più codice viene eseguito mentre si tiene un lock, maggiore è la contesa che si crea. Il pattern corretto è fare il minimo necessario all'interno del blocco:

// Bad: I/O inside the lock — everybody waits while one thread talks to disk
public synchronized void load(String k) {
  String v = Files.readString(Path.of("/tmp/" + k));         // bad
  cache.put(k, v);
}

// Good: read outside the lock, lock only the mutation
public void load(String k) throws IOException {
  String v = Files.readString(Path.of("/tmp/" + k));
  synchronized (lock) {
    cache.put(k, v);
  }
}

Il principio generale: acquisisci il lock solo mentre modifichi lo stato condiviso, mai mentre esegui lavoro arbitrario che potrebbe bloccarsi.

Azioni composte e doppio locking

synchronized protegge un blocco. Se due operazioni insieme devono essere atomiche, entrambe devono trovarsi nello stesso blocco:

// Wrong: the if and the put are individually synchronised by HashMap... no they're not,
// but even if they were, the gap between them is not.
if (!map.containsKey(k)) {                            // someone else could insert here
  map.put(k, v);
}

// Right: one block protects both ops
synchronized (lock) {
  if (!map.containsKey(k)) {
    map.put(k, v);
  }
}

// Even better: a single atomic operation
map.putIfAbsent(k, v);                                // for ConcurrentHashMap, fully atomic

La race condition tra containsKey e put — nota come race check-then-act — è la fonte di più bug di concorrenza rispetto al locking stesso. Ogni volta che scrivi if (...) doThing(), chiediti: tra l'if e il doThing, un altro thread può cambiare il risultato? Se sì, rendi l'operazione atomica.

I lock non si compongono — attenzione all'ordine di acquisizione

Due blocchi synchronized acquisiti in ordini diversi da thread diversi possono causare deadlock:

// Thread A
synchronized (account1) {
  synchronized (account2) { transfer(account1, account2, 100); }
}

// Thread B simultaneously
synchronized (account2) {
  synchronized (account1) { transfer(account2, account1, 100); }
}

Ogni thread tiene un lock e aspetta l'altro. Entrambi i thread sono in stato BLOCKED per sempre. La soluzione è un ordine consistente — acquisire sempre i lock nello stesso ordine globale:

void transfer(Account a, Account b, int x) {
  Account first  = a.id() < b.id() ? a : b;          // ordering by stable key
  Account second = a.id() < b.id() ? b : a;
  synchronized (first) {
    synchronized (second) {
      a.debit(x);
      b.credit(x);
    }
  }
}

L'ordinamento basato su hash, l'ordinamento basato su System.identityHashCode, o un lock tie-breaker sono i tre approcci più comuni. Il capitolo sui deadlock li tratta in dettaglio.

E synchronized su un primitivo?

Non puoi. synchronized richiede un oggetto — un long o int non ha un monitor. Incapsulalo (Long/Integer) e puoi sintatticamente acquisire il lock su di esso, ma non farlo mai: i primitivi boxed nella cache di auto-boxing sono condivisi. Due parti di codice che acquisiscono il lock su Integer.valueOf(1) stanno bloccando lo stesso oggetto — anche se non hanno nulla a che fare l'una con l'altra.

synchronized (Integer.valueOf(1)) {                   // never do this
  ...
}

Per gli oggetti lock, alloca sempre un Object privato. Il senso di un monitor è l'identità, non il valore.

synchronized e le eccezioni

Se il corpo di un blocco sincronizzato lancia un'eccezione, il monitor viene rilasciato mentre l'eccezione si propaga. Non hai bisogno di un finally per lo sblocco — la JVM lo gestisce. Questo è uno dei motivi principali per cui synchronized è difficile da usare in modo errato: non c'è "lock leak" come avviene con l'API esplicita Lock con lock()/unlock() (dove lo sblocco è una chiamata di metodo separata che devi ricordare di mettere in un finally).

Il rovescio della medaglia: qualsiasi stato condiviso che hai modificato a metà all'interno del blocco è visibile al prossimo acquirente del lock. Se l'eccezione lascia i tuoi invarianti rotti, il lock da solo non ti salva — ripristina gli invarianti nel catch o progetta la mutazione in modo che non possa completarsi a metà.

Un esempio pratico: le quattro forme a confronto

Il programma seguente usa ogni forma sullo stesso stato condiviso e termina con un confronto affiancato.

java— editable, runs on the server

Cosa si ricava dall'esecuzione:

  • Tutte e tre le forme con contatore hanno prodotto il conteggio atteso 800.000. Ogni forma ha scelto un oggetto lock diverso (this, un Object privato, la Class) ma ognuna ha protetto la lettura-modifica-scrittura nello stesso modo. synchronized non si preoccupa di cosa sia l'oggetto lock — solo che ogni thread in contesa usi lo stesso.
  • La forma con metodo statico (V3) ha usato il monitor V3.class come lock. Ogni thread, ogni test, ogni altro pezzo di codice che si sincronizza su V3.class contende per lo stesso lock. Questo è appropriato per lo stato per-classe; usarlo per lo stato per-istanza è un bug di contesa — si bloccherebbero lavori non correlati tra loro.
  • Le forme con metodo statico e d'istanza sono comode ma acquisiscono il lock su un oggetto accessibile pubblicamente (this o la Class). Chiunque può fare synchronized (someObject) e tenere lo stesso monitor. La forma con oggetto lock privato (V2) è quella che il codice in produzione usa proprio perché nessuno al di fuori della classe può raggiungere il lock.
  • La classe V4 (definita ma non testata in benchmark sopra) mostra la forma sbagliata: lavoro simile all'I/O all'interno della sezione critica. La versione corretta successiva sposta la formattazione e la chiamata (simulata) bloccante fuori dal blocco synchronized in modo che la contesa riguardi solo la put effettiva. Stessa correttezza, con throughput molto più elevato sotto carico.
  • Il blocco con doppio lock alla fine ha acquisito due lock non correlati nell'ordine determinato da System.identityHashCode. Quella regola di ordinamento, applicata ovunque nel programma, è la strategia più semplice di prevenzione del deadlock quando si devono tenere due lock contemporaneamente. La rivedremo nel capitolo sul deadlock.

Prossimo argomento

Il prossimo capitolo, Comunicazione tra Thread in Java, introduce l'altra metà dell'API del monitor intrinseco — wait, notify e notifyAll — il modo in cui i thread si segnalano a vicenda all'interno di una sezione critica.

Esercitazione

Pratica
Scrivi `public synchronized void deposit(int x)` su un metodo della `class Account`. Quale monitor acquisisce il metodo?
Scrivi `public synchronized void deposit(int x)` su un metodo della `class Account`. Quale monitor acquisisce il metodo?
Was this page helpful?