JavaRush /Java Blog /Random-IT /Non puoi rovinare Java con un thread: Parte II - sincroni...
Viacheslav
Livello 3

Non puoi rovinare Java con un thread: Parte II - sincronizzazione

Pubblicato nel gruppo Random-IT

introduzione

Quindi, sappiamo che ci sono thread in Java, di cui puoi leggere nella recensione " Non puoi rovinare Java con un thread: Parte I - Thread ". I thread sono necessari per svolgere il lavoro simultaneamente. Pertanto, è molto probabile che i thread interagiscano in qualche modo tra loro. Capiamo come ciò avviene e quali controlli di base abbiamo. Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 1

Prodotto

Il metodo Thread.yield() è misterioso e usato raramente. Esistono molte varianti della sua descrizione su Internet. Al punto che alcuni scrivono di una sorta di coda di thread, in cui il thread si sposterà verso il basso tenendo conto delle loro priorità. Qualcuno scrive che il thread cambierà il suo stato da in esecuzione a eseguibile (sebbene non vi sia alcuna divisione in questi stati e Java non li distingue). Ma in realtà tutto è molto più sconosciuto e, in un certo senso, più semplice. Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 2In merito alla documentazione del metodo, yieldè presente un bug " JDK-6416721: (spec thread) Fix Thread.yield() javadoc ". Se lo leggi, è chiaro che in realtà il metodo yieldtrasmette solo alcune raccomandazioni allo scheduler del thread Java che a questo thread può essere assegnato un tempo di esecuzione inferiore. Ma ciò che accadrà effettivamente, se lo scheduler ascolterà la raccomandazione e cosa farà in generale, dipende dall'implementazione della JVM e del sistema operativo. O forse da altri fattori. Tutta la confusione era molto probabilmente dovuta al ripensamento del multithreading durante lo sviluppo del linguaggio Java. Puoi leggere di più nella recensione " Breve introduzione a Java Thread.yield() ".

Sonno: filo conduttore per addormentarsi

