English | 简体中文 | 繁體中文 | Русский язык | Français | Español | Português | Deutsch | 日本語 | 한국어 | Italiano | بالعربية
Metodi per migliorare le prestazioni delle lock in Java
Ci sforziamo di pensare a soluzioni per i problemi che incontriamo nei nostri prodotti, ma in questo articolo condividerò alcune tecniche comuni, tra cui la separazione delle lock, le strutture dati parallele, la protezione dei dati invece del codice, e la riduzione dell'area di azione delle lock, che ci permettono di non utilizzare strumenti per rilevare i deadlock.
La lock non è la causa del problema, è la competizione tra le lock
Di solito, quando si incontrano problemi di prestazioni nel codice multithreaded, si lamentevole per i problemi delle lock. Dopo tutto, è noto che le lock riducono la velocità di esecuzione del programma e la loro bassa scalabilità. Pertanto, se si inizia a ottimizzare il codice con questa 'conoscenza', il risultato potrebbe essere la comparsa di problemi di concorrenza fastidiosi.
Quindi, è molto importante comprendere la differenza tra lock competitive e non competitive. Quando un thread tenta di accedere a un blocco o metodo sincronizzato che è già in esecuzione da parte di un altro thread, si verifica una competizione per la lock. Il thread viene forzato a entrare in stato di attesa fino a quando il primo thread ha completato il blocco sincronizzato e ha rilasciato il monitor. Quando solo un thread tenta di eseguire un'area di codice sincronizzata in un momento dato, la lock rimane in uno stato non competitivo.
In realtà, in condizioni non competitive e nella maggior parte delle applicazioni, la JVM ha già ottimizzato la sincronizzazione. Le lock non competitive non comportano alcun costo aggiuntivo durante l'esecuzione. Pertanto, non dovresti lamentarti delle prestazioni delle lock, ma piuttosto delle competizioni tra le lock. Dopo aver chiarito questa consapevolezza, vediamo cosa possiamo fare per ridurre la probabilità di competizione o ridurre la durata della competizione.
Proteggere i dati invece del codice
Un metodo rapido per risolvere problemi di sicurezza delle thread è quello di bloccare l'accesso a tutto il metodo. Ad esempio, nel seguente esempio, si tenta di costruire un server di poker online utilizzando questo metodo:
class GameServer { public Map<<String, List<Player>> tables = new HashMap<String, List<Player>>(); public synchronized void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } public synchronized void leave(Player player, Table table) {/*body skipped for brevity*/} public synchronized void createTable() {/*body skipped for brevity*/} public synchronized void destroyTable(Table table) {/*body skipped for brevity*/} }
L'intenzione dell'autore è buona - quando un nuovo giocatore si unisce al tavolo di gioco, deve essere garantito che il numero di giocatori al tavolo non superi il numero massimo di giocatori che il tavolo può accogliere, che è 9.
Ma questa soluzione di fatto deve controllare l'ingresso dei giocatori nel tavolo di gioco in qualsiasi momento - anche quando il volume di accesso del server è basso, i thread in attesa di rilasciare il blocco di locking sono destinati a scatenare frequentemente eventi di competizione nel sistema. Il blocco di locking che contiene il controllo del saldo del conto e dei limiti del tavolo potrebbe aumentare significativamente i costi delle operazioni di chiamata, aumentando così la probabilità e la durata della competizione.
Il primo passo per risolvere il problema è assicurarsi che proteggiamo i dati e non la dichiarazione di sincronizzazione spostata dal dichiarativo al corpo del metodo. Per un esempio semplice, questo potrebbe non fare una grande differenza. Tuttavia, dobbiamo considerare l'interfaccia dell'intero servizio di gioco e non solo il metodo join().
class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { synchronized (tables) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } public void leave(Player player, Table table) {/* body skipped for brevity */} public void createTable() {/* body skipped for brevity */} public void destroyTable(Table table) {/* body skipped for brevity */} }
Una piccola modifica potrebbe sembrare insignificante, ma potrebbe influenzare il comportamento dell'intera classe. Indipendentemente dal momento in cui un giocatore si unisce al tavolo di gioco, il metodo di sincronizzazione precedente bloccava l'intero istante di GameServer, causando competizione con quei giocatori che cercano di lasciare il tavolo. Spostare il blocco di locking dal dichiarativo al corpo del metodo ritarda il caricamento del blocco di locking, riducendo così la probabilità di competizione.
Ridurre l'area di azione del blocco di locking
Ora, quando siamo sicuri che dobbiamo proteggere i dati e non il programma, dobbiamo assicurarci di aggiungere il blocco di locking solo dove è necessario - ad esempio, quando il codice viene ristrutturato:
public class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { synchronized (tables) { List<Player> tablePlayers = tables.get(table.getId()); if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } //Altri metodi omessi per brevità }
Di conseguenza, il codice che contiene il controllo del saldo del conto del giocatore (che potrebbe causare operazioni di I/O) è stato spostato al di fuori dell'area di controllo del blocco di locking. Attenzione, ora il blocco di locking viene utilizzato solo per prevenire che il numero di giocatori superi il numero massimo di posti al tavolo, il controllo del saldo del conto non fa più parte di questa misura di protezione.
Blocco di locking separato
Puoi vedere chiaramente dall'ultima riga del codice dell'esempio sopra che l'intera struttura dei dati è protetta da uno stesso blocco di locking. Considerando che in questo tipo di struttura potrebbero esserci migliaia di tavoli di gioco, e dobbiamo garantire che il numero di persone a ciascun tavolo non superi la capacità, in questo caso c'è ancora un rischio molto alto di eventi di competizione.
C'è un modo semplice per farlo: introdurre un lock separato per ogni tavolo, come nell'esempio seguente:}}
public class GameServer { public Map<String, List<Player>> tables = new HashMap<String, List<Player>>(); public void join(Player player, Table table) { if (player.getAccountBalance() > table.getLimit()) { List<Player> tablePlayers = tables.get(table.getId()); synchronized (tablePlayers) { if (tablePlayers.size() < 9) { tablePlayers.add(player); } } } } //Altri metodi omessi per brevità }
Ora, sincronizziamo solo l'accessibilità di un singolo tavolo invece di tutti i tavoli, riducendo significativamente la probabilità di competizione per i lock. Prendiamo un esempio specifico: ora, nella nostra struttura dati ci sono 100 istanze di tavoli, quindi la probabilità di competizione è ora 100 volte inferiore rispetto prima.
Utilizzo di strutture dati thread-safe
Un altro punto che può essere migliorato è abbandonare la struttura dati tradizionale a singolo thread, sostituendola con una struttura dati chiaramente progettata per essere thread-safe. Ad esempio, quando si utilizza ConcurrentHashMap per memorizzare le istanze delle tue tavole, il codice potrebbe essere come segue:
public class GameServer { public Map<String, List<Player>> tables = new ConcurrentHashMap<String, List<Player>>(); public synchronized void join(Player player, Table table) {/*Corpo del metodo omesso per brevità*/} public synchronized void leave(Player player, Table table) {/*Corpo del metodo omesso per brevità*/} public synchronized void createTable() { Table table = new Table(); tables.put(table.getId(), table); } public synchronized void destroyTable(Table table) { tables.remove(table.getId()); } }
Il blocco di sincronizzazione all'interno dei metodi join() e leave() è lo stesso degli esempi precedenti, perché dobbiamo garantire l'integrità dei dati di un singolo tavolo. ConcurrentHashMap non aiuta in questo senso. Tuttavia, utilizzeremo ConcurrentHashMap per creare e distruggere nuovi tavoli nei metodi increaseTable() e destoryTable(), tutte queste operazioni sono completamente sincrone per ConcurrentHashMap, che ci permette di aggiungere o ridurre il numero di tavoli in modo parallelo.
Altri suggerimenti e tecniche
Ridurre la visibilità del lock. Nei nostri esempi, il lock è dichiarato come public (visibile all'esterno), il che potrebbe permettere a persone con intenzioni malevoli di rompere il vostro lavoro aggiungendo lock ai monitori ben progettati.
Esaminiamo l'API di java.util.concurrent.locks per vedere se ci sono altre strategie di lock già implementate, utilizzandole per migliorare la soluzione precedente.
Utilizzare operazioni atomiche. Nel contatore di incrementi semplice che stiamo utilizzando non è richiesto di utilizzare lock. Nei nostri esempi è più adatto utilizzare AtomicInteger invece di Integer come contatore.
Ultimo punto, indipendentemente dal fatto che stiate utilizzando la soluzione di rilevamento automatico dei deadlock di Plumber o ottenendo informazioni sulle soluzioni da uno staccato manuale, spero che questo articolo possa aiutarvi a risolvere i problemi di competizione dei lock.
Grazie per aver letto, spero che questo possa aiutarvi, grazie per il supporto al nostro sito!