Un thread potrebbe addormentarsi durante la sua esecuzione. Questo è il tipo più semplice di interazione con altri thread. Il sistema operativo su cui è installata la Java virtual machine, dove viene eseguito il codice Java, dispone di un proprio thread scheduler, chiamato Thread Scheduler. È lui che decide quale thread eseguire e quando. Il programmatore non può interagire con questo scheduler direttamente dal codice Java, ma può, attraverso la JVM, chiedere allo scheduler di mettere in pausa il thread per un po', per “metterlo in stop”. Puoi leggere di più negli articoli " Thread.sleep() " e " Come funziona il multithreading ". Inoltre, puoi scoprire come funzionano i thread nel sistema operativo Windows: " Interni del thread di Windows ". Ora lo vedremo con i nostri occhi. Salviamo il seguente codice in un file HelloWorldApp.java:
class HelloWorldApp {
    public static void main(String []args) {
        Runnable task = () -> {
            try {
                int secToWait = 1000 * 60;
                Thread.currentThread().sleep(secToWait);
                System.out.println("Waked up");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };
        Thread thread = new Thread(task);
        thread.start();
    }
}
Come puoi vedere, abbiamo un'attività che attende 60 secondi, trascorsi i quali il programma termina. Compiliamo javac HelloWorldApp.javaed eseguiamo java HelloWorldApp. È meglio avviare in una finestra separata. Ad esempio, su Windows sarebbe così: start java HelloWorldApp. Utilizzando il comando jps, troviamo il PID del processo e apriamo l'elenco dei thread utilizzando jvisualvm --openpid pidПроцесса: Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 3Come puoi vedere, il nostro thread è entrato nello stato Sleeping. In effetti, dormire il thread corrente può essere fatto in modo più bello:
try {
	TimeUnit.SECONDS.sleep(60);
	System.out.println("Waked up");
} catch (InterruptedException e) {
	e.printStackTrace();
}
Probabilmente hai notato che elaboriamo ovunque InterruptedException? Capiamo perché.

Interruzione di un thread o Thread.interrupt

Il fatto è che mentre il thread attende nel sonno, qualcuno potrebbe voler interrompere questa attesa. In questo caso, gestiamo tale eccezione. Ciò è stato fatto dopo che il metodo Thread.stopè stato dichiarato deprecato, ovvero obsoleto e indesiderabile per l'uso. La ragione di ciò era che quando veniva chiamato il metodo, stopil thread veniva semplicemente “ucciso”, il che era molto imprevedibile. Non potevamo sapere quando il flusso sarebbe stato interrotto, non potevamo garantire la coerenza dei dati. Immagina di scrivere dati su un file e poi il flusso viene distrutto. Pertanto, hanno deciso che sarebbe stato più logico non interrompere il flusso, ma informarlo che avrebbe dovuto essere interrotto. Come reagire a questo dipende dal flusso stesso. Maggiori dettagli possono essere trovati in " Perché Thread.stop è deprecato " di Oracle ? Diamo un'occhiata ad un esempio:
public static void main(String []args) {
	Runnable task = () -> {
		try {
			TimeUnit.SECONDS.sleep(60);
		} catch (InterruptedException e) {
			System.out.println("Interrupted");
		}
	};
	Thread thread = new Thread(task);
	thread.start();
	thread.interrupt();
}
In questo esempio, non aspetteremo 60 secondi, ma stamperemo immediatamente "Interrotto". Questo perché abbiamo chiamato il metodo del thread interrupt. Questo metodo imposta il "flag interno chiamato stato di interruzione". Cioè ogni thread ha un flag interno che non è direttamente accessibile. Ma abbiamo metodi nativi per interagire con questo flag. Ma questo non è l'unico modo. Un thread può essere in fase di esecuzione, non in attesa di qualcosa, ma semplicemente eseguendo azioni. Ma può prevedere che essi vogliano portarlo a termine ad un certo punto dei suoi lavori. Per esempio:
public static void main(String []args) {
	Runnable task = () -> {
		while(!Thread.currentThread().isInterrupted()) {
			//Do some work
		}
		System.out.println("Finished");
	};
	Thread thread = new Thread(task);
	thread.start();
	thread.interrupt();
}
Nell'esempio sopra, puoi vedere che il ciclo whileverrà eseguito finché il thread non verrà interrotto esternamente. La cosa importante da sapere sul flag isInterrupted è che se lo prendiamo InterruptedException, il flag isInterruptedviene resettato e quindi isInterruptedrestituirà false. Esiste anche un metodo statico nella classe Thread che si applica solo al thread corrente - Thread.interrupted() , ma questo metodo reimposta il flag su false! Puoi leggere di più nel capitolo " Interruzione del thread ".

Partecipa: in attesa del completamento di un altro thread

Il tipo più semplice di attesa è attendere il completamento di un altro thread.
public static void main(String []args) throws InterruptedException {
	Runnable task = () -> {
		try {
			TimeUnit.SECONDS.sleep(5);
		} catch (InterruptedException e) {
			System.out.println("Interrupted");
		}
	};
	Thread thread = new Thread(task);
	thread.start();
	thread.join();
	System.out.println("Finished");
}
In questo esempio, il nuovo thread resterà inattivo per 5 secondi. Allo stesso tempo, il thread principale attenderà finché il thread dormiente non si sveglierà e finirà il suo lavoro. Se guardi attraverso JVisualVM, lo stato del thread sarà simile a questo: Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 4Grazie agli strumenti di monitoraggio, puoi vedere cosa sta succedendo al thread. Il metodo joinè abbastanza semplice, perché è semplicemente un metodo con codice Java che viene eseguito waitmentre il thread su cui viene chiamato è vivo. Una volta che il thread muore (al termine), l'attesa termina. Questa è tutta la magia del metodo join. Passiamo quindi alla parte più interessante.

Monitor concettuale

Nel multithreading esiste qualcosa come Monitor. In generale, la parola monitor è tradotta dal latino come “sorvegliante” o “sorvegliante”. Nell'ambito di questo articolo cercheremo di ricordare l'essenza e, per chi lo desidera, chiedo di immergersi nel materiale dai link per i dettagli. Iniziamo il nostro viaggio con la specifica del linguaggio Java, ovvero con JLS: " 17.1. Sincronizzazione ". Dice quanto segue: Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 5Risulta che ai fini della sincronizzazione tra i thread, Java utilizza un certo meccanismo chiamato "Monitor". A ogni oggetto è associato un monitor e i thread possono bloccarlo o sbloccarlo. Successivamente, troveremo un tutorial di formazione sul sito Web Oracle: " Blocchi intrinseci e sincronizzazione ". Questo tutorial spiega che la sincronizzazione in Java è costruita attorno a un'entità interna nota come blocco intrinseco o blocco del monitor. Spesso tale serratura viene semplicemente chiamata "monitor". Vediamo anche ancora una volta che ogni oggetto in Java ha un blocco intrinseco ad esso associato. Puoi leggere " Java - Blocchi intrinseci e sincronizzazione ". Successivamente, è importante capire come un oggetto in Java può essere associato a un monitor. Ogni oggetto in Java ha un'intestazione, una sorta di metadati interni che non sono disponibili al programmatore dal codice, ma di cui la macchina virtuale ha bisogno per funzionare correttamente con gli oggetti. L'intestazione dell'oggetto include una MarkWord simile alla seguente: Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 6

https://edu.netbeans.org/contrib/slides/java-overview-and-java-se6.pdf

Un articolo di Habr è molto utile qui: " Ma come funziona il multithreading? Parte I: sincronizzazione ". A questo articolo vale la pena aggiungere una descrizione dal Riepilogo del blocco attività del bugtaker JDK: " JDK-8183909 ". Puoi leggere la stessa cosa in " JEP-8183909 ". Quindi, in Java, un monitor è associato a un oggetto e il thread può bloccare questo thread, oppure dire anche "ottieni un blocco". L'esempio più semplice:
public class HelloWorld{
    public static void main(String []args){
        Object object = new Object();
        synchronized(object) {
            System.out.println("Hello World");
        }
    }
}
Quindi, utilizzando la parola chiave, synchronizedil thread corrente (in cui vengono eseguite queste righe di codice) tenta di utilizzare il monitor associato all'oggetto objecte di "ottenere un blocco" o "catturare il monitor" (la seconda opzione è addirittura preferibile). Se non c'è contesa per il monitor (ovvero nessun altro vuole sincronizzarsi sullo stesso oggetto), Java può provare a eseguire un'ottimizzazione chiamata "blocco distorto". Il titolo dell'oggetto in Mark Word conterrà il tag corrispondente e un record del thread a cui è collegato il monitor. Ciò riduce il sovraccarico durante l'acquisizione del monitor. Se il monitor è già stato collegato a un altro thread in precedenza, questo blocco non è sufficiente. La JVM passa al tipo di blocco successivo: blocco di base. Utilizza operazioni di confronto e scambio (CAS). Allo stesso tempo, l'intestazione in Mark Word non memorizza più Mark Word stesso, ma un collegamento alla sua archiviazione + il tag viene modificato in modo che la JVM comprenda che stiamo utilizzando il blocco di base. Se è presente un conflitto per il monitor di diversi thread (uno ha catturato il monitor e il secondo è in attesa che il monitor venga rilasciato), il tag in Mark Word cambia e Mark Word inizia a memorizzare un riferimento al monitor come un oggetto: un'entità interna della JVM. Come indicato nel JEP, in questo caso è necessario spazio nell'area di memoria Native Heap per memorizzare questa entità. Il collegamento alla posizione di archiviazione di questa entità interna si troverà nell'oggetto Segna parola. Pertanto, come vediamo, il monitor è in realtà un meccanismo per garantire la sincronizzazione dell'accesso di più thread alle risorse condivise. Esistono diverse implementazioni di questo meccanismo tra cui la JVM passa. Quindi, per semplicità, quando si parla di monitor si parla in realtà di serrature. Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 7

Sincronizzato e in attesa tramite blocco

Il concetto di monitor, come abbiamo visto in precedenza, è strettamente correlato al concetto di “blocco di sincronizzazione” (o, come viene anche chiamato, sezione critica). Diamo un'occhiata ad un esempio:
public static void main(String[] args) throws InterruptedException {
	Object lock = new Object();

	Runnable task = () -> {
		synchronized (lock) {
			System.out.println("thread");
		}
	};

	Thread th1 = new Thread(task);
	th1.start();
	synchronized (lock) {
		for (int i = 0; i < 8; i++) {
			Thread.currentThread().sleep(1000);
			System.out.print("  " + i);
		}
		System.out.println(" ...");
	}
}
Qui, il thread principale invia prima l'attività a un nuovo thread, quindi "cattura" immediatamente il blocco ed esegue con esso un'operazione lunga (8 secondi). Per tutto questo tempo, l'attività non può entrare nel blocco per la sua esecuzione synchronized, perché la serratura è già occupata. Se un thread non riesce a ottenere un blocco, lo attenderà sul monitor. Non appena lo riceve, continuerà l'esecuzione. Quando un thread lascia il monitor, rilascia il blocco. In JVisualVM sarebbe simile a questo: Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 8Come puoi vedere, lo stato in JVisualVM si chiama "Monitor" perché il thread è bloccato e non può occupare il monitor. Puoi anche scoprire lo stato del thread nel codice, ma il nome di questo stato non coincide con i termini JVisualVM, sebbene siano simili. In questo caso, th1.getState()il ciclo forrestituirà BLOCKED , perché Mentre il ciclo è in esecuzione, il monitor lockè occupato maindal thread e il thread th1è bloccato e non può continuare a funzionare finché non viene restituito il blocco. Oltre ai blocchi di sincronizzazione è possibile sincronizzare un intero metodo. Ad esempio, un metodo della classe HashTable:
public synchronized int size() {
	return count;
}
In un'unità di tempo, questo metodo verrà eseguito da un solo thread. Ma ci serve una serratura, giusto? Sì, ne ho bisogno. Nel caso dei metodi oggetto, il blocco sarà this. C'è una discussione interessante su questo argomento: " C'è un vantaggio nell'usare un metodo sincronizzato invece di un blocco sincronizzato? ". Se il metodo è statico, il lock non lo sarà this(poiché per un metodo statico non può essere this), ma l'oggetto della classe (ad esempio Integer.class).

Aspetta e aspetta sul monitor. I metodi notify e notifyAll

Il thread ha un altro metodo di attesa, che è connesso al monitor. A differenza di sleepe join, non può essere semplicemente chiamato. E il suo nome è wait. Il metodo viene eseguito waitsull'oggetto sul cui monitor vogliamo attendere. Vediamo un esempio:
public static void main(String []args) throws InterruptedException {
	    Object lock = new Object();
	    // task будет ждать, пока его не оповестят через lock
	    Runnable task = () -> {
	        synchronized(lock) {
	            try {
	                lock.wait();
	            } catch(InterruptedException e) {
	                System.out.println("interrupted");
	            }
	        }
	        // После оповещения нас мы будем ждать, пока сможем взять лок
	        System.out.println("thread");
	    };
	    Thread taskThread = new Thread(task);
	    taskThread.start();
        // Ждём и после этого забираем себе лок, оповещаем и отдаём лок
	    Thread.currentThread().sleep(3000);
	    System.out.println("main");
	    synchronized(lock) {
	        lock.notify();
	    }
}
In JVisualVM apparirà così: Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 10Per capire come funziona, dovresti ricordare che i metodi waitsi riferiscono notifya java.lang.Object. Sembra strano che i metodi relativi ai thread siano nel formato Object. Ma qui sta la risposta. Come ricordiamo, ogni oggetto in Java ha un'intestazione. L'intestazione contiene varie informazioni di servizio, comprese le informazioni sul monitor, ovvero i dati sullo stato di blocco. E come ricordiamo, ogni oggetto (cioè ogni istanza) ha un'associazione con un'entità JVM interna chiamata blocco intrinseco, chiamato anche monitor. Nell'esempio sopra, l'attività descrive l'inserimento del blocco di sincronizzazione sul monitor associato a lock. Se è possibile ottenere un blocco su questo monitor, allora wait. Il thread che esegue questa attività rilascerà il monitor lock, ma si unirà alla coda dei thread in attesa di notifica sul monitor lock. Questa coda di thread si chiama WAIT-SET, che più correttamente ne riflette l'essenza. È più un set che una coda. Il thread maincrea un nuovo thread con l'attività task, lo avvia e attende 3 secondi. Ciò consente, con un alto grado di probabilità, a un nuovo thread di afferrare il lock prima del thread maine di mettersi in coda sul monitor. Dopodiché il thread mainstesso entra nel blocco di sincronizzazione locked esegue la notifica del thread sul monitor. Dopo l'invio della notifica, il thread mainrilascia il monitor locke il nuovo thread (che era in precedenza in attesa) lockcontinua l'esecuzione dopo aver atteso il rilascio del monitor. È possibile inviare una notifica solo a uno dei thread ( notify) o a tutti i thread in coda contemporaneamente ( notifyAll). Puoi leggere di più in " Differenza tra notify() e notifyAll() in Java ". È importante notare che l'ordine di notifica dipende dall'implementazione della JVM. Puoi leggere di più in " Come risolvere la fame con Notify e Notifyall? ". La sincronizzazione può essere eseguita senza specificare un oggetto. Questo può essere fatto quando non viene sincronizzata una sezione separata di codice, ma un intero metodo. Ad esempio, per i metodi statici il lock sarà l'oggetto classe (ottenuto tramite .class):
public static synchronized void printA() {
	System.out.println("A");
}
public static void printB() {
	synchronized(HelloWorld.class) {
		System.out.println("B");
	}
}
In termini di utilizzo dei blocchi, entrambi i metodi sono gli stessi. Se il metodo non è statico, la sincronizzazione verrà eseguita in base alla corrente instance, ovvero in base a this. A proposito, prima abbiamo detto che usando il metodo getStatepuoi ottenere lo stato di un thread. Quindi ecco un thread che viene accodato dal monitor, lo stato sarà WAITING o TIMED_WAITING se il metodo waitha specificato un limite di tempo di attesa. Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 11

Ciclo di vita di un thread

Come abbiamo visto, il flusso cambia il suo stato nel corso della vita. In sostanza, questi cambiamenti sono il ciclo di vita del thread. Quando un thread è appena stato creato, ha lo stato NUOVO. In questa posizione non è ancora stato avviato e Java Thread Scheduler non sa ancora nulla del nuovo thread. Affinché lo scheduler dei thread possa conoscere un thread, è necessario chiamare il metodo thread.start(). Quindi il thread entrerà nello stato RUNNABLE. Esistono molti schemi errati su Internet in cui gli stati Eseguibile e In esecuzione sono separati. Ma questo è un errore, perché... Java non distingue tra gli stati "pronto per l'esecuzione" e "in esecuzione". Quando un thread è vivo ma non attivo (non eseguibile), si trova in uno dei due stati:
  • BLOCCATO - attende l'ingresso in una sezione protetta, cioè al synchonizedblocco.
  • WAITING: attende un altro thread in base a una condizione. Se la condizione è vera, lo scheduler del thread avvia il thread.
Se un thread è in attesa in base al tempo, è nello stato TIMED_WAITING. Se il thread non è più in esecuzione (completato con successo o con un'eccezione), passa allo stato TERMINATED. Per scoprire lo stato di un thread (il suo stato), viene utilizzato il metodo getState. I thread hanno anche un metodo isAliveche restituisce true se il thread non è terminato.

LockSupport e parcheggio del thread

A partire da Java 1.6 esisteva un meccanismo interessante chiamato LockSupport . Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 12Questa classe associa un "permesso" o un permesso a ciascun thread che lo utilizza. La chiamata al metodo parkritorna immediatamente se è disponibile un permesso, occupando lo stesso permesso durante la chiamata. Altrimenti è bloccato. La chiamata al metodo unparkrende disponibile il permesso se non è già disponibile. Esiste solo 1 permesso. Nell'API Java, LockSupportun certo Semaphore. Diamo un'occhiata a un semplice esempio:
import java.util.concurrent.Semaphore;
public class HelloWorldApp{

    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(0);
        try {
            semaphore.acquire();
        } catch (InterruptedException e) {
            // Просим разрешение и ждём, пока не получим его
            e.printStackTrace();
        }
        System.out.println("Hello, World!");
    }
}
Questo codice attenderà per sempre perché il semaforo ora ha 0 permessi. E quando viene chiamato nel codice acquire(ad esempio, richiede l'autorizzazione), il thread attende finché non riceve l'autorizzazione. Poiché stiamo aspettando, siamo obbligati a elaborarlo InterruptedException. È interessante notare che un semaforo implementa uno stato di thread separato. Se guardiamo in JVisualVM, vedremo che il nostro stato non è Wait, ma Park. Non puoi rovinare Java con un thread: Parte II - sincronizzazione - 13Diamo un'occhiata a un altro esempio:
public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            //Запаркуем текущий поток
            System.err.println("Will be Parked");
            LockSupport.park();
            // Как только нас распаркуют - начнём действовать
            System.err.println("Unparked");
        };
        Thread th = new Thread(task);
        th.start();
        Thread.currentThread().sleep(2000);
        System.err.println("Thread state: " + th.getState());

        LockSupport.unpark(th);
        Thread.currentThread().sleep(2000);
}
Lo stato del thread sarà WAITING, ma JVisualVM distingue waittra from synchronizede parkfrom LockSupport. Perché questo è così importante LockSupport? Torniamo nuovamente all'API Java e osserviamo Thread State WAITING . Come puoi vedere, ci sono solo tre modi per accedervi. 2 modi: questo waite join. E il terzo è LockSupport. I lock in Java si basano sugli stessi principi LockSupporte rappresentano strumenti di livello superiore. Proviamo ad usarne uno. Diamo un'occhiata, ad esempio, a ReentrantLock:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class HelloWorld{

    public static void main(String []args) throws InterruptedException {
        Lock lock = new ReentrantLock();
        Runnable task = () -> {
            lock.lock();
            System.out.println("Thread");
            lock.unlock();
        };
        lock.lock();

        Thread th = new Thread(task);
        th.start();
        System.out.println("main");
        Thread.currentThread().sleep(2000);
        lock.unlock();
    }
}
Come negli esempi precedenti, qui tutto è semplice. lockattende che qualcuno rilasci una risorsa. Se guardiamo in JVisualVM, vedremo che il nuovo thread verrà parcheggiato finché mainil thread non gli darà il lock. Puoi leggere ulteriori informazioni sui lock qui: " Programmazione multithread in Java 8. Parte seconda. Sincronizzazione dell'accesso a oggetti mutabili " e " Java Lock API. Teoria ed esempio di utilizzo ". Per comprendere meglio l'implementazione dei blocchi, è utile leggere informazioni su Phazer nella panoramica " Classe Phaser ". E a proposito dei vari sincronizzatori, dovete leggere l'articolo su Habré “ Java.util.concurrent.* Synchronizers Reference ”.

Totale

In questa recensione, abbiamo esaminato i principali modi in cui i thread interagiscono in Java. Materiale aggiuntivo: #Viacheslav
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